Skip to main content

max / pom

Version bump to 0.2.2
Co-Authored-By
Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-12 02:03 UTC
Commit: d7fcbc6bb36c3425ab34e092ee663dcdc8c34f63
Parent: a75999e
25 files changed, +5679 insertions, -474 deletions
M .gitignore +7
@@ -2,3 +2,10 @@
2 2 *.db
3 3 *.db-wal
4 4 *.db-shm
5 +
6 + # OS
7 + .DS_Store
8 +
9 + # IDE
10 + .idea/
11 + .vscode/
M Cargo.lock +313 -4
@@ -76,6 +76,51 @@
76 76 "windows-sys 0.61.2",
77 77 ]
78 78
79 + [[package]]
80 + name = "anyhow"
81 + version = "1.0.102"
82 + source = "registry+https://github.com/rust-lang/crates.io-index"
83 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
84 +
85 + [[package]]
86 + name = "asn1-rs"
87 + version = "0.6.2"
88 + source = "registry+https://github.com/rust-lang/crates.io-index"
89 + checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
90 + dependencies = [
91 + "asn1-rs-derive",
92 + "asn1-rs-impl",
93 + "displaydoc",
94 + "nom",
95 + "num-traits",
96 + "rusticata-macros",
97 + "thiserror 1.0.69",
98 + "time",
99 + ]
100 +
101 + [[package]]
102 + name = "asn1-rs-derive"
103 + version = "0.5.1"
104 + source = "registry+https://github.com/rust-lang/crates.io-index"
105 + checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
106 + dependencies = [
107 + "proc-macro2",
108 + "quote",
109 + "syn",
110 + "synstructure",
111 + ]
112 +
113 + [[package]]
114 + name = "asn1-rs-impl"
115 + version = "0.2.0"
116 + source = "registry+https://github.com/rust-lang/crates.io-index"
117 + checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
118 + dependencies = [
119 + "proc-macro2",
120 + "quote",
121 + "syn",
122 + ]
123 +
79 124 [[package]]
80 125 name = "atoi"
81 126 version = "2.0.0"
@@ -97,6 +142,78 @@
97 142 source = "registry+https://github.com/rust-lang/crates.io-index"
98 143 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
99 144
145 + [[package]]
146 + name = "aws-lc-rs"
147 + version = "1.16.1"
148 + source = "registry+https://github.com/rust-lang/crates.io-index"
149 + checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
150 + dependencies = [
151 + "aws-lc-sys",
152 + "zeroize",
153 + ]
154 +
155 + [[package]]
156 + name = "aws-lc-sys"
157 + version = "0.38.0"
158 + source = "registry+https://github.com/rust-lang/crates.io-index"
159 + checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
160 + dependencies = [
161 + "cc",
162 + "cmake",
163 + "dunce",
164 + "fs_extra",
165 + ]
166 +
167 + [[package]]
168 + name = "axum"
169 + version = "0.8.8"
170 + source = "registry+https://github.com/rust-lang/crates.io-index"
171 + checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
172 + dependencies = [
173 + "axum-core",
174 + "bytes",
175 + "form_urlencoded",
176 + "futures-util",
177 + "http",
178 + "http-body",
179 + "http-body-util",
180 + "hyper",
181 + "hyper-util",
182 + "itoa",
183 + "matchit",
184 + "memchr",
185 + "mime",
186 + "percent-encoding",
187 + "pin-project-lite",
188 + "serde_core",
189 + "serde_json",
190 + "serde_path_to_error",
191 + "serde_urlencoded",
192 + "sync_wrapper",
193 + "tokio",
194 + "tower",
195 + "tower-layer",
196 + "tower-service",
197 + ]
198 +
199 + [[package]]
200 + name = "axum-core"
201 + version = "0.5.6"
202 + source = "registry+https://github.com/rust-lang/crates.io-index"
203 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
204 + dependencies = [
205 + "bytes",
206 + "futures-core",
207 + "http",
208 + "http-body",
209 + "http-body-util",
210 + "mime",
211 + "pin-project-lite",
212 + "sync_wrapper",
213 + "tower-layer",
214 + "tower-service",
215 + ]
216 +
100 217 [[package]]
101 218 name = "base64"
102 219 version = "0.21.7"
@@ -158,6 +275,8 @@
158 275 checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
159 276 dependencies = [
160 277 "find-msvc-tools",
278 + "jobserver",
279 + "libc",
161 280 "shlex",
162 281 ]
163 282
@@ -227,6 +346,15 @@
227 346 source = "registry+https://github.com/rust-lang/crates.io-index"
228 347 checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
229 348
349 + [[package]]
350 + name = "cmake"
351 + version = "0.1.57"
352 + source = "registry+https://github.com/rust-lang/crates.io-index"
353 + checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
354 + dependencies = [
355 + "cc",
356 + ]
357 +
230 358 [[package]]
231 359 name = "colorchoice"
232 360 version = "1.0.4"
@@ -303,6 +431,12 @@
303 431 "typenum",
304 432 ]
305 433
434 + [[package]]
435 + name = "data-encoding"
436 + version = "2.10.0"
437 + source = "registry+https://github.com/rust-lang/crates.io-index"
438 + checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
439 +
306 440 [[package]]
307 441 name = "der"
308 442 version = "0.7.10"
@@ -314,6 +448,29 @@
314 448 "zeroize",
315 449 ]
316 450
451 + [[package]]
452 + name = "der-parser"
453 + version = "9.0.0"
454 + source = "registry+https://github.com/rust-lang/crates.io-index"
455 + checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
456 + dependencies = [
457 + "asn1-rs",
458 + "displaydoc",
459 + "nom",
460 + "num-bigint",
461 + "num-traits",
462 + "rusticata-macros",
463 + ]
464 +
465 + [[package]]
466 + name = "deranged"
467 + version = "0.5.8"
468 + source = "registry+https://github.com/rust-lang/crates.io-index"
469 + checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
470 + dependencies = [
471 + "powerfmt",
472 + ]
473 +
317 474 [[package]]
318 475 name = "digest"
319 476 version = "0.10.7"
@@ -364,6 +521,12 @@
364 521 source = "registry+https://github.com/rust-lang/crates.io-index"
365 522 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
366 523
524 + [[package]]
525 + name = "dunce"
526 + version = "1.0.5"
527 + source = "registry+https://github.com/rust-lang/crates.io-index"
528 + checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
529 +
367 530 [[package]]
368 531 name = "dyn-clone"
369 532 version = "1.0.20"
@@ -449,6 +612,12 @@
449 612 "percent-encoding",
450 613 ]
451 614
615 + [[package]]
616 + name = "fs_extra"
617 + version = "1.3.0"
618 + source = "registry+https://github.com/rust-lang/crates.io-index"
619 + checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
620 +
452 621 [[package]]
453 622 name = "futures"
454 623 version = "0.3.32"
@@ -580,11 +749,24 @@
580 749 "cfg-if",
581 750 "js-sys",
582 751 "libc",
583 - "r-efi",
752 + "r-efi 5.3.0",
584 753 "wasip2",
585 754 "wasm-bindgen",
586 755 ]
587 756
757 + [[package]]
758 + name = "getrandom"
759 + version = "0.4.2"
760 + source = "registry+https://github.com/rust-lang/crates.io-index"
761 + checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
762 + dependencies = [
763 + "cfg-if",
764 + "libc",
765 + "r-efi 6.0.0",
766 + "wasip2",
767 + "wasip3",
768 + ]
769 +
588 770 [[package]]
589 771 name = "hashbrown"
590 772 version = "0.15.5"
@@ -650,6 +832,17 @@
650 832 "windows-sys 0.61.2",
651 833 ]
652 834
835 + [[package]]
836 + name = "hostname"
837 + version = "0.4.2"
838 + source = "registry+https://github.com/rust-lang/crates.io-index"
839 + checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
840 + dependencies = [
841 + "cfg-if",
842 + "libc",
843 + "windows-link",
844 + ]
845 +
653 846 [[package]]
654 847 name = "http"
655 848 version = "1.4.0"
@@ -689,6 +882,12 @@
689 882 source = "registry+https://github.com/rust-lang/crates.io-index"
690 883 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
691 884
885 + [[package]]
886 + name = "httpdate"
887 + version = "1.0.3"
888 + source = "registry+https://github.com/rust-lang/crates.io-index"
889 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
890 +
692 891 [[package]]
693 892 name = "hyper"
694 893 version = "1.8.1"
@@ -702,6 +901,7 @@
702 901 "http",
703 902 "http-body",
704 903 "httparse",
904 + "httpdate",
705 905 "itoa",
706 906 "pin-project-lite",
707 907 "pin-utils",
@@ -855,6 +1055,12 @@
855 1055 "zerovec",
856 1056 ]
857 1057
1058 + [[package]]
1059 + name = "id-arena"
1060 + version = "2.3.0"
1061 + source = "registry+https://github.com/rust-lang/crates.io-index"
1062 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
1063 +
858 1064 [[package]]
859 1065 name = "idna"
860 1066 version = "1.1.0"
@@ -884,6 +1090,8 @@
884 1090 dependencies = [
885 1091 "equivalent",
886 1092 "hashbrown 0.16.1",
1093 + "serde",
1094 + "serde_core",
887 1095 ]
888 1096
889 1097 [[package]]
@@ -914,6 +1122,16 @@
914 1122 source = "registry+https://github.com/rust-lang/crates.io-index"
915 1123 checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
916 1124
1125 + [[package]]
1126 + name = "jobserver"
1127 + version = "0.1.34"
1128 + source = "registry+https://github.com/rust-lang/crates.io-index"
1129 + checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
1130 + dependencies = [
1131 + "getrandom 0.3.4",
1132 + "libc",
1133 + ]
1134 +
917 1135 [[package]]
918 1136 name = "js-sys"
919 1137 version = "0.3.91"
@@ -933,6 +1151,12 @@
933 1151 "spin",
934 1152 ]
935 1153
1154 + [[package]]
1155 + name = "leb128fmt"
1156 + version = "0.1.0"
1157 + source = "registry+https://github.com/rust-lang/crates.io-index"
1158 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
1159 +
936 1160 [[package]]
937 1161 name = "libc"
938 1162 version = "0.2.183"
@@ -1004,6 +1228,12 @@
1004 1228 "regex-automata",
1005 1229 ]
1006 1230
1231 + [[package]]
1232 + name = "matchit"
1233 + version = "0.8.4"
1234 + source = "registry+https://github.com/rust-lang/crates.io-index"
1235 + checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
1236 +
1007 1237 [[package]]
1008 1238 name = "md-5"
1009 1239 version = "0.10.6"
@@ -1020,6 +1250,18 @@
1020 1250 source = "registry+https://github.com/rust-lang/crates.io-index"
1021 1251 checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
1022 1252
1253 + [[package]]
1254 + name = "mime"
1255 + version = "0.3.17"
1256 + source = "registry+https://github.com/rust-lang/crates.io-index"
1257 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1258 +
1259 + [[package]]
1260 + name = "minimal-lexical"
1261 + version = "0.2.1"
1262 + source = "registry+https://github.com/rust-lang/crates.io-index"
1263 + checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1264 +
1023 1265 [[package]]
1024 1266 name = "mio"
1025 1267 version = "1.1.1"
@@ -1031,6 +1273,16 @@
1031 1273 "windows-sys 0.61.2",
1032 1274 ]
1033 1275
1276 + [[package]]
1277 + name = "nom"
1278 + version = "7.1.3"
1279 + source = "registry+https://github.com/rust-lang/crates.io-index"
1280 + checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
1281 + dependencies = [
1282 + "memchr",
1283 + "minimal-lexical",
1284 + ]
1285 +
1034 1286 [[package]]
1035 1287 name = "nu-ansi-term"
1036 1288 version = "0.50.3"
@@ -1040,6 +1292,16 @@
1040 1292 "windows-sys 0.61.2",
1041 1293 ]
1042 1294
1295 + [[package]]
1296 + name = "num-bigint"
1297 + version = "0.4.6"
1298 + source = "registry+https://github.com/rust-lang/crates.io-index"
1299 + checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
1300 + dependencies = [
1301 + "num-integer",
1302 + "num-traits",
1303 + ]
1304 +
1043 1305 [[package]]
1044 1306 name = "num-bigint-dig"
1045 1307 version = "0.8.6"
@@ -1056,6 +1318,12 @@
1056 1318 "zeroize",
1057 1319 ]
1058 1320
1321 + [[package]]
1322 + name = "num-conv"
1323 + version = "0.2.0"
1324 + source = "registry+https://github.com/rust-lang/crates.io-index"
1325 + checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
1326 +
1059 1327 [[package]]
1060 1328 name = "num-integer"
1061 1329 version = "0.1.46"
@@ -1086,6 +1354,15 @@
1086 1354 "libm",
1087 1355 ]
1088 1356
1357 + [[package]]
1358 + name = "oid-registry"
1359 + version = "0.7.1"
1360 + source = "registry+https://github.com/rust-lang/crates.io-index"
1361 + checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
1362 + dependencies = [
1363 + "asn1-rs",
1364 + ]
1365 +
1089 1366 [[package]]
1090 1367 name = "once_cell"
1091 1368 version = "1.21.3"
@@ -1201,21 +1478,31 @@
1201 1478
1202 1479 [[package]]
1203 1480 name = "pom"
1204 - version = "0.1.0"
1481 + version = "0.2.1"
1205 1482 dependencies = [
1483 + "axum",
1206 1484 "chrono",
1207 1485 "clap",
1208 1486 "dirs",
1487 + "hostname",
1488 + "http-body-util",
1209 1489 "reqwest",
1210 1490 "rmcp",
1491 + "rustls-pki-types",
1211 1492 "schemars",
1212 1493 "serde",
1213 1494 "serde_json",
1214 1495 "sqlx",
1496 + "thiserror 2.0.18",
1215 1497 "tokio",
1498 + "tokio-rustls",
1216 1499 "toml",
1500 + "tower",
1217 1501 "tracing",
1218 1502 "tracing-subscriber",
1503 + "uuid",
1504 + "webpki-roots",
1505 + "x509-parser",
1219 1506 ]
1220 1507
1221 1508 [[package]]
@@ -1227,6 +1514,12 @@
1227 1514 "zerovec",
1228 1515 ]
1229 1516
1517 + [[package]]
1518 + name = "powerfmt"
1519 + version = "0.2.0"
1520 + source = "registry+https://github.com/rust-lang/crates.io-index"
1521 + checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
1522 +
1230 1523 [[package]]
1231 1524 name = "ppv-lite86"
1232 1525 version = "0.2.21"
@@ -1236,6 +1529,16 @@
1236 1529 "zerocopy",
1237 1530 ]
1238 1531
1532 + [[package]]
1533 + name = "prettyplease"
1534 + version = "0.2.37"
1535 + source = "registry+https://github.com/rust-lang/crates.io-index"
1536 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
1537 + dependencies = [
1538 + "proc-macro2",
1539 + "syn",
1540 + ]
1541 +
1239 1542 [[package]]
1240 1543 name = "proc-macro2"
1241 1544 version = "1.0.106"
@@ -1259,7 +1562,7 @@
1259 1562 "rustc-hash",
1260 1563 "rustls",
1261 1564 "socket2",
1262 - "thiserror",
1565 + "thiserror 2.0.18",
1263 1566 "tokio",
1264 1567 "tracing",
1265 1568 "web-time",
@@ -1280,7 +1583,7 @@
1280 1583 "rustls",
1281 1584 "rustls-pki-types",
1282 1585 "slab",
1283 - "thiserror",
1586 + "thiserror 2.0.18",
1284 1587 "tinyvec",
1285 1588 "tracing",
1286 1589 "web-time",
@@ -1315,6 +1618,12 @@
1315 1618 source = "registry+https://github.com/rust-lang/crates.io-index"
1316 1619 checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1317 1620
1621 + [[package]]
1622 + name = "r-efi"
1623 + version = "6.0.0"
1624 + source = "registry+https://github.com/rust-lang/crates.io-index"
1625 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1626 +
Lines truncated
M Cargo.toml +21 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "pom"
3 - version = "0.1.0"
3 + version = "0.2.2"
4 4 edition = "2024"
5 5
6 6 [lib]
@@ -24,6 +24,9 @@
24 24 # HTTP client
25 25 reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
26 26
27 + # HTTP server (API in serve mode)
28 + axum = { version = "0.8", default-features = false, features = ["json", "tokio", "http1", "query"] }
29 +
27 30 # Database
28 31 sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
29 32
@@ -35,12 +38,29 @@
35 38 # Config
36 39 toml = "0.8"
37 40
41 + # Errors
42 + thiserror = "2"
43 +
38 44 # Time
39 45 chrono = { version = "0.4", features = ["serde"] }
40 46
41 47 # Paths
42 48 dirs = "6"
43 49
50 + # Identity
51 + uuid = { version = "1", features = ["v4"] }
52 + hostname = "0.4"
53 +
54 + # TLS certificate checking
55 + x509-parser = "0.16"
56 + tokio-rustls = "0.26"
57 + rustls-pki-types = "1"
58 + webpki-roots = "1"
59 +
44 60 # Logging
45 61 tracing = "0.1"
46 62 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
63 +
64 + [dev-dependencies]
65 + tower = { version = "0.5", features = ["util"] }
66 + http-body-util = "0.1"
M pom.toml +1
@@ -1,6 +1,7 @@
1 1 [serve]
2 2 interval_secs = 300
3 3 prune_days = 30
4 + listen = "127.0.0.1:9100"
4 5
5 6 [targets.mnw]
6 7 label = "Makenotwork Production"
M deploy/deploy.sh +17 -9
@@ -2,19 +2,20 @@
2 2 set -euo pipefail
3 3
4 4 ASTRA_HOST="max@100.106.221.39"
5 - HETZNER_HOST="root@5.78.144.244"
5 + HETZNER_HOST="root@100.120.174.96"
6 6
7 7 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8 8 PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
9 9
10 10 deploy_target() {
11 11 local name="$1"
12 - local host target
12 + local host target sudo_prefix=""
13 13
14 14 case "$name" in
15 15 astra)
16 16 host="$ASTRA_HOST"
17 17 target="aarch64-unknown-linux-gnu"
18 + sudo_prefix="sudo"
18 19 ;;
19 20 hetzner)
20 21 host="$HETZNER_HOST"
@@ -31,16 +32,23 @@
31 32
32 33 local binary="$PROJECT_DIR/target/$target/release/pom"
33 34
34 - echo "=== Deploying to $name ($host) ==="
35 - ssh "$host" "mkdir -p /etc/pom"
36 - scp "$binary" "$host:/usr/local/bin/pom"
37 - scp "$PROJECT_DIR/pom.toml" "$host:/etc/pom/pom.toml"
38 - scp "$SCRIPT_DIR/pom.service" "$host:/etc/systemd/system/pom.service"
35 + local config_file="$SCRIPT_DIR/pom-${name}.toml"
36 + if [ ! -f "$config_file" ]; then
37 + echo "Config not found: $config_file"
38 + exit 1
39 + fi
39 40
40 - ssh "$host" "systemctl daemon-reload && systemctl enable pom && systemctl restart pom"
41 + echo "=== Deploying to $name ($host) ==="
42 + ssh "$host" "$sudo_prefix mkdir -p /etc/pom"
43 + scp "$binary" "$host:/tmp/pom"
44 + scp "$config_file" "$host:/tmp/pom.toml"
45 + scp "$SCRIPT_DIR/pom.service" "$host:/tmp/pom.service"
46 +
47 + ssh "$host" "$sudo_prefix mv /tmp/pom /usr/local/bin/pom && $sudo_prefix chmod +x /usr/local/bin/pom && $sudo_prefix mv /tmp/pom.toml /etc/pom/pom.toml && $sudo_prefix mv /tmp/pom.service /etc/systemd/system/pom.service"
48 + ssh "$host" "$sudo_prefix systemctl daemon-reload && $sudo_prefix systemctl enable pom && $sudo_prefix systemctl restart pom"
41 49
42 50 echo "=== $name: deployed ==="
43 - ssh "$host" "systemctl status pom --no-pager"
51 + ssh "$host" "$sudo_prefix systemctl status pom --no-pager"
44 52 }
45 53
46 54 if [ $# -eq 0 ]; then
M src/config.rs +417 -8
@@ -1,13 +1,47 @@
1 + //! TOML configuration loading and types.
2 +
1 3 use serde::Deserialize;
2 4 use std::collections::HashMap;
3 5 use std::path::{Path, PathBuf};
4 6
7 + use crate::error::{PomError, Result};
8 + use crate::peer::OnMissing;
9 +
5 10 #[derive(Debug, Clone, Deserialize)]
6 11 pub struct Config {
7 12 #[serde(default)]
8 13 pub serve: ServeConfig,
9 14 #[serde(default)]
15 + pub instance: InstanceConfig,
16 + #[serde(default)]
10 17 pub targets: HashMap<String, TargetConfig>,
18 + #[serde(default)]
19 + pub peers: HashMap<String, PeerConfig>,
20 + pub alerts: Option<AlertConfig>,
21 + }
22 +
23 + #[derive(Debug, Clone, Deserialize)]
24 + pub struct AlertConfig {
25 + pub postmark_token: Option<String>,
26 + pub to: String,
27 + #[serde(default = "default_alert_from")]
28 + pub from: String,
29 + #[serde(default = "default_cooldown_secs")]
30 + pub cooldown_secs: u64,
31 + }
32 +
33 + #[derive(Debug, Clone, Default, Deserialize)]
34 + pub struct InstanceConfig {
35 + pub name: Option<String>,
36 + pub id: Option<String>,
37 + }
38 +
39 + #[derive(Debug, Clone, Deserialize)]
40 + pub struct PeerConfig {
41 + pub address: String,
42 + #[serde(default)]
43 + pub on_missing: OnMissing,
44 + pub grace_count: Option<u32>,
11 45 }
12 46
13 47 #[derive(Debug, Clone, Deserialize)]
@@ -16,6 +50,12 @@
16 50 pub interval_secs: u64,
17 51 #[serde(default = "default_prune_days")]
18 52 pub prune_days: i64,
53 + #[serde(default = "default_listen")]
54 + pub listen: String,
55 + #[serde(default = "default_peer_heartbeat")]
56 + pub peer_heartbeat_secs: u64,
57 + #[serde(default = "default_tls_check_interval")]
58 + pub tls_check_interval_secs: u64,
19 59 }
20 60
21 61 impl Default for ServeConfig {
@@ -23,10 +63,21 @@
23 63 Self {
24 64 interval_secs: 300,
25 65 prune_days: 30,
66 + listen: default_listen(),
67 + peer_heartbeat_secs: 60,
68 + tls_check_interval_secs: 3600,
26 69 }
27 70 }
28 71 }
29 72
73 + fn default_peer_heartbeat() -> u64 {
74 + 60
75 + }
76 +
77 + fn default_tls_check_interval() -> u64 {
78 + 3600
79 + }
80 +
30 81 fn default_serve_interval() -> u64 {
31 82 300
32 83 }
@@ -35,11 +86,33 @@
35 86 30
36 87 }
37 88
89 + fn default_listen() -> String {
90 + "127.0.0.1:9100".to_string()
91 + }
92 +
38 93 #[derive(Debug, Clone, Deserialize)]
39 94 pub struct TargetConfig {
40 95 pub label: String,
41 96 pub health: Option<HealthConfig>,
42 97 pub tests: Option<TestsConfig>,
98 + pub tls: Option<TlsConfig>,
99 + }
100 +
101 + #[derive(Debug, Clone, Deserialize)]
102 + pub struct TlsConfig {
103 + pub host: String,
104 + #[serde(default = "default_tls_port")]
105 + pub port: u16,
106 + #[serde(default = "default_tls_warn_days")]
107 + pub warn_days: u32,
108 + }
109 +
110 + fn default_tls_port() -> u16 {
111 + 443
112 + }
113 +
114 + fn default_tls_warn_days() -> u32 {
115 + 14
43 116 }
44 117
45 118 #[derive(Debug, Clone, Deserialize)]
@@ -49,6 +122,34 @@
49 122 pub timeout_secs: u64,
50 123 /// Per-target interval override for serve mode
51 124 pub interval_secs: Option<u64>,
125 + /// Response validation expectations
126 + pub expect: Option<HealthExpectation>,
127 + /// Latency trending and drift detection
128 + pub trending: Option<TrendingConfig>,
129 + }
130 +
131 + #[derive(Debug, Clone, Deserialize)]
132 + pub struct TrendingConfig {
133 + #[serde(default = "default_baseline_window_hours")]
134 + pub baseline_window_hours: u64,
135 + #[serde(default = "default_spike_threshold")]
136 + pub spike_threshold: f64,
137 + }
138 +
139 + fn default_baseline_window_hours() -> u64 {
140 + 168
141 + }
142 +
143 + fn default_spike_threshold() -> f64 {
144 + 2.0
145 + }
146 +
147 + #[derive(Debug, Clone, Deserialize, Default)]
148 + pub struct HealthExpectation {
149 + pub status_code: Option<u16>,
150 + #[serde(default)]
151 + pub json_fields: HashMap<String, String>,
152 + pub body_contains: Option<String>,
52 153 }
53 154
54 155 #[derive(Debug, Clone, Deserialize)]
@@ -57,6 +158,12 @@
57 158 pub command: String,
58 159 #[serde(default = "default_test_timeout")]
59 160 pub timeout_secs: u64,
161 + #[serde(default = "default_staleness_days")]
162 + pub staleness_days: u64,
163 + }
164 +
165 + fn default_staleness_days() -> u64 {
166 + 7
60 167 }
61 168
62 169 fn default_health_timeout() -> u64 {
@@ -67,15 +174,26 @@
67 174 600
68 175 }
69 176
177 + fn default_alert_from() -> String {
178 + "PoM Alerts <pom-alerts@makenot.work>".to_string()
179 + }
180 +
181 + fn default_cooldown_secs() -> u64 {
182 + 300
183 + }
184 +
70 185 impl Config {
71 - pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
186 + pub fn load(path: Option<&Path>) -> Result<Self> {
72 187 let config_path = match path {
73 188 Some(p) => p.to_path_buf(),
74 189 None => default_config_path()?,
75 190 };
76 191
77 192 if !config_path.exists() {
78 - return Err(format!("Config file not found: {}", config_path.display()).into());
193 + return Err(PomError::Config(format!(
194 + "Config file not found: {}",
195 + config_path.display()
196 + )));
79 197 }
80 198
81 199 let contents = std::fs::read_to_string(&config_path)?;
@@ -92,16 +210,318 @@
92 210 names.sort();
93 211 names
94 212 }
213 +
214 + pub fn instance_name(&self) -> String {
215 + self.instance
216 + .name
217 + .clone()
218 + .unwrap_or_else(|| hostname::get().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|_| "unknown".to_string()))
219 + }
95 220 }
96 221
97 - pub fn default_config_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
98 - let config_dir = dirs::config_dir().ok_or("Could not determine config directory")?;
99 - Ok(config_dir.join("pom").join("pom.toml"))
222 + pub fn default_config_path() -> Result<PathBuf> {
223 + let config_dir =
224 + dirs::config_dir().ok_or_else(|| PomError::Config("Could not determine config directory".into()));
225 + Ok(config_dir?.join("pom").join("pom.toml"))
100 226 }
101 227
102 - pub fn db_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
103 - let data_dir = dirs::data_local_dir().ok_or("Could not determine data directory")?;
104 - let pom_dir = data_dir.join("pom");
228 + pub fn db_path() -> Result<PathBuf> {
229 + let data_dir =
230 + dirs::data_local_dir().ok_or_else(|| PomError::Config("Could not determine data directory".into()));
231 + let pom_dir = data_dir?.join("pom");
105 232 std::fs::create_dir_all(&pom_dir)?;
106 233 Ok(pom_dir.join("pom.db"))
107 234 }
235 +
236 + #[cfg(test)]
237 + mod tests {
238 + use super::*;
239 +
240 + #[test]
241 + fn parse_full_config() {
242 + let toml = r#"
243 + [serve]
244 + interval_secs = 120
245 + listen = "0.0.0.0:9100"
246 + peer_heartbeat_secs = 30
247 +
248 + [instance]
249 + name = "hetzner"
250 +
251 + [targets.mnw]
252 + label = "MakeNotWork"
253 + [targets.mnw.health]
254 + url = "https://makenot.work/health"
255 + timeout_secs = 5
256 + [targets.mnw.tests]
257 + ssh = "hetzner"
258 + command = "cd /srv/mnw && ./ci.sh"
259 +
260 + [peers.astra]
261 + address = "100.0.0.1:9100"
262 + on_missing = "alert"
263 + grace_count = 5
264 + "#;
265 +
266 + let config: Config = toml::from_str(toml).unwrap();
267 + assert_eq!(config.serve.interval_secs, 120);
268 + assert_eq!(config.serve.listen, "0.0.0.0:9100");
269 + assert_eq!(config.serve.peer_heartbeat_secs, 30);
270 + assert_eq!(config.instance.name.as_deref(), Some("hetzner"));
271 + assert_eq!(config.target_names(), vec!["mnw"]);
272 +
273 + let mnw = config.get_target("mnw").unwrap();
274 + assert_eq!(mnw.label, "MakeNotWork");
275 + assert_eq!(mnw.health.as_ref().unwrap().timeout_secs, 5);
276 + assert_eq!(mnw.tests.as_ref().unwrap().ssh, "hetzner");
277 +
278 + let astra = config.peers.get("astra").unwrap();
279 + assert_eq!(astra.address, "100.0.0.1:9100");
280 + assert_eq!(astra.on_missing, OnMissing::Alert);
281 + assert_eq!(astra.grace_count, Some(5));
282 + }
283 +
284 + #[test]
285 + fn empty_config_uses_defaults() {
286 + let config: Config = toml::from_str("").unwrap();
287 + assert_eq!(config.serve.interval_secs, 300);
288 + assert_eq!(config.serve.prune_days, 30);
289 + assert_eq!(config.serve.listen, "127.0.0.1:9100");
290 + assert_eq!(config.serve.peer_heartbeat_secs, 60);
291 + assert!(config.targets.is_empty());
292 + assert!(config.peers.is_empty());
293 + assert!(config.instance.name.is_none());
294 + }
295 +
296 + #[test]
297 + fn peer_on_missing_defaults_to_log() {
298 + let toml = r#"
299 + [peers.test]
300 + address = "10.0.0.1:9100"
301 + "#;
302 + let config: Config = toml::from_str(toml).unwrap();
303 + let peer = config.peers.get("test").unwrap();
304 + assert_eq!(peer.on_missing, OnMissing::Log);
305 + assert_eq!(peer.grace_count, None);
306 + }
307 +
308 + #[test]
309 + fn instance_name_falls_back_to_hostname() {
310 + let config: Config = toml::from_str("").unwrap();
311 + let name = config.instance_name();
312 + assert!(!name.is_empty());
313 + }
314 +
315 + #[test]
316 + fn config_without_alerts_section() {
317 + let config: Config = toml::from_str("").unwrap();
318 + assert!(config.alerts.is_none());
319 + }
320 +
321 + #[test]
322 + fn config_with_alerts_section() {
323 + let toml = r#"
324 + [alerts]
325 + postmark_token = "test-token"
326 + to = "alerts@example.com"
327 + "#;
328 + let config: Config = toml::from_str(toml).unwrap();
329 + let alerts = config.alerts.unwrap();
330 + assert_eq!(alerts.postmark_token.as_deref(), Some("test-token"));
331 + assert_eq!(alerts.to, "alerts@example.com");
332 + assert_eq!(alerts.from, "PoM Alerts <pom-alerts@makenot.work>");
333 + assert_eq!(alerts.cooldown_secs, 300);
334 + }
335 +
336 + #[test]
337 + fn config_with_tls() {
338 + let toml = r#"
339 + [targets.mnw]
340 + label = "MakeNotWork"
341 + [targets.mnw.tls]
342 + host = "makenot.work"
343 + port = 8443
344 + warn_days = 30
345 + "#;
346 + let config: Config = toml::from_str(toml).unwrap();
347 + let mnw = config.get_target("mnw").unwrap();
348 + let tls = mnw.tls.as_ref().unwrap();
349 + assert_eq!(tls.host, "makenot.work");
350 + assert_eq!(tls.port, 8443);
351 + assert_eq!(tls.warn_days, 30);
352 + }
353 +
354 + #[test]
355 + fn config_tls_defaults() {
356 + let toml = r#"
357 + [targets.mnw]
358 + label = "MakeNotWork"
359 + [targets.mnw.tls]
360 + host = "makenot.work"
361 + "#;
362 + let config: Config = toml::from_str(toml).unwrap();
363 + let tls = config.get_target("mnw").unwrap().tls.as_ref().unwrap();
364 + assert_eq!(tls.port, 443);
365 + assert_eq!(tls.warn_days, 14);
366 + }
367 +
368 + #[test]
369 + fn config_without_tls() {
370 + let toml = r#"
371 + [targets.mnw]
372 + label = "MakeNotWork"
373 + "#;
374 + let config: Config = toml::from_str(toml).unwrap();
375 + assert!(config.get_target("mnw").unwrap().tls.is_none());
376 + }
377 +
378 + #[test]
379 + fn config_tls_check_interval_default() {
380 + let config: Config = toml::from_str("").unwrap();
381 + assert_eq!(config.serve.tls_check_interval_secs, 3600);
382 + }
383 +
384 + #[test]
385 + fn config_tls_check_interval_custom() {
386 + let toml = r#"
387 + [serve]
388 + tls_check_interval_secs = 1800
389 + "#;
390 + let config: Config = toml::from_str(toml).unwrap();
391 + assert_eq!(config.serve.tls_check_interval_secs, 1800);
392 + }
393 +
394 + #[test]
395 + fn config_with_health_expect() {
396 + let toml = r#"
397 + [targets.mnw]
398 + label = "MakeNotWork"
399 + [targets.mnw.health]
400 + url = "https://makenot.work/health"
401 + [targets.mnw.health.expect]
402 + status_code = 200
403 + body_contains = "operational"
404 + json_fields = { "status" = "operational", "checks.db" = "ok" }
405 + "#;
406 + let config: Config = toml::from_str(toml).unwrap();
407 + let expect = config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.as_ref().unwrap();
408 + assert_eq!(expect.status_code, Some(200));
409 + assert_eq!(expect.body_contains.as_deref(), Some("operational"));
410 + assert_eq!(expect.json_fields.get("status").unwrap(), "operational");
411 + assert_eq!(expect.json_fields.get("checks.db").unwrap(), "ok");
412 + }
413 +
414 + #[test]
415 + fn config_health_without_expect() {
416 + let toml = r#"
417 + [targets.mnw]
418 + label = "MakeNotWork"
419 + [targets.mnw.health]
420 + url = "https://makenot.work/health"
421 + "#;
422 + let config: Config = toml::from_str(toml).unwrap();
423 + assert!(config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.is_none());
424 + }
425 +
426 + #[test]
427 + fn config_with_trending() {
428 + let toml = r#"
429 + [targets.mnw]
430 + label = "MakeNotWork"
431 + [targets.mnw.health]
432 + url = "https://makenot.work/health"
433 + [targets.mnw.health.trending]
434 + baseline_window_hours = 48
435 + spike_threshold = 1.5
436 + "#;
437 + let config: Config = toml::from_str(toml).unwrap();
438 + let trending = config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.as_ref().unwrap();
439 + assert_eq!(trending.baseline_window_hours, 48);
440 + assert_eq!(trending.spike_threshold, 1.5);
441 + }
442 +
443 + #[test]
444 + fn config_trending_defaults() {
445 + let toml = r#"
446 + [targets.mnw]
447 + label = "MakeNotWork"
448 + [targets.mnw.health]
449 + url = "https://makenot.work/health"
450 + [targets.mnw.health.trending]
451 + "#;
452 + let config: Config = toml::from_str(toml).unwrap();
453 + let trending = config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.as_ref().unwrap();
454 + assert_eq!(trending.baseline_window_hours, 168);
455 + assert_eq!(trending.spike_threshold, 2.0);
456 + }
457 +
458 + #[test]
459 + fn config_without_trending() {
460 + let toml = r#"
461 + [targets.mnw]
462 + label = "MakeNotWork"
463 + [targets.mnw.health]
464 + url = "https://makenot.work/health"
465 + "#;
466 + let config: Config = toml::from_str(toml).unwrap();
467 + assert!(config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.is_none());
468 + }
469 +
470 + #[test]
471 + fn config_health_expect_empty() {
472 + let toml = r#"
473 + [targets.mnw]
474 + label = "MakeNotWork"
475 + [targets.mnw.health]
476 + url = "https://makenot.work/health"
477 + [targets.mnw.health.expect]
478 + "#;
479 + let config: Config = toml::from_str(toml).unwrap();
480 + let expect = config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.as_ref().unwrap();
481 + assert_eq!(expect.status_code, None);
482 + assert!(expect.json_fields.is_empty());
483 + assert_eq!(expect.body_contains, None);
484 + }
485 +
486 + #[test]
487 + fn config_staleness_days_default() {
488 + let toml = r#"
489 + [targets.mnw]
490 + label = "MakeNotWork"
491 + [targets.mnw.tests]
492 + ssh = "host"
493 + command = "./ci.sh"
494 + "#;
495 + let config: Config = toml::from_str(toml).unwrap();
496 + assert_eq!(config.get_target("mnw").unwrap().tests.as_ref().unwrap().staleness_days, 7);
497 + }
498 +
499 + #[test]
500 + fn config_staleness_days_custom() {
501 + let toml = r#"
502 + [targets.mnw]
503 + label = "MakeNotWork"
504 + [targets.mnw.tests]
505 + ssh = "host"
506 + command = "./ci.sh"
507 + staleness_days = 14
508 + "#;
509 + let config: Config = toml::from_str(toml).unwrap();
510 + assert_eq!(config.get_target("mnw").unwrap().tests.as_ref().unwrap().staleness_days, 14);
511 + }
512 +
513 + #[test]
514 + fn config_with_alerts_custom_defaults() {
515 + let toml = r#"
516 + [alerts]
Lines truncated
M src/db.rs +394 -42
@@ -1,37 +1,23 @@
1 + //! SQLite persistence — schema, health checks, test runs, and peer data.
2 + //!
3 + //! Uses a migration versioning system: each schema change is a numbered migration
4 + //! stored in [`MIGRATIONS`]. On startup, [`run_migrations`] checks the current
5 + //! version and runs any pending migrations. Existing databases (pre-migration)
6 + //! are detected by the presence of the `health_checks` table and marked as v1.
7 +
1 8 use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
2 9 use std::path::Path;
3 10 use std::str::FromStr;
11 + use tracing::info;
4 12
5 - use crate::types::{HealthDetails, HealthSnapshot, HealthStatus, TestRun, TestSummary};
13 + use crate::error::Result;
14 + use crate::types::{HealthDetails, HealthSnapshot, HealthStatus, TestRun, TestSummary, TlsStatus};
6 15
7 - pub async fn connect(path: &Path) -> Result<SqlitePool, Box<dyn std::error::Error + Send + Sync>> {
8 - let opts = SqliteConnectOptions::from_str(&format!("sqlite:{}", path.display()))?
9 - .create_if_missing(true)
10 - .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
11 -
12 - let pool = SqlitePoolOptions::new()
13 - .max_connections(5)
14 - .connect_with(opts)
15 - .await?;
16 -
17 - init_schema(&pool).await?;
18 - Ok(pool)
19 - }
20 -
21 - pub async fn connect_in_memory() -> Result<SqlitePool, Box<dyn std::error::Error + Send + Sync>> {
22 - let opts = SqliteConnectOptions::from_str("sqlite::memory:")?;
23 - let pool = SqlitePoolOptions::new()
24 - .max_connections(1)
25 - .connect_with(opts)
26 - .await?;
27 -
28 - init_schema(&pool).await?;
29 - Ok(pool)
30 - }
31 -
32 - async fn init_schema(pool: &SqlitePool) -> Result<(), sqlx::Error> {
33 - sqlx::query(
34 - "CREATE TABLE IF NOT EXISTS health_checks (
16 + /// Each migration is a (version, description, SQL) tuple. Versions start at 1.
17 + /// The SQL may contain multiple statements separated by semicolons.
18 + const MIGRATIONS: &[(i64, &str, &str)] = &[
19 + (1, "initial schema", r#"
20 + CREATE TABLE IF NOT EXISTS health_checks (
35 21 id INTEGER PRIMARY KEY AUTOINCREMENT,
36 22 target TEXT NOT NULL,
37 23 status TEXT NOT NULL,
@@ -39,13 +25,8 @@
39 25 response_time_ms INTEGER NOT NULL,
40 26 details_json TEXT,
41 27 error TEXT
42 - )",
43 - )
44 - .execute(pool)
45 - .await?;
46 -
47 - sqlx::query(
48 - "CREATE TABLE IF NOT EXISTS test_runs (
28 + );
29 + CREATE TABLE IF NOT EXISTS test_runs (
49 30 id INTEGER PRIMARY KEY AUTOINCREMENT,
50 31 target TEXT NOT NULL,
51 32 started_at TEXT NOT NULL,
@@ -56,11 +37,183 @@
56 37 summary_json TEXT NOT NULL,
57 38 raw_output TEXT NOT NULL,
58 39 filter TEXT
40 + );
41 + CREATE TABLE IF NOT EXISTS peer_identities (
42 + peer_name TEXT PRIMARY KEY,
43 + instance_id TEXT NOT NULL,
44 + first_seen TEXT NOT NULL
45 + );
46 + CREATE TABLE IF NOT EXISTS peer_heartbeats (
47 + id INTEGER PRIMARY KEY AUTOINCREMENT,
48 + peer_name TEXT NOT NULL,
49 + status TEXT NOT NULL,
50 + latency_ms INTEGER NOT NULL,
51 + checked_at TEXT NOT NULL
52 + );
53 + CREATE INDEX IF NOT EXISTS idx_health_checks_target_id ON health_checks(target, id DESC);
54 + CREATE INDEX IF NOT EXISTS idx_health_checks_target_checked ON health_checks(target, checked_at);
55 + CREATE INDEX IF NOT EXISTS idx_test_runs_target_id ON test_runs(target, id DESC);
56 + CREATE INDEX IF NOT EXISTS idx_peer_heartbeats_peer_id ON peer_heartbeats(peer_name, id DESC);
57 + "#),
58 + (2, "add alerts table", r#"
59 + CREATE TABLE IF NOT EXISTS alerts (
60 + id INTEGER PRIMARY KEY AUTOINCREMENT,
61 + target TEXT NOT NULL,
62 + alert_type TEXT NOT NULL,
63 + from_status TEXT,
64 + to_status TEXT,
65 + sent_at TEXT NOT NULL,
66 + error TEXT
67 + );
68 + CREATE INDEX IF NOT EXISTS idx_alerts_target_sent ON alerts(target, sent_at);
69 + "#),
70 + (3, "add tls_checks table", r#"
71 + CREATE TABLE tls_checks (
72 + id INTEGER PRIMARY KEY AUTOINCREMENT,
73 + target TEXT NOT NULL,
74 + host TEXT NOT NULL,
75 + valid INTEGER NOT NULL,
76 + days_remaining INTEGER NOT NULL,
77 + not_before TEXT NOT NULL,
78 + not_after TEXT NOT NULL,
79 + subject TEXT NOT NULL,
80 + issuer TEXT NOT NULL,
81 + checked_at TEXT NOT NULL,
82 + error TEXT
83 + );
84 + CREATE INDEX idx_tls_checks_target_id ON tls_checks(target, id DESC);
85 + "#),
86 + (4, "add incidents table", r#"
87 + CREATE TABLE incidents (
88 + id INTEGER PRIMARY KEY AUTOINCREMENT,
89 + target TEXT NOT NULL,
90 + started_at TEXT NOT NULL,
91 + ended_at TEXT,
92 + duration_secs INTEGER,
93 + from_status TEXT NOT NULL,
94 + to_status TEXT NOT NULL
95 + );
96 + CREATE INDEX idx_incidents_target_id ON incidents(target, id DESC);
97 + "#),
98 + ];
99 +
100 + pub async fn connect(path: &Path) -> Result<SqlitePool> {
101 + let opts = SqliteConnectOptions::from_str(&format!("sqlite:{}", path.display()))?
102 + .create_if_missing(true)
103 + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
104 +
105 + let pool = SqlitePoolOptions::new()
106 + .max_connections(5)
107 + .connect_with(opts)
108 + .await?;
109 +
110 + run_migrations(&pool).await?;
111 + Ok(pool)
112 + }
113 +
114 + pub async fn connect_in_memory() -> Result<SqlitePool> {
115 + let opts = SqliteConnectOptions::from_str("sqlite::memory:")?;
116 + let pool = SqlitePoolOptions::new()
117 + .max_connections(1)
118 + .connect_with(opts)
119 + .await?;
120 +
121 + run_migrations(&pool).await?;
122 + Ok(pool)
123 + }
124 +
125 + /// Run pending schema migrations. Detects pre-migration databases by checking
126 + /// for existing tables and stamps them as version 1 without re-running.
127 + pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
128 + // Ensure the schema_version table exists
129 + sqlx::query(
130 + "CREATE TABLE IF NOT EXISTS schema_version (
131 + version INTEGER NOT NULL,
132 + description TEXT NOT NULL,
133 + applied_at TEXT NOT NULL
59 134 )",
60 135 )
61 136 .execute(pool)
62 137 .await?;
63 138
139 + let current_version = get_schema_version(pool).await?;
140 +
141 + // Detect pre-migration databases: if schema_version is empty but tables exist,
142 + // this is an existing database that predates the migration system.
143 + if current_version == 0 && has_existing_tables(pool).await? {
144 + info!("detected pre-migration database, stamping as version 1");
145 + stamp_version(pool, 1, "initial schema (pre-existing)").await?;
146 + // Run remaining migrations (2+) if any
147 + for &(version, description, sql) in MIGRATIONS {
148 + if version > 1 {
149 + run_one_migration(pool, version, description, sql).await?;
150 + }
151 + }
152 + return Ok(());
153 + }
154 +
155 + // Run all migrations newer than current version
156 + for &(version, description, sql) in MIGRATIONS {
157 + if version > current_version {
158 + run_one_migration(pool, version, description, sql).await?;
159 + }
160 + }
161 +
162 + Ok(())
163 + }
164 +
165 + /// Get the current schema version (0 if no migrations have been applied).
166 + pub async fn get_schema_version(pool: &SqlitePool) -> Result<i64> {
167 + let row = sqlx::query_as::<_, (i64,)>(
168 + "SELECT COALESCE(MAX(version), 0) FROM schema_version",
169 + )
170 + .fetch_one(pool)
171 + .await?;
172 + Ok(row.0)
173 + }
174 +
175 + /// Check whether the database has existing tables from before the migration system.
176 + async fn has_existing_tables(pool: &SqlitePool) -> Result<bool> {
177 + let row = sqlx::query_as::<_, (i64,)>(
178 + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'health_checks'",
179 + )
180 + .fetch_one(pool)
181 + .await?;
182 + Ok(row.0 > 0)
183 + }
184 +
185 + /// Execute a single migration's SQL and record it in schema_version.
186 + async fn run_one_migration(
187 + pool: &SqlitePool,
188 + version: i64,
189 + description: &str,
190 + sql: &str,
191 + ) -> Result<()> {
192 + info!(version, description, "running migration");
193 +
194 + // Execute each statement in the migration SQL
195 + for statement in sql.split(';') {
196 + let trimmed = statement.trim();
197 + if !trimmed.is_empty() {
198 + sqlx::query(trimmed).execute(pool).await?;
199 + }
200 + }
201 +
202 + stamp_version(pool, version, description).await?;
203 + Ok(())
204 + }
205 +
206 + /// Record a version in the schema_version table.
207 + async fn stamp_version(pool: &SqlitePool, version: i64, description: &str) -> Result<()> {
208 + let now = chrono::Utc::now().to_rfc3339();
209 + sqlx::query(
210 + "INSERT INTO schema_version (version, description, applied_at) VALUES (?, ?, ?)",
211 + )
212 + .bind(version)
213 + .bind(description)
214 + .bind(&now)
215 + .execute(pool)
216 + .await?;
64 217 Ok(())
65 218 }
66 219
@@ -69,7 +222,7 @@
69 222 pub async fn insert_health_check(
70 223 pool: &SqlitePool,
71 224 snapshot: &HealthSnapshot,
72 - ) -> Result<i64, sqlx::Error> {
225 + ) -> Result<i64> {
73 226 let status = snapshot.status.to_string();
74 227 let details_json = snapshot
75 228 .details
@@ -96,7 +249,7 @@
96 249 pool: &SqlitePool,
97 250 target: Option<&str>,
98 251 limit: i64,
99 - ) -> Result<Vec<HealthSnapshot>, sqlx::Error> {
252 + ) -> Result<Vec<HealthSnapshot>> {
100 253 let rows = match target {
101 254 Some(t) => {
102 255 sqlx::query_as::<_, HealthCheckRow>(
@@ -125,7 +278,7 @@
125 278 pub async fn get_latest_health(
126 279 pool: &SqlitePool,
127 280 target: &str,
128 - ) -> Result<Option<HealthSnapshot>, sqlx::Error> {
281 + ) -> Result<Option<HealthSnapshot>> {
129 282 let row = sqlx::query_as::<_, HealthCheckRow>(
130 283 "SELECT id, target, status, checked_at, response_time_ms, details_json, error
131 284 FROM health_checks WHERE target = ? ORDER BY id DESC LIMIT 1",
@@ -142,7 +295,7 @@
142 295 pub async fn insert_test_run(
143 296 pool: &SqlitePool,
144 297 run: &TestRun,
145 - ) -> Result<i64, sqlx::Error> {
298 + ) -> Result<i64> {
146 299 let summary_json = serde_json::to_string(&run.summary).unwrap_or_default();
147 300
148 301 let result = sqlx::query(
@@ -168,7 +321,7 @@
168 321 pool: &SqlitePool,
169 322 target: Option<&str>,
170 323 limit: i64,
171 - ) -> Result<Vec<TestRun>, sqlx::Error> {
324 + ) -> Result<Vec<TestRun>> {
172 325 let rows = match target {
173 326 Some(t) => {
174 327 sqlx::query_as::<_, TestRunRow>(
@@ -197,7 +350,7 @@
197 350 pub async fn get_latest_test_run(
198 351 pool: &SqlitePool,
199 352 target: &str,
200 - ) -> Result<Option<TestRun>, sqlx::Error> {
353 + ) -> Result<Option<TestRun>> {
201 354 let row = sqlx::query_as::<_, TestRunRow>(
202 355 "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter
203 356 FROM test_runs WHERE target = ? ORDER BY id DESC LIMIT 1",
@@ -209,10 +362,285 @@
209 362 Ok(row.map(|r| r.into_test_run()))
210 363 }
211 364
365 + /// Get the version from the health check closest to (but before) a given timestamp.
366 + pub async fn get_version_at_time(
367 + pool: &SqlitePool,
368 + target: &str,
369 + before_rfc3339: &str,
370 + ) -> Result<Option<String>> {
371 + let row = sqlx::query_as::<_, (Option<String>,)>(
372 + "SELECT details_json FROM health_checks
373 + WHERE target = ? AND checked_at <= ?
374 + ORDER BY checked_at DESC LIMIT 1",
375 + )
376 + .bind(target)
377 + .bind(before_rfc3339)
378 + .fetch_optional(pool)
379 + .await?;
380 +
381 + let version = row
382 + .and_then(|r| r.0)
383 + .and_then(|json_str| serde_json::from_str::<serde_json::Value>(&json_str).ok())
384 + .and_then(|json| json.get("version").and_then(|v| v.as_str()).map(String::from));
385 +
386 + Ok(version)
387 + }
388 +
389 + /// Calculate uptime percentage for a target over the given number of hours.
390 + /// Returns the percentage of health checks with "operational" status.
391 + pub async fn get_uptime_percent(
392 + pool: &SqlitePool,
393 + target: &str,
394 + hours: i64,
395 + ) -> Result<Option<f64>> {
396 + let cutoff = chrono::Utc::now() - chrono::Duration::hours(hours);
397 + let cutoff_str = cutoff.to_rfc3339();
398 +
399 + let row = sqlx::query_as::<_, (i64, i64)>(
400 + "SELECT
401 + COUNT(*) as total,
402 + SUM(CASE WHEN status = 'operational' THEN 1 ELSE 0 END) as operational
403 + FROM health_checks
404 + WHERE target = ? AND checked_at >= ?",
405 + )
406 + .bind(target)
407 + .bind(&cutoff_str)
408 + .fetch_one(pool)
409 + .await?;
410 +
411 + if row.0 == 0 {
412 + Ok(None)
413 + } else {
414 + Ok(Some(row.1 as f64 / row.0 as f64 * 100.0))
415 + }
416 + }
417 +
418 + // --- Latency trending queries ---
419 +
420 + /// Fetch all response times for a target since a given timestamp, ordered ASC.
421 + pub async fn get_response_times(
422 + pool: &SqlitePool,
423 + target: &str,
424 + since_rfc3339: &str,
425 + ) -> Result<Vec<(String, i64)>> {
426 + let rows = sqlx::query_as::<_, (String, i64)>(
427 + "SELECT checked_at, response_time_ms FROM health_checks
428 + WHERE target = ? AND checked_at >= ?
429 + ORDER BY checked_at ASC",
430 + )
431 + .bind(target)
432 + .bind(since_rfc3339)
433 + .fetch_all(pool)
434 + .await?;
435 + Ok(rows)
436 + }
437 +
438 + /// Fetch the last N response times for **operational** checks only (most recent first).
439 + pub async fn get_recent_response_times(
440 + pool: &SqlitePool,
441 + target: &str,
442 + count: i64,
443 + ) -> Result<Vec<i64>> {
444 + let rows = sqlx::query_as::<_, (i64,)>(
445 + "SELECT response_time_ms FROM health_checks
446 + WHERE target = ? AND status = 'operational'
447 + ORDER BY id DESC LIMIT ?",
448 + )
449 + .bind(target)
450 + .bind(count)
451 + .fetch_all(pool)
452 + .await?;
453 + Ok(rows.into_iter().map(|r| r.0).collect())
454 + }
455 +
456 + // --- Alert queries ---
457 +
458 + #[derive(Debug, sqlx::FromRow)]
459 + pub struct AlertRow {
460 + pub id: i64,
461 + pub target: String,
462 + pub alert_type: String,
463 + pub from_status: Option<String>,
464 + pub to_status: Option<String>,
465 + pub sent_at: String,
466 + pub error: Option<String>,
467 + }
468 +
469 + pub async fn insert_alert(
470 + pool: &SqlitePool,
471 + target: &str,
472 + alert_type: &str,
473 + from_status: Option<&str>,
474 + to_status: Option<&str>,
475 + error: Option<&str>,
476 + ) -> Result<i64> {
477 + let now = chrono::Utc::now().to_rfc3339();
478 + let result = sqlx::query(
479 + "INSERT INTO alerts (target, alert_type, from_status, to_status, sent_at, error)
480 + VALUES (?, ?, ?, ?, ?, ?)",
481 + )
482 + .bind(target)
483 + .bind(alert_type)
484 + .bind(from_status)
485 + .bind(to_status)
486 + .bind(&now)
487 + .bind(error)
488 + .execute(pool)
489 + .await?;
490 + Ok(result.last_insert_rowid())
491 + }
492 +
493 + pub async fn get_latest_alert_for_target(
494 + pool: &SqlitePool,
495 + target: &str,
496 + ) -> Result<Option<AlertRow>> {
497 + Ok(sqlx::query_as::<_, AlertRow>(
498 + "SELECT id, target, alert_type, from_status, to_status, sent_at, error
499 + FROM alerts WHERE target = ? ORDER BY id DESC LIMIT 1",
500 + )
501 + .bind(target)
502 + .fetch_optional(pool)
503 + .await?)
504 + }
505 +
506 + // --- TLS check queries ---
507 +
508 + #[derive(Debug, sqlx::FromRow, serde::Serialize)]
509 + pub struct TlsCheckRow {
510 + pub id: i64,
511 + pub target: String,
512 + pub host: String,
513 + pub valid: bool,
514 + pub days_remaining: i64,
515 + pub not_before: String,
516 + pub not_after: String,
517 + pub subject: String,
518 + pub issuer: String,
519 + pub checked_at: String,
520 + pub error: Option<String>,
521 + }
522 +
523 + pub async fn insert_tls_check(
524 + pool: &SqlitePool,
525 + status: &TlsStatus,
526 + ) -> Result<i64> {
527 + let result = sqlx::query(
528 + "INSERT INTO tls_checks (target, host, valid, days_remaining, not_before, not_after, subject, issuer, checked_at, error)
529 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
530 + )
531 + .bind(&status.target)
532 + .bind(&status.host)
533 + .bind(status.valid)
534 + .bind(status.days_remaining)
535 + .bind(&status.not_before)
536 + .bind(&status.not_after)
537 + .bind(&status.subject)
538 + .bind(&status.issuer)
539 + .bind(&status.checked_at)
540 + .bind(&status.error)
541 + .execute(pool)
542 + .await?;
543 +
544 + Ok(result.last_insert_rowid())
545 + }
546 +
547 + pub async fn get_latest_tls_check(
548 + pool: &SqlitePool,
549 + target: &str,
550 + ) -> Result<Option<TlsCheckRow>> {
551 + Ok(sqlx::query_as::<_, TlsCheckRow>(
552 + "SELECT id, target, host, valid, days_remaining, not_before, not_after, subject, issuer, checked_at, error
553 + FROM tls_checks WHERE target = ? ORDER BY id DESC LIMIT 1",
554 + )
555 + .bind(target)
556 + .fetch_optional(pool)
557 + .await?)
558 + }
559 +
560 + // --- Incident queries ---
561 +
562 + #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
563 + pub struct IncidentRow {
Lines truncated
M src/lib.rs +9
@@ -1,5 +1,14 @@
1 + //! PoM — Production Operations Monitor.
2 + //!
3 + //! Health checks, test orchestration, and peer mesh for monitoring deployed services.
4 +
5 + pub mod alerts;
6 + pub mod api;
1 7 pub mod checks;
2 8 pub mod config;
3 9 pub mod db;
10 + pub mod display;
11 + pub mod error;
12 + pub mod peer;
4 13 pub mod tools;
5 14 pub mod types;
M src/main.rs +23 -358
@@ -1,14 +1,17 @@
1 + //! PoM CLI entry point — parses subcommands and dispatches to handlers or MCP server.
2 +
1 3 use clap::{Parser, Subcommand};
2 4 use rmcp::ServiceExt;
3 5 use tokio::io::{stdin, stdout};
4 6 use tracing::info;
5 7 use tracing_subscriber::{fmt, prelude::*, EnvFilter};
6 8
7 - use pom::checks::{http, ssh};
8 9 use pom::config::{self, Config};
9 10 use pom::db;
11 + use pom::error::Result;
10 12 use pom::tools::PomServer;
11 - use pom::types::HealthStatus;
13 +
14 + mod cli;
12 15
13 16 #[derive(Parser)]
14 17 #[command(name = "pom", about = "Peace of Mind — health checks and test orchestration")]
@@ -51,7 +54,7 @@
51 54 /// View history
52 55 History {
53 56 #[command(subcommand)]
54 - kind: HistoryKind,
57 + kind: cli::HistoryKind,
55 58 },
56 59 /// Prune old records
57 60 Prune {
@@ -61,28 +64,8 @@
61 64 },
62 65 /// Run as a daemon, checking health at intervals
63 66 Serve,
64 - }
65 -
66 - #[derive(Subcommand)]
67 - enum HistoryKind {
68 - /// Health check history
69 - Health {
70 - /// Filter by target
71 - target: Option<String>,
72 - /// Number of results
73 - #[arg(short, default_value = "10")]
74 - n: i64,
75 - /// Output as JSON
76 - #[arg(long)]
77 - json: bool,
78 - },
79 - /// Test run history
80 - Tests {
81 - /// Filter by target
82 - target: Option<String>,
83 - /// Number of results
84 - #[arg(short, default_value = "10")]
85 - n: i64,
67 + /// Show peer mesh status
68 + Mesh {
86 69 /// Output as JSON
87 70 #[arg(long)]
88 71 json: bool,
@@ -90,7 +73,12 @@
90 73 }
91 74
92 75 #[tokio::main]
93 - async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
76 + async fn main() -> Result<()> {
77 + // Install the default rustls crypto provider before any TLS operations.
78 + // Both aws-lc-rs and ring are in the dependency tree (via reqwest and tokio-rustls),
79 + // so rustls can't auto-detect which to use.
80 + let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default();
81 +
94 82 let cli = Cli::parse();
95 83
96 84 let config_path = cli.config.as_deref();
@@ -102,7 +90,7 @@
102 90 }
103 91 }
104 92
105 - async fn run_mcp_server(config: Config) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
93 + async fn run_mcp_server(config: Config) -> Result<()> {
106 94 tracing_subscriber::registry()
107 95 .with(fmt::layer().with_writer(std::io::stderr))
108 96 .with(EnvFilter::from_default_env().add_directive("pom=info".parse()?))
@@ -128,7 +116,7 @@
128 116 async fn run_cli(
129 117 cmd: Commands,
130 118 config: Config,
131 - ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
119 + ) -> Result<()> {
132 120 let log_level = if matches!(cmd, Commands::Serve) { "pom=info" } else { "pom=warn" };
133 121 tracing_subscriber::registry()
134 122 .with(fmt::layer().with_writer(std::io::stderr))
@@ -139,335 +127,12 @@
139 127 let pool = db::connect(&db_path).await?;
140 128
141 129 match cmd {
142 - Commands::Health { target, json } => cmd_health(&pool, &config, target.as_deref(), json).await,
143 - Commands::Test { target, filter, json } => cmd_test(&pool, &config, &target, filter.as_deref(), json).await,
144 - Commands::Status { json } => cmd_status(&pool, &config, json).await,
145 - Commands::History { kind } => cmd_history(&pool, kind).await,
146 - Commands::Prune { days } => cmd_prune(&pool, days).await,
147 - Commands::Serve => cmd_serve(&pool, &config).await,
130 + Commands::Health { target, json } => cli::cmd_health(&pool, &config, target.as_deref(), json).await,
131 + Commands::Test { target, filter, json } => cli::cmd_test(&pool, &config, &target, filter.as_deref(), json).await,
132 + Commands::Status { json } => cli::cmd_status(&pool, &config, json).await,
133 + Commands::History { kind } => cli::cmd_history(&pool, kind).await,
134 + Commands::Prune { days } => cli::cmd_prune(&pool, days).await,
135 + Commands::Serve => cli::cmd_serve(&pool, &config).await,
136 + Commands::Mesh { json } => cli::cmd_mesh(&config, json).await,
148 137 }
149 138 }
150 -
151 - async fn cmd_health(
152 - pool: &sqlx::SqlitePool,
153 - config: &Config,
154 - target: Option<&str>,
155 - json: bool,
156 - ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
157 - let targets: Vec<String> = match target {
158 - Some(t) => {
159 - if config.get_target(t).is_none() {
160 - eprintln!("Unknown target: {t}");
161 - std::process::exit(1);
162 - }
163 - vec![t.to_string()]
164 - }
165 - None => config.target_names(),
166 - };
167 -
168 - let mut snapshots = Vec::new();
169 -
170 - for name in &targets {
171 - let target_config = config.get_target(name).unwrap();
172 - if let Some(health_config) = &target_config.health {
173 - let snapshot = http::check_health(name, health_config).await;
174 - db::insert_health_check(pool, &snapshot).await?;
175 - snapshots.push(snapshot);
176 - } else {
177 - eprintln!("{name}: no health endpoint configured");
178 - }
179 - }
180 -
181 - if json {
182 - println!("{}", serde_json::to_string_pretty(&snapshots)?);
183 - } else {
184 - for s in &snapshots {
185 - let icon = match s.status {
186 - HealthStatus::Operational => "OK",
187 - HealthStatus::Degraded => "WARN",
188 - HealthStatus::Error => "ERR",
189 - HealthStatus::Unreachable => "DOWN",
190 - };
191 - print!("[{icon}] {} — {}", s.target, s.status);
192 - print!(" ({}ms)", s.response_time_ms);
193 - if let Some(details) = &s.details {
194 - if let Some(v) = &details.version {
195 - print!(" v{v}");
196 - }
197 - if let Some(u) = &details.uptime {
198 - print!(" up {u}");
199 - }
200 - }
201 - println!();
202 - if let Some(err) = &s.error {
203 - println!(" {err}");
204 - }
205 - }
206 - }
207 -
208 - Ok(())
209 - }
210 -
211 - async fn cmd_test(
212 - pool: &sqlx::SqlitePool,
213 - config: &Config,
214 - target_name: &str,
215 - filter: Option<&str>,
216 - json: bool,
217 - ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
218 - let target = config.get_target(target_name).ok_or_else(|| {
219 - format!("Unknown target: {target_name}")
220 - })?;
221 - let tests_config = target.tests.as_ref().ok_or_else(|| {
222 - format!("Target '{target_name}' has no test configuration")
223 - })?;
224 -
225 - eprintln!("Running tests on {target_name}...");
226 - let run = ssh::run_tests(target_name, tests_config, filter).await;
227 - db::insert_test_run(pool, &run).await?;
228 -
229 - if json {
230 - let summary = serde_json::json!({
231 - "target": run.target,
232 - "passed": run.passed,
233 - "exit_code": run.exit_code,
234 - "duration_secs": run.duration_secs,
235 - "started_at": run.started_at,
236 - "finished_at": run.finished_at,
237 - "filter": run.filter,
238 - "summary": run.summary,
239 - });
240 - println!("{}", serde_json::to_string_pretty(&summary)?);
241 - } else {
242 - let result = if run.passed { "PASSED" } else { "FAILED" };
243 - println!("{target_name}: {result}");
244 - if let Some(d) = run.duration_secs {
245 - println!("Duration: {d}s");
246 - }
247 - if let (Some(p), Some(f)) = (run.summary.total_passed, run.summary.total_failed) {
248 - println!("Tests: {p} passed, {f} failed");
249 - }
250 - for step in &run.summary.steps {
251 - let mark = if step.passed { "PASS" } else { "FAIL" };
252 - println!(" {mark} {}", step.name);
253 - }
254 - if !run.passed {
255 - println!("\nRaw output:\n{}", run.raw_output);
256 - }
257 - }
258 -
259 - Ok(())
260 - }
261 -
262 - async fn cmd_status(
263 - pool: &sqlx::SqlitePool,
264 - config: &Config,
265 - json: bool,
266 - ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
267 - let mut target_statuses = Vec::new();
268 -
269 - for name in config.target_names() {
270 - let target = config.get_target(&name).unwrap();
271 - let health = db::get_latest_health(pool, &name).await?;
272 - let test = db::get_latest_test_run(pool, &name).await?;
273 -
274 - if json {
275 - target_statuses.push(serde_json::json!({
276 - "target": name,
277 - "label": target.label,
278 - "health": health,
279 - "last_test": test.map(|t| serde_json::json!({
280 - "passed": t.passed,
281 - "exit_code": t.exit_code,
282 - "duration_secs": t.duration_secs,
283 - "started_at": t.started_at,
284 - "summary": t.summary,
285 - })),
286 - }));
287 - } else {
288 - println!("=== {} ({}) ===", name, target.label);
289 - if let Some(h) = &health {
290 - let icon = match h.status {
291 - HealthStatus::Operational => "OK",
292 - HealthStatus::Degraded => "WARN",
293 - HealthStatus::Error => "ERR",
294 - HealthStatus::Unreachable => "DOWN",
295 - };
296 - print!(" Health: [{icon}] {}", h.status);
297 - print!(" ({}ms)", h.response_time_ms);
298 - if let Some(d) = &h.details {
299 - if let Some(v) = &d.version {
300 - print!(" v{v}");
301 - }
302 - }
303 - println!();
304 - } else {
305 - println!(" Health: no data");
306 - }
307 -
308 - if let Some(t) = &test {
309 - let result = if t.passed { "PASSED" } else { "FAILED" };
310 - print!(" Tests: {result}");
311 - if let Some(d) = t.duration_secs {
312 - print!(" ({d}s)");
313 - }
314 - println!();
315 - if let (Some(p), Some(f)) = (t.summary.total_passed, t.summary.total_failed) {
316 - println!(" {p} passed, {f} failed");
317 - }
318 - } else {
319 - println!(" Tests: no data");
320 - }
321 - println!();
322 - }
323 - }
324 -
325 - if json {
326 - println!("{}", serde_json::to_string_pretty(&target_statuses)?);
327 - }
328 -
329 - Ok(())
330 - }
331 -
332 - async fn cmd_history(
333 - pool: &sqlx::SqlitePool,
334 - kind: HistoryKind,
335 - ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
336 - match kind {
337 - HistoryKind::Health { target, n, json } => {
338 - let history = db::get_health_history(pool, target.as_deref(), n).await?;
339 - if json {
340 - println!("{}", serde_json::to_string_pretty(&history)?);
341 - } else if history.is_empty() {
342 - println!("No health check history.");
343 - } else {
344 - for h in &history {
345 - let icon = match h.status {
346 - HealthStatus::Operational => "OK",
347 - HealthStatus::Degraded => "WARN",
348 - HealthStatus::Error => "ERR",
349 - HealthStatus::Unreachable => "DOWN",
350 - };
351 - println!("[{icon}] {} — {} ({}ms) {}", h.target, h.status, h.response_time_ms, h.checked_at);
352 - }
353 - }
354 - }
355 - HistoryKind::Tests { target, n, json } => {
356 - let history = db::get_test_history(pool, target.as_deref(), n).await?;
357 - if json {
358 - let summaries: Vec<serde_json::Value> = history
359 - .iter()
360 - .map(|r| serde_json::json!({
361 - "id": r.id,
362 - "target": r.target,
363 - "passed": r.passed,
364 - "exit_code": r.exit_code,
365 - "duration_secs": r.duration_secs,
366 - "started_at": r.started_at,
367 - "summary": r.summary,
368 - }))
369 - .collect();
370 - println!("{}", serde_json::to_string_pretty(&summaries)?);
371 - } else if history.is_empty() {
372 - println!("No test run history.");
373 - } else {
374 - for r in &history {
375 - let result = if r.passed { "PASS" } else { "FAIL" };
376 - print!("[{result}] {}", r.target);
377 - if let Some(d) = r.duration_secs {
378 - print!(" ({d}s)");
379 - }
380 - print!(" {}", r.started_at);
381 - if let (Some(p), Some(f)) = (r.summary.total_passed, r.summary.total_failed) {
382 - print!(" — {p} passed, {f} failed");
383 - }
384 - println!();
385 - }
386 - }
387 - }
388 - }
389 -
390 - Ok(())
391 - }
392 -
393 - async fn cmd_prune(
394 - pool: &sqlx::SqlitePool,
395 - days: i64,
396 - ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
397 - let (health_pruned, test_pruned) = db::prune_old_records(pool, days).await?;
398 - println!("Pruned {health_pruned} health checks and {test_pruned} test runs older than {days} days.");
399 - Ok(())
400 - }
401 -
402 - async fn cmd_serve(
403 - pool: &sqlx::SqlitePool,
404 - config: &Config,
405 - ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
406 - let default_interval = config.serve.interval_secs;
407 - let prune_days = config.serve.prune_days;
408 -
409 - info!("Starting serve mode (default interval: {default_interval}s, prune: {prune_days}d)");
410 -
411 - // Spawn a health check task per target
412 - let mut handles = Vec::new();
413 -
414 - for name in config.target_names() {
415 - let target_config = config.get_target(&name).unwrap().clone();
416 - if let Some(health_config) = target_config.health {
417 - let interval_secs = health_config.interval_secs.unwrap_or(default_interval);
418 - let pool = pool.clone();
419 - let name = name.clone();
420 -
421 - info!("{name}: health check every {interval_secs}s");
422 -
423 - handles.push(tokio::spawn(async move {
424 - let mut interval = tokio::time::interval(
425 - std::time::Duration::from_secs(interval_secs),
426 - );
427 - loop {
428 - interval.tick().await;
429 - let snapshot = http::check_health(&name, &health_config).await;
430 - info!("{}: {} ({}ms)", name, snapshot.status, snapshot.response_time_ms);
431 - if let Err(e) = db::insert_health_check(&pool, &snapshot).await {
432 - tracing::error!("{name}: failed to store health check: {e}");
433 - }
434 - }
435 - }));
436 - }
437 - }
438 -
439 - // Spawn daily prune task
440 - let prune_pool = pool.clone();
441 - handles.push(tokio::spawn(async move {
442 - let mut interval = tokio::time::interval(
443 - std::time::Duration::from_secs(86400),
444 - );
445 - loop {
446 - interval.tick().await;
447 - match db::prune_old_records(&prune_pool, prune_days).await {
448 - Ok((h, t)) => info!("Pruned {h} health checks, {t} test runs"),
449 - Err(e) => tracing::error!("Prune failed: {e}"),
450 - }
451 - }
452 - }));
453 -
454 - // Wait for shutdown signal
455 - let mut sigterm = tokio::signal::unix::signal(
456 - tokio::signal::unix::SignalKind::terminate(),
457 - )?;
458 -
459 - tokio::select! {
460 - _ = tokio::signal::ctrl_c() => {
461 - info!("Received SIGINT, shutting down");
462 - }
463 - _ = sigterm.recv() => {
464 - info!("Received SIGTERM, shutting down");
465 - }
466 - }
467 -
468 - for handle in handles {
469 - handle.abort();
470 - }
471 -
472 - Ok(())
473 - }
M src/types.rs +255
@@ -1,3 +1,5 @@
1 + //! Shared domain types — health snapshots, test runs, and target info.
2 +
1 3 use serde::{Deserialize, Serialize};
2 4
3 5 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -9,6 +11,18 @@
9 11 Unreachable,
10 12 }
11 13
14 + impl HealthStatus {
15 + /// Short label for CLI output.
16 + pub fn icon(&self) -> &'static str {
17 + match self {
18 + Self::Operational => "OK",
19 + Self::Degraded => "WARN",
20 + Self::Error => "ERR",
21 + Self::Unreachable => "DOWN",
22 + }
23 + }
24 + }
25 +
12 26 impl std::fmt::Display for HealthStatus {
13 27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14 28 match self {
@@ -79,6 +93,21 @@
79 93 pub passed: bool,
80 94 }
81 95
96 + #[derive(Debug, Clone, Serialize, Deserialize)]
97 + pub struct TlsStatus {
98 + pub target: String,
99 + pub host: String,
100 + pub port: u16,
101 + pub valid: bool,
102 + pub days_remaining: i64,
103 + pub not_before: String,
104 + pub not_after: String,
105 + pub subject: String,
106 + pub issuer: String,
107 + pub checked_at: String,
108 + pub error: Option<String>,
109 + }
110 +
82 111 #[derive(Debug, Clone, Serialize, Deserialize)]
83 112 pub struct TargetInfo {
84 113 pub name: String,
@@ -86,3 +115,229 @@
86 115 pub has_health: bool,
87 116 pub has_tests: bool,
88 117 }
118 +
119 + #[derive(Debug, Clone, Serialize, Deserialize)]
120 + pub struct LatencyStats {
121 + pub min_ms: i64,
122 + pub max_ms: i64,
123 + pub avg_ms: f64,
124 + pub p95_ms: i64,
125 + pub sample_count: i64,
126 + }
127 +
128 + #[derive(Debug, Clone, Serialize, Deserialize)]
129 + pub struct LatencyBucket {
130 + pub period_start: String,
131 + pub min_ms: i64,
132 + pub max_ms: i64,
133 + pub avg_ms: f64,
134 + pub p95_ms: i64,
135 + pub sample_count: i64,
136 + }
137 +
138 + #[derive(Debug, Clone, Serialize, Deserialize)]
139 + pub struct TestStaleness {
140 + pub stale: bool,
141 + pub reason: Option<String>,
142 + pub current_version: Option<String>,
143 + pub tested_version: Option<String>,
144 + pub last_test_at: Option<String>,
145 + pub days_since_test: Option<i64>,
146 + }
147 +
148 + impl LatencyStats {
149 + /// Compute latency statistics from a slice of response times.
150 + /// Returns `None` if the slice is empty.
151 + pub fn from_times(times: &[i64]) -> Option<Self> {
152 + if times.is_empty() {
153 + return None;
154 + }
155 + let mut sorted = times.to_vec();
156 + sorted.sort_unstable();
157 + let n = sorted.len();
158 + let min_ms = sorted[0];
159 + let max_ms = sorted[n - 1];
160 + let sum: i64 = sorted.iter().sum();
161 + let avg_ms = sum as f64 / n as f64;
162 + let p95_idx = ((n as f64 * 0.95).ceil() as usize).saturating_sub(1).min(n - 1);
163 + let p95_ms = sorted[p95_idx];
164 + Some(Self {
165 + min_ms,
166 + max_ms,
167 + avg_ms,
168 + p95_ms,
169 + sample_count: n as i64,
170 + })
171 + }
172 +
173 + /// Group timestamped response times into fixed-width buckets and compute
174 + /// per-bucket statistics.
175 + pub fn bucket_by_time(data: &[(String, i64)], bucket_minutes: u64) -> Vec<LatencyBucket> {
176 + if data.is_empty() || bucket_minutes == 0 {
177 + return Vec::new();
178 + }
179 + let bucket_secs = bucket_minutes * 60;
180 + let mut buckets: Vec<(i64, Vec<i64>)> = Vec::new();
181 +
182 + for (ts, ms) in data {
183 + let epoch = chrono::DateTime::parse_from_rfc3339(ts)
184 + .map(|dt| dt.timestamp())
185 + .unwrap_or(0);
186 + let bucket_start = epoch - (epoch % bucket_secs as i64);
187 + if let Some(last) = buckets.last_mut()
188 + && last.0 == bucket_start
189 + {
190 + last.1.push(*ms);
191 + continue;
192 + }
193 + buckets.push((bucket_start, vec![*ms]));
194 + }
195 +
196 + buckets
197 + .into_iter()
198 + .filter_map(|(start_epoch, times)| {
199 + let stats = Self::from_times(&times)?;
200 + let period_start = chrono::DateTime::from_timestamp(start_epoch, 0)
201 + .map(|dt| dt.to_rfc3339())
202 + .unwrap_or_default();
203 + Some(LatencyBucket {
204 + period_start,
205 + min_ms: stats.min_ms,
206 + max_ms: stats.max_ms,
207 + avg_ms: stats.avg_ms,
208 + p95_ms: stats.p95_ms,
209 + sample_count: stats.sample_count,
210 + })
211 + })
212 + .collect()
213 + }
214 + }
215 +
216 + #[cfg(test)]
217 + mod tests {
218 + use super::*;
219 +
220 + #[test]
221 + fn health_status_display_roundtrip() {
222 + for status in [
223 + HealthStatus::Operational,
224 + HealthStatus::Degraded,
225 + HealthStatus::Error,
226 + HealthStatus::Unreachable,
227 + ] {
228 + let s = status.to_string();
229 + let parsed: HealthStatus = s.parse().unwrap();
230 + assert_eq!(parsed, status);
231 + }
232 + }
233 +
234 + #[test]
235 + fn health_status_icons() {
236 + assert_eq!(HealthStatus::Operational.icon(), "OK");
237 + assert_eq!(HealthStatus::Degraded.icon(), "WARN");
238 + assert_eq!(HealthStatus::Error.icon(), "ERR");
239 + assert_eq!(HealthStatus::Unreachable.icon(), "DOWN");
240 + }
241 +
242 + #[test]
243 + fn health_status_from_str_rejects_unknown() {
244 + assert!("bogus".parse::<HealthStatus>().is_err());
245 + }
246 +
247 + #[test]
248 + fn health_status_serde_roundtrip() {
249 + let status = HealthStatus::Operational;
250 + let json = serde_json::to_string(&status).unwrap();
251 + assert_eq!(json, "\"operational\"");
252 + let parsed: HealthStatus = serde_json::from_str(&json).unwrap();
253 + assert_eq!(parsed, status);
254 + }
255 +
256 + // --- LatencyStats ---
257 +
258 + #[test]
259 + fn latency_stats_single_element() {
260 + let stats = LatencyStats::from_times(&[100]).unwrap();
261 + assert_eq!(stats.min_ms, 100);
262 + assert_eq!(stats.max_ms, 100);
263 + assert_eq!(stats.avg_ms, 100.0);
264 + assert_eq!(stats.p95_ms, 100);
265 + assert_eq!(stats.sample_count, 1);
266 + }
267 +
268 + #[test]
269 + fn latency_stats_five_elements() {
270 + let stats = LatencyStats::from_times(&[50, 100, 150, 200, 250]).unwrap();
271 + assert_eq!(stats.min_ms, 50);
272 + assert_eq!(stats.max_ms, 250);
273 + assert_eq!(stats.avg_ms, 150.0);
274 + assert_eq!(stats.p95_ms, 250);
275 + assert_eq!(stats.sample_count, 5);
276 + }
277 +
278 + #[test]
279 + fn latency_stats_hundred_elements() {
280 + let times: Vec<i64> = (1..=100).collect();
281 + let stats = LatencyStats::from_times(&times).unwrap();
282 + assert_eq!(stats.min_ms, 1);
283 + assert_eq!(stats.max_ms, 100);
284 + assert_eq!(stats.p95_ms, 95);
285 + assert_eq!(stats.sample_count, 100);
286 + }
287 +
288 + #[test]
289 + fn latency_stats_empty() {
290 + assert!(LatencyStats::from_times(&[]).is_none());
291 + }
292 +
293 + #[test]
294 + fn latency_stats_p95_boundary() {
295 + // 20 elements: p95 index = ceil(20 * 0.95) - 1 = 18 → value 19
296 + let times: Vec<i64> = (1..=20).collect();
297 + let stats = LatencyStats::from_times(&times).unwrap();
298 + assert_eq!(stats.p95_ms, 19);
299 + }
300 +
301 + #[test]
302 + fn latency_bucket_by_time_even_split() {
303 + let data: Vec<(String, i64)> = vec![
304 + ("2026-03-10T00:00:00+00:00".to_string(), 100),
305 + ("2026-03-10T00:30:00+00:00".to_string(), 120),
306 + ("2026-03-10T01:00:00+00:00".to_string(), 140),
307 + ("2026-03-10T01:30:00+00:00".to_string(), 160),
308 + ];
309 + let buckets = LatencyStats::bucket_by_time(&data, 60);
310 + assert_eq!(buckets.len(), 2);
311 + assert_eq!(buckets[0].sample_count, 2);
312 + assert_eq!(buckets[1].sample_count, 2);
313 + }
314 +
315 + #[test]
316 + fn latency_bucket_by_time_single_bucket() {
317 + let data: Vec<(String, i64)> = vec![
318 + ("2026-03-10T00:01:00+00:00".to_string(), 100),
319 + ("2026-03-10T00:02:00+00:00".to_string(), 200),
320 + ];
321 + let buckets = LatencyStats::bucket_by_time(&data, 60);
322 + assert_eq!(buckets.len(), 1);
323 + assert_eq!(buckets[0].sample_count, 2);
324 + assert_eq!(buckets[0].avg_ms, 150.0);
325 + }
326 +
327 + #[test]
328 + fn latency_bucket_by_time_empty() {
329 + let buckets = LatencyStats::bucket_by_time(&[], 60);
330 + assert!(buckets.is_empty());
331 + }
332 +
333 + #[test]
334 + fn latency_stats_serde_roundtrip() {
335 + let stats = LatencyStats::from_times(&[50, 100, 150]).unwrap();
336 + let json = serde_json::to_string(&stats).unwrap();
337 + let parsed: LatencyStats = serde_json::from_str(&json).unwrap();
338 + assert_eq!(parsed.min_ms, stats.min_ms);
339 + assert_eq!(parsed.max_ms, stats.max_ms);
340 + assert_eq!(parsed.p95_ms, stats.p95_ms);
341 + assert_eq!(parsed.sample_count, stats.sample_count);
342 + }
343 + }
@@ -1,4 +1,12 @@
1 + use std::collections::HashMap;
2 + use std::str::FromStr;
3 +
4 + use axum::body::Body;
5 + use http_body_util::BodyExt;
6 + use tower::ServiceExt;
7 +
1 8 use pom::db;
9 + use pom::tools::PomServer;
2 10 use pom::types::*;
3 11
4 12 #[tokio::test]
@@ -167,7 +175,7 @@
167 175 };
168 176 db::insert_health_check(&pool, &recent).await.unwrap();
169 177
170 - let (health_pruned, _) = db::prune_old_records(&pool, 30).await.unwrap();
178 + let (health_pruned, _, _, _, _, _) = db::prune_old_records(&pool, 30).await.unwrap();
171 179 assert_eq!(health_pruned, 1);
172 180
173 181 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
@@ -209,3 +217,1398 @@
209 217 assert_eq!(summary.total_passed, Some(45));
210 218 assert_eq!(summary.total_failed, Some(0));
211 219 }
220 +
221 + #[tokio::test]
222 + async fn peer_identity_first_wins() {
223 + let pool = db::connect_in_memory().await.unwrap();
224 +
225 + db::store_peer_identity(&pool, "astra", "uuid-1").await.unwrap();
226 + // Second insert with different ID should be ignored (INSERT OR IGNORE)
227 + db::store_peer_identity(&pool, "astra", "uuid-2").await.unwrap();
228 +
229 + let stored = db::get_peer_identity(&pool, "astra").await.unwrap();
230 + assert_eq!(stored, Some("uuid-1".to_string()));
231 + }
232 +
233 + #[tokio::test]
234 + async fn peer_heartbeat_insert_and_query() {
235 + let pool = db::connect_in_memory().await.unwrap();
236 +
237 + db::insert_peer_heartbeat(&pool, "astra", "online", 42).await.unwrap();
238 + db::insert_peer_heartbeat(&pool, "astra", "online", 55).await.unwrap();
239 + db::insert_peer_heartbeat(&pool, "astra", "missing", 0).await.unwrap();
240 +
241 + let history = db::get_peer_heartbeat_history(&pool, "astra", 10).await.unwrap();
242 + assert_eq!(history.len(), 3);
243 + // Most recent first
244 + assert_eq!(history[0].status, "missing");
245 + assert_eq!(history[1].latency_ms, 55);
246 + }
247 +
248 + // --- API endpoint tests ---
249 +
250 + fn test_config() -> pom::config::Config {
251 + toml::from_str(
252 + r#"
253 + [targets.mnw]
254 + label = "MakeNotWork"
255 + [targets.mnw.health]
256 + url = "https://makenot.work/health"
257 + "#,
258 + )
259 + .unwrap()
260 + }
261 +
262 + fn test_mesh() -> pom::peer::SharedMeshState {
263 + let info = pom::peer::InstanceInfo {
264 + id: "test-uuid".to_string(),
265 + name: "test-node".to_string(),
266 + version: "0.1.0".to_string(),
267 + targets: vec!["mnw".to_string()],
268 + started_at: "2026-03-10T00:00:00Z".to_string(),
269 + };
270 + pom::peer::new_mesh_state(info, &HashMap::new())
271 + }
272 +
273 + async fn api_get(app: &axum::Router, path: &str) -> (u16, serde_json::Value) {
274 + let req = axum::http::Request::builder()
275 + .uri(path)
276 + .body(Body::empty())
277 + .unwrap();
278 + let resp = app.clone().oneshot(req).await.unwrap();
279 + let status = resp.status().as_u16();
280 + let body = resp.into_body().collect().await.unwrap().to_bytes();
281 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
282 + (status, json)
283 + }
284 +
285 + #[tokio::test]
286 + async fn api_status_returns_targets() {
287 + let pool = db::connect_in_memory().await.unwrap();
288 + let config = test_config();
289 + let app = pom::api::router(pool.clone(), config, None);
290 +
291 + // Insert a health check so there's data
292 + let snapshot = HealthSnapshot {
293 + id: None,
294 + target: "mnw".to_string(),
295 + status: HealthStatus::Operational,
296 + checked_at: "2026-03-10T00:00:00Z".to_string(),
297 + response_time_ms: 120,
298 + details: None,
299 + error: None,
300 + };
301 + db::insert_health_check(&pool, &snapshot).await.unwrap();
302 +
303 + let (status, json) = api_get(&app, "/api/status").await;
304 + assert_eq!(status, 200);
305 + assert!(json["targets"]["mnw"].is_object());
306 + assert_eq!(json["targets"]["mnw"]["label"], "MakeNotWork");
307 + assert_eq!(json["targets"]["mnw"]["latest"]["status"], "operational");
308 + assert_eq!(json["targets"]["mnw"]["latest"]["response_time_ms"], 120);
309 + }
310 +
311 + #[tokio::test]
312 + async fn api_status_target_not_found() {
313 + let pool = db::connect_in_memory().await.unwrap();
314 + let config = test_config();
315 + let app = pom::api::router(pool, config, None);
316 +
317 + let (status, json) = api_get(&app, "/api/status/nonexistent").await;
318 + assert_eq!(status, 404);
319 + assert!(json["error"].as_str().unwrap().contains("unknown target"));
320 + }
321 +
322 + #[tokio::test]
323 + async fn api_peer_info_returns_instance() {
324 + let pool = db::connect_in_memory().await.unwrap();
325 + let config = test_config();
326 + let mesh = test_mesh();
327 + let app = pom::api::router(pool, config, Some(mesh));
328 +
329 + let (status, json) = api_get(&app, "/api/peer/info").await;
330 + assert_eq!(status, 200);
331 + assert_eq!(json["id"], "test-uuid");
332 + assert_eq!(json["name"], "test-node");
333 + }
334 +
335 + #[tokio::test]
336 + async fn api_peer_info_disabled_without_mesh() {
337 + let pool = db::connect_in_memory().await.unwrap();
338 + let config = test_config();
339 + let app = pom::api::router(pool, config, None);
340 +
341 + let (status, json) = api_get(&app, "/api/peer/info").await;
342 + assert_eq!(status, 503);
343 + assert!(json["error"].as_str().unwrap().contains("not enabled"));
344 + }
345 +
346 + #[tokio::test]
347 + async fn api_mesh_view_includes_self() {
348 + let pool = db::connect_in_memory().await.unwrap();
349 + let config = test_config();
350 + let mesh = test_mesh();
351 + let app = pom::api::router(pool, config, Some(mesh));
352 +
353 + let (status, json) = api_get(&app, "/api/mesh").await;
354 + assert_eq!(status, 200);
355 + assert!(json["instances"]["test-node"].is_object());
356 + assert_eq!(json["instances"]["test-node"]["instance"]["id"], "test-uuid");
357 + }
358 +
359 + // --- Migration tests ---
360 +
361 + #[tokio::test]
362 + async fn migration_fresh_db_reaches_latest_version() {
363 + // A fresh in-memory DB should run all migrations and reach version 4.
364 + let pool = db::connect_in_memory().await.unwrap();
365 + let version = db::get_schema_version(&pool).await.unwrap();
366 + assert_eq!(version, 4);
367 +
368 + // Verify the schema_version table has entries for each migration
369 + let rows = sqlx::query_as::<_, (i64, String)>(
370 + "SELECT version, description FROM schema_version ORDER BY version",
371 + )
372 + .fetch_all(&pool)
373 + .await
374 + .unwrap();
375 + assert_eq!(rows.len(), 4);
376 + assert_eq!(rows[0].0, 1);
377 + assert_eq!(rows[0].1, "initial schema");
378 + assert_eq!(rows[1].0, 2);
379 + assert_eq!(rows[1].1, "add alerts table");
380 + assert_eq!(rows[2].0, 3);
381 + assert_eq!(rows[2].1, "add tls_checks table");
382 + assert_eq!(rows[3].0, 4);
383 + assert_eq!(rows[3].1, "add incidents table");
384 +
385 + // Verify actual tables were created by inserting data
386 + let snapshot = HealthSnapshot {
387 + id: None,
388 + target: "test".to_string(),
389 + status: HealthStatus::Operational,
390 + checked_at: "2026-03-11T00:00:00Z".to_string(),
391 + response_time_ms: 50,
392 + details: None,
393 + error: None,
394 + };
395 + let id = db::insert_health_check(&pool, &snapshot).await.unwrap();
396 + assert!(id > 0);
397 + }
398 +
399 + #[tokio::test]
400 + async fn migration_already_current_is_idempotent() {
401 + // Running migrations on an already-migrated DB should be a no-op.
402 + let pool = db::connect_in_memory().await.unwrap();
403 + assert_eq!(db::get_schema_version(&pool).await.unwrap(), 4);
404 +
405 + // Run migrations again
406 + db::run_migrations(&pool).await.unwrap();
407 + assert_eq!(db::get_schema_version(&pool).await.unwrap(), 4);
408 +
409 + // schema_version should still have exactly four entries (not duplicated)
410 + let count = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM schema_version")
411 + .fetch_one(&pool)
412 + .await
413 + .unwrap();
414 + assert_eq!(count.0, 4);
415 + }
416 +
417 + #[tokio::test]
418 + async fn migration_detects_pre_migration_database() {
419 + // Simulate a pre-migration database: create tables manually without schema_version.
420 + let opts = sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
421 + let pool = sqlx::sqlite::SqlitePoolOptions::new()
422 + .max_connections(1)
423 + .connect_with(opts)
424 + .await
425 + .unwrap();
426 +
427 + // Create the old-style tables directly (as init_schema used to do)
428 + sqlx::query(
429 + "CREATE TABLE health_checks (
430 + id INTEGER PRIMARY KEY AUTOINCREMENT,
431 + target TEXT NOT NULL,
432 + status TEXT NOT NULL,
433 + checked_at TEXT NOT NULL,
434 + response_time_ms INTEGER NOT NULL,
435 + details_json TEXT,
436 + error TEXT
437 + )",
438 + )
439 + .execute(&pool)
440 + .await
441 + .unwrap();
442 +
443 + sqlx::query(
444 + "CREATE TABLE test_runs (
445 + id INTEGER PRIMARY KEY AUTOINCREMENT,
446 + target TEXT NOT NULL,
447 + started_at TEXT NOT NULL,
448 + finished_at TEXT,
449 + duration_secs INTEGER,
450 + exit_code INTEGER,
451 + passed INTEGER NOT NULL,
452 + summary_json TEXT NOT NULL,
453 + raw_output TEXT NOT NULL,
454 + filter TEXT
455 + )",
456 + )
457 + .execute(&pool)
458 + .await
459 + .unwrap();
460 +
461 + // Insert some existing data to verify it's preserved
462 + sqlx::query(
463 + "INSERT INTO health_checks (target, status, checked_at, response_time_ms)
464 + VALUES ('mnw', 'operational', '2026-03-10T00:00:00Z', 100)",
465 + )
466 + .execute(&pool)
467 + .await
468 + .unwrap();
469 +
470 + // Now run migrations — should detect existing tables, stamp as v1, then run v2+v3+v4
471 + db::run_migrations(&pool).await.unwrap();
472 +
473 + // Version should be 4 (stamped v1 + ran v2 + ran v3 + ran v4)
474 + assert_eq!(db::get_schema_version(&pool).await.unwrap(), 4);
475 +
476 + // Description should indicate pre-existing
477 + let row = sqlx::query_as::<_, (String,)>(
478 + "SELECT description FROM schema_version WHERE version = 1",
479 + )
480 + .fetch_one(&pool)
481 + .await
482 + .unwrap();
483 + assert!(row.0.contains("pre-existing"));
484 +
485 + // Existing data should be preserved
486 + let history = db::get_health_history(&pool, Some("mnw"), 10).await.unwrap();
487 + assert_eq!(history.len(), 1);
488 + assert_eq!(history[0].response_time_ms, 100);
489 + }
490 +
491 + // --- MCP tool tests ---
492 +
493 + fn test_server(pool: sqlx::SqlitePool) -> PomServer {
494 + PomServer::new(pool, test_config())
495 + }
496 +
497 + #[tokio::test]
498 + async fn tool_get_status_with_data() {
499 + let pool = db::connect_in_memory().await.unwrap();
500 + let server = test_server(pool.clone());
501 +
502 + // Insert health + test data
503 + let snapshot = HealthSnapshot {
504 + id: None,
505 + target: "mnw".to_string(),
506 + status: HealthStatus::Operational,
507 + checked_at: "2026-03-10T00:00:00Z".to_string(),
508 + response_time_ms: 95,
509 + details: Some(HealthDetails {
510 + version: Some("2.1.0".to_string()),
511 + uptime: Some("3d".to_string()),
512 + checks: None,
513 + monitoring: None,
514 + }),
515 + error: None,
516 + };
517 + db::insert_health_check(&pool, &snapshot).await.unwrap();
518 +
519 + let run = TestRun {
520 + id: None,
521 + target: "mnw".to_string(),
522 + started_at: "2026-03-10T00:00:00Z".to_string(),
523 + finished_at: Some("2026-03-10T00:01:00Z".to_string()),
524 + duration_secs: Some(60),
525 + exit_code: Some(0),
526 + passed: true,
527 + summary: TestSummary {
528 + steps: vec![StepResult { name: "cargo test".to_string(), passed: true }],
529 + total_passed: Some(100),
530 + total_failed: Some(0),
531 + },
532 + raw_output: "all good".to_string(),
533 + filter: None,
534 + };
535 + db::insert_test_run(&pool, &run).await.unwrap();
536 +
537 + let result = server.get_status_impl().await.unwrap();
538 + assert!(result.contains("## mnw (MakeNotWork)"));
539 + assert!(result.contains("operational"));
540 + assert!(result.contains("95ms"));
541 + assert!(result.contains("Version: 2.1.0"));
542 + assert!(result.contains("Uptime: 3d"));
543 + assert!(result.contains("PASSED"));
544 + assert!(result.contains("100 passed, 0 failed"));
545 + assert!(result.contains("PASS cargo test"));
546 + }
547 +
548 + #[tokio::test]
549 + async fn tool_get_status_no_data() {
550 + let pool = db::connect_in_memory().await.unwrap();
551 + let server = test_server(pool);
552 +
553 + let result = server.get_status_impl().await.unwrap();
554 + assert!(result.contains("Health: no data"));
555 + assert!(result.contains("Tests: no data"));
556 + }
557 +
558 + #[tokio::test]
559 + async fn tool_get_status_no_targets() {
560 + let pool = db::connect_in_memory().await.unwrap();
561 + let config: pom::config::Config = toml::from_str("").unwrap();
562 + let server = PomServer::new(pool, config);
563 +
564 + let result = server.get_status_impl().await.unwrap();
565 + assert_eq!(result, "No targets configured.");
566 + }
567 +
568 + #[tokio::test]
569 + async fn tool_list_targets() {
570 + let pool = db::connect_in_memory().await.unwrap();
571 + let server = test_server(pool);
572 +
573 + let result = server.list_targets_impl().await.unwrap();
574 + let targets: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
575 + assert_eq!(targets.len(), 1);
576 + assert_eq!(targets[0]["name"], "mnw");
577 + assert_eq!(targets[0]["label"], "MakeNotWork");
578 + assert_eq!(targets[0]["has_health"], true);
579 + assert_eq!(targets[0]["has_tests"], false); // test_config has no tests section
580 + }
581 +
582 + #[tokio::test]
583 + async fn tool_health_history_with_data() {
584 + let pool = db::connect_in_memory().await.unwrap();
585 + let server = test_server(pool.clone());
586 +
587 + for i in 0..3 {
588 + let snapshot = HealthSnapshot {
589 + id: None,
590 + target: "mnw".to_string(),
591 + status: HealthStatus::Operational,
592 + checked_at: format!("2026-03-10T0{i}:00:00Z"),
593 + response_time_ms: 100 + i * 10,
594 + details: None,
595 + error: None,
596 + };
597 + db::insert_health_check(&pool, &snapshot).await.unwrap();
598 + }
599 +
600 + let params = pom::tools::health::HealthHistoryParams {
601 + target: Some("mnw".to_string()),
602 + limit: Some(2),
603 + };
604 + let result = server.health_history_impl(params).await.unwrap();
605 + let history: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
606 + assert_eq!(history.len(), 2);
607 + }
608 +
609 + #[tokio::test]
610 + async fn tool_health_history_empty() {
611 + let pool = db::connect_in_memory().await.unwrap();
612 + let server = test_server(pool);
613 +
614 + let params = pom::tools::health::HealthHistoryParams {
615 + target: None,
616 + limit: None,
617 + };
618 + let result = server.health_history_impl(params).await.unwrap();
619 + assert_eq!(result, "No health check history.");
620 + }
621 +
622 + #[tokio::test]
623 + async fn tool_health_history_default_limit() {
624 + let pool = db::connect_in_memory().await.unwrap();
625 + let server = test_server(pool.clone());
626 +
627 + for i in 0..15 {
628 + let snapshot = HealthSnapshot {
629 + id: None,
630 + target: "mnw".to_string(),
631 + status: HealthStatus::Operational,
632 + checked_at: format!("2026-03-10T{:02}:00:00Z", i),
633 + response_time_ms: 100,
634 + details: None,
635 + error: None,
636 + };
637 + db::insert_health_check(&pool, &snapshot).await.unwrap();
638 + }
639 +
640 + let params = pom::tools::health::HealthHistoryParams {
641 + target: None,
642 + limit: None, // should default to 10
643 + };
644 + let result = server.health_history_impl(params).await.unwrap();
645 + let history: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
646 + assert_eq!(history.len(), 10);
647 + }
648 +
649 + #[tokio::test]
650 + async fn tool_check_health_unknown_target() {
651 + let pool = db::connect_in_memory().await.unwrap();
652 + let server = test_server(pool);
653 +
654 + let params = pom::tools::health::CheckHealthParams {
655 + target: Some("nonexistent".to_string()),
656 + };
657 + let result = server.check_health_impl(params).await.unwrap();
658 + assert_eq!(result, "Unknown target: nonexistent");
659 + }
660 +
661 + #[tokio::test]
662 + async fn tool_test_history_strips_raw_output() {
663 + let pool = db::connect_in_memory().await.unwrap();
664 + let server = test_server(pool.clone());
665 +
666 + let run = TestRun {
667 + id: None,
668 + target: "mnw".to_string(),
669 + started_at: "2026-03-10T00:00:00Z".to_string(),
670 + finished_at: None,
671 + duration_secs: None,
672 + exit_code: None,
673 + passed: true,
674 + summary: TestSummary { steps: vec![], total_passed: None, total_failed: None },
675 + raw_output: "HUGE OUTPUT THAT SHOULD NOT APPEAR".to_string(),
676 + filter: None,
677 + };
678 + db::insert_test_run(&pool, &run).await.unwrap();
679 +
680 + let params = pom::tools::tests::TestHistoryParams {
681 + target: Some("mnw".to_string()),
682 + limit: None,
683 + };
684 + let result = server.test_history_impl(params).await.unwrap();
685 + assert!(!result.contains("HUGE OUTPUT"));
686 + assert!(result.contains("mnw"));
687 + }
688 +
689 + #[tokio::test]
690 + async fn tool_test_history_empty() {
691 + let pool = db::connect_in_memory().await.unwrap();
692 + let server = test_server(pool);
693 +
694 + let params = pom::tools::tests::TestHistoryParams {
695 + target: None,
696 + limit: None,
Lines truncated
M src/checks/http.rs +436 -27
@@ -1,11 +1,12 @@
1 1 use std::time::Instant;
2 2
3 - use crate::config::HealthConfig;
3 + use crate::config::{HealthConfig, HealthExpectation};
4 4 use crate::types::{HealthDetails, HealthSnapshot, HealthStatus};
5 5
6 6 pub async fn check_health(
7 7 target_name: &str,
8 8 config: &HealthConfig,
9 + expect: Option<&HealthExpectation>,
9 10 ) -> HealthSnapshot {
10 11 let client = reqwest::Client::builder()
11 12 .timeout(std::time::Duration::from_secs(config.timeout_secs))
@@ -18,28 +19,27 @@
18 19 match client.get(&config.url).send().await {
19 20 Ok(response) => {
20 21 let response_time_ms = start.elapsed().as_millis() as i64;
21 - let status_code = response.status();
22 + let status_code = response.status().as_u16();
22 23
23 - match response.json::<serde_json::Value>().await {
24 - Ok(json) => {
25 - let api_status = json
26 - .get("status")
27 - .and_then(|s| s.as_str())
28 - .unwrap_or("unknown");
24 + match response.text().await {
25 + Ok(body) => {
26 + let json: Option<serde_json::Value> = serde_json::from_str(&body).ok();
29 27
30 - let status = match api_status {
31 - "operational" => HealthStatus::Operational,
32 - "degraded" => HealthStatus::Degraded,
33 - _ if status_code.is_success() => HealthStatus::Degraded,
34 - _ => HealthStatus::Error,
28 + let (mut status, details, mut error) = if let Some(ref json) = json {
29 + let (s, d) = classify_json_response(status_code, json);
30 + (s, Some(d), None)
31 + } else {
32 + (classify_non_json(status_code), None, Some("Failed to parse response as JSON".to_string()))
35 33 };
36 34
37 - let details = HealthDetails {
38 - version: json.get("version").and_then(|v| v.as_str()).map(String::from),
39 - uptime: json.get("uptime").and_then(|v| v.as_str()).map(String::from),
40 - checks: json.get("checks").cloned(),
41 - monitoring: json.get("monitoring").cloned(),
42 - };
35 + // Apply expectation validation
36 + if let Some(exp) = expect {
37 + let failures = validate_expectations(exp, status_code, &body, json.as_ref());
38 + if !failures.is_empty() {
39 + status = HealthStatus::Degraded;
40 + error = Some(failures.join("; "));
41 + }
42 + }
43 43
44 44 HealthSnapshot {
45 45 id: None,
@@ -47,22 +47,18 @@
47 47 status,
48 48 checked_at,
49 49 response_time_ms,
50 - details: Some(details),
51 - error: None,
50 + details,
51 + error,
52 52 }
53 53 }
54 54 Err(e) => HealthSnapshot {
55 55 id: None,
56 56 target: target_name.to_string(),
57 - status: if status_code.is_success() {
58 - HealthStatus::Degraded
59 - } else {
60 - HealthStatus::Error
61 - },
57 + status: classify_non_json(status_code),
62 58 checked_at,
63 59 response_time_ms,
64 60 details: None,
65 - error: Some(format!("Failed to parse response: {e}")),
61 + error: Some(format!("Failed to read response body: {e}")),
66 62 },
67 63 }
68 64 }
@@ -80,3 +76,524 @@
80 76 }
81 77 }
82 78 }
79 +
80 + /// Walk a dot-separated path through nested JSON objects.
81 + pub fn resolve_json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
82 + let mut current = value;
83 + for key in path.split('.') {
84 + current = current.get(key)?;
85 + }
86 + Some(current)
87 + }
88 +
89 + /// Validate response against expectations. Returns a list of failure descriptions.
90 + pub fn validate_expectations(
91 + expect: &HealthExpectation,
92 + status_code: u16,
93 + body: &str,
94 + json: Option<&serde_json::Value>,
95 + ) -> Vec<String> {
96 + let mut failures = Vec::new();
97 +
98 + if let Some(expected_code) = expect.status_code
99 + && status_code != expected_code
100 + {
101 + failures.push(format!("expected status {expected_code}, got {status_code}"));
102 + }
103 +
104 + if let Some(ref substring) = expect.body_contains
105 + && !body.contains(substring.as_str())
106 + {
107 + failures.push(format!("body missing expected substring \"{substring}\""));
108 + }
109 +
110 + if !expect.json_fields.is_empty() {
111 + if let Some(json) = json {
112 + for (path, expected_value) in &expect.json_fields {
113 + match resolve_json_path(json, path) {
114 + Some(actual) => {
115 + let actual_str = match actual {
116 + serde_json::Value::String(s) => s.clone(),
117 + other => other.to_string(),
118 + };
119 + if actual_str != *expected_value {
120 + failures.push(format!("json field \"{path}\": expected \"{expected_value}\", got \"{actual_str}\""));
121 + }
122 + }
123 + None => {
124 + failures.push(format!("json field \"{path}\" not found"));
125 + }
126 + }
127 + }
128 + } else {
129 + failures.push("expected JSON response for field validation, got non-JSON".to_string());
130 + }
131 + }
132 +
133 + failures
134 + }
135 +
136 + /// Classify a JSON health response into status + details.
137 + pub fn classify_json_response(
138 + status_code: u16,
139 + json: &serde_json::Value,
140 + ) -> (HealthStatus, HealthDetails) {
141 + let api_status = json
142 + .get("status")
143 + .and_then(|s| s.as_str())
144 + .unwrap_or("unknown");
145 +
146 + let status = match api_status {
147 + "operational" => HealthStatus::Operational,
148 + "degraded" => HealthStatus::Degraded,
149 + _ if (200..300).contains(&status_code) => HealthStatus::Degraded,
150 + _ => HealthStatus::Error,
151 + };
152 +
153 + let details = HealthDetails {
154 + version: json.get("version").and_then(|v| v.as_str()).map(String::from),
155 + uptime: json.get("uptime").and_then(|v| v.as_str()).map(String::from),
156 + checks: json.get("checks").cloned(),
157 + monitoring: json.get("monitoring").cloned(),
158 + };
159 +
160 + (status, details)
161 + }
162 +
163 + /// Classify a response that couldn't be parsed as JSON.
164 + pub fn classify_non_json(status_code: u16) -> HealthStatus {
165 + if (200..300).contains(&status_code) {
166 + HealthStatus::Degraded
167 + } else {
168 + HealthStatus::Error
169 + }
170 + }
171 +
172 + /// Detect sustained latency drift by checking if all recent response times
173 + /// exceed the baseline average by the given threshold multiplier.
174 + ///
175 + /// Returns a description string if drift is detected, `None` otherwise.
176 + /// Requires at least 10 baseline samples to avoid false positives.
177 + pub fn detect_latency_drift(
178 + recent_times: &[i64],
179 + baseline: &crate::types::LatencyStats,
180 + threshold: f64,
181 + ) -> Option<String> {
182 + if baseline.sample_count < 10 || recent_times.is_empty() {
183 + return None;
184 + }
185 + let drift_threshold = baseline.avg_ms * threshold;
186 + let all_over = recent_times.iter().all(|&t| t as f64 > drift_threshold);
187 + if all_over {
188 + let avg_recent: f64 = recent_times.iter().sum::<i64>() as f64 / recent_times.len() as f64;
189 + Some(format!(
190 + "latency drift: last {} checks avg {:.0}ms (baseline avg {:.0}ms, threshold {:.0}ms)",
191 + recent_times.len(),
192 + avg_recent,
193 + baseline.avg_ms,
194 + drift_threshold,
195 + ))
196 + } else {
197 + None
198 + }
199 + }
200 +
201 + /// Compute test staleness from version and timing data.
202 + ///
203 + /// A target's tests are considered stale when:
204 + /// 1. No tests have ever been run
205 + /// 2. Tests are older than `staleness_days`
206 + /// 3. The deployed version has changed since the last test run
207 + pub fn compute_test_staleness(
208 + current_version: Option<&str>,
209 + tested_version: Option<&str>,
210 + last_test_at: Option<&str>,
211 + staleness_days: u64,
212 + ) -> crate::types::TestStaleness {
213 + let Some(last_test_at) = last_test_at else {
214 + return crate::types::TestStaleness {
215 + stale: true,
216 + reason: Some("no tests have been run".to_string()),
217 + current_version: current_version.map(String::from),
218 + tested_version: None,
219 + last_test_at: None,
220 + days_since_test: None,
221 + };
222 + };
223 +
224 + let days_since = chrono::DateTime::parse_from_rfc3339(last_test_at)
225 + .ok()
226 + .map(|dt| {
227 + let now = chrono::Utc::now();
228 + (now - dt.with_timezone(&chrono::Utc)).num_days()
229 + });
230 +
231 + if let Some(days) = days_since
232 + && days >= staleness_days as i64
233 + {
234 + return crate::types::TestStaleness {
235 + stale: true,
236 + reason: Some(format!("tests are {days} days old (threshold: {staleness_days}d)")),
237 + current_version: current_version.map(String::from),
238 + tested_version: tested_version.map(String::from),
239 + last_test_at: Some(last_test_at.to_string()),
240 + days_since_test: Some(days),
241 + };
242 + }
243 +
244 + if let (Some(current), Some(tested)) = (current_version, tested_version)
245 + && current != tested
246 + {
247 + return crate::types::TestStaleness {
248 + stale: true,
249 + reason: Some(format!("version changed: {tested} -> {current}")),
250 + current_version: Some(current.to_string()),
251 + tested_version: Some(tested.to_string()),
252 + last_test_at: Some(last_test_at.to_string()),
253 + days_since_test: days_since,
254 + };
255 + }
256 +
257 + crate::types::TestStaleness {
258 + stale: false,
259 + reason: None,
260 + current_version: current_version.map(String::from),
261 + tested_version: tested_version.map(String::from),
262 + last_test_at: Some(last_test_at.to_string()),
263 + days_since_test: days_since,
264 + }
265 + }
266 +
267 + #[cfg(test)]
268 + mod tests {
269 + use super::*;
270 + use std::collections::HashMap;
271 +
272 + #[test]
273 + fn classify_operational() {
274 + let json = serde_json::json!({
275 + "status": "operational",
276 + "version": "2.1.0",
277 + "uptime": "3d 12h",
278 + });
279 + let (status, details) = classify_json_response(200, &json);
280 + assert_eq!(status, HealthStatus::Operational);
281 + assert_eq!(details.version.as_deref(), Some("2.1.0"));
282 + assert_eq!(details.uptime.as_deref(), Some("3d 12h"));
283 + }
284 +
285 + #[test]
286 + fn classify_degraded_explicit() {
287 + let json = serde_json::json!({ "status": "degraded" });
288 + let (status, _) = classify_json_response(200, &json);
289 + assert_eq!(status, HealthStatus::Degraded);
290 + }
291 +
292 + #[test]
293 + fn classify_unknown_status_with_success_code() {
294 + let json = serde_json::json!({ "status": "starting_up" });
295 + let (status, _) = classify_json_response(200, &json);
296 + assert_eq!(status, HealthStatus::Degraded);
297 + }
298 +
299 + #[test]
300 + fn classify_unknown_status_with_error_code() {
301 + let json = serde_json::json!({ "status": "starting_up" });
302 + let (status, _) = classify_json_response(503, &json);
303 + assert_eq!(status, HealthStatus::Error);
304 + }
305 +
306 + #[test]
307 + fn classify_missing_status_field() {
308 + let json = serde_json::json!({ "version": "1.0.0" });
309 + let (status, details) = classify_json_response(200, &json);
310 + assert_eq!(status, HealthStatus::Degraded); // "unknown" falls through
311 + assert_eq!(details.version.as_deref(), Some("1.0.0"));
312 + }
313 +
314 + #[test]
315 + fn classify_extracts_checks_and_monitoring() {
316 + let json = serde_json::json!({
317 + "status": "operational",
318 + "checks": { "db": "ok", "redis": "ok" },
319 + "monitoring": { "external": true },
320 + });
321 + let (_, details) = classify_json_response(200, &json);
322 + assert!(details.checks.is_some());
323 + assert!(details.monitoring.is_some());
324 + }
325 +
326 + #[test]
327 + fn classify_non_json_success() {
328 + assert_eq!(classify_non_json(200), HealthStatus::Degraded);
329 + assert_eq!(classify_non_json(204), HealthStatus::Degraded);
330 + }
331 +
332 + #[test]
333 + fn classify_non_json_error() {
334 + assert_eq!(classify_non_json(500), HealthStatus::Error);
335 + assert_eq!(classify_non_json(404), HealthStatus::Error);
336 + }
337 +
338 + // --- resolve_json_path ---
339 +
340 + #[test]
341 + fn resolve_json_path_top_level() {
342 + let json = serde_json::json!({"status": "operational"});
343 + let val = resolve_json_path(&json, "status").unwrap();
344 + assert_eq!(val, "operational");
345 + }
346 +
347 + #[test]
348 + fn resolve_json_path_nested() {
349 + let json = serde_json::json!({"checks": {"db": "ok", "redis": "warn"}});
350 + let val = resolve_json_path(&json, "checks.db").unwrap();
351 + assert_eq!(val, "ok");
352 + }
353 +
354 + #[test]
355 + fn resolve_json_path_deeply_nested() {
356 + let json = serde_json::json!({"a": {"b": {"c": 42}}});
357 + let val = resolve_json_path(&json, "a.b.c").unwrap();
358 + assert_eq!(val, 42);
359 + }
360 +
361 + #[test]
362 + fn resolve_json_path_missing() {
363 + let json = serde_json::json!({"status": "operational"});
364 + assert!(resolve_json_path(&json, "missing").is_none());
365 + }
366 +
367 + #[test]
368 + fn resolve_json_path_partial_missing() {
369 + let json = serde_json::json!({"checks": {"db": "ok"}});
370 + assert!(resolve_json_path(&json, "checks.redis").is_none());
371 + }
372 +
373 + // --- validate_expectations ---
374 +
375 + #[test]
376 + fn validate_status_code_match() {
377 + let expect = HealthExpectation {
378 + status_code: Some(200),
379 + ..Default::default()
380 + };
381 + let failures = validate_expectations(&expect, 200, "", None);
382 + assert!(failures.is_empty());
383 + }
384 +
385 + #[test]
386 + fn validate_status_code_mismatch() {
387 + let expect = HealthExpectation {
388 + status_code: Some(200),
389 + ..Default::default()
390 + };
391 + let failures = validate_expectations(&expect, 503, "", None);
392 + assert_eq!(failures.len(), 1);
393 + assert!(failures[0].contains("expected status 200"));
394 + assert!(failures[0].contains("got 503"));
395 + }
396 +
397 + #[test]
398 + fn validate_body_contains_match() {
399 + let expect = HealthExpectation {
400 + body_contains: Some("operational".to_string()),
401 + ..Default::default()
402 + };
403 + let failures = validate_expectations(&expect, 200, r#"{"status":"operational"}"#, None);
404 + assert!(failures.is_empty());
405 + }
406 +
407 + #[test]
408 + fn validate_body_contains_mismatch() {
409 + let expect = HealthExpectation {
410 + body_contains: Some("operational".to_string()),
411 + ..Default::default()
412 + };
413 + let failures = validate_expectations(&expect, 200, r#"{"status":"error"}"#, None);
414 + assert_eq!(failures.len(), 1);
415 + assert!(failures[0].contains("body missing"));
416 + }
417 +
418 + #[test]
419 + fn validate_json_fields_match() {
420 + let mut fields = HashMap::new();
421 + fields.insert("status".to_string(), "operational".to_string());
422 + fields.insert("checks.db".to_string(), "ok".to_string());
423 + let expect = HealthExpectation {
424 + json_fields: fields,
425 + ..Default::default()
426 + };
427 + let json = serde_json::json!({"status": "operational", "checks": {"db": "ok"}});
428 + let failures = validate_expectations(&expect, 200, "", Some(&json));
429 + assert!(failures.is_empty());
430 + }
431 +
432 + #[test]
433 + fn validate_json_fields_mismatch() {
434 + let mut fields = HashMap::new();
435 + fields.insert("status".to_string(), "operational".to_string());
436 + let expect = HealthExpectation {
437 + json_fields: fields,
438 + ..Default::default()
439 + };
440 + let json = serde_json::json!({"status": "degraded"});
441 + let failures = validate_expectations(&expect, 200, "", Some(&json));
442 + assert_eq!(failures.len(), 1);
443 + assert!(failures[0].contains("expected \"operational\""));
444 + assert!(failures[0].contains("got \"degraded\""));
445 + }
446 +
447 + #[test]
448 + fn validate_json_field_missing() {
449 + let mut fields = HashMap::new();
450 + fields.insert("checks.redis".to_string(), "ok".to_string());
451 + let expect = HealthExpectation {
452 + json_fields: fields,
453 + ..Default::default()
454 + };
455 + let json = serde_json::json!({"checks": {"db": "ok"}});
456 + let failures = validate_expectations(&expect, 200, "", Some(&json));
457 + assert_eq!(failures.len(), 1);
458 + assert!(failures[0].contains("not found"));
459 + }
460 +
461 + #[test]
462 + fn validate_json_fields_on_non_json() {
463 + let mut fields = HashMap::new();
464 + fields.insert("status".to_string(), "ok".to_string());
465 + let expect = HealthExpectation {
466 + json_fields: fields,
467 + ..Default::default()
468 + };
469 + let failures = validate_expectations(&expect, 200, "not json", None);
470 + assert_eq!(failures.len(), 1);
471 + assert!(failures[0].contains("non-JSON"));
472 + }
473 +
474 + #[test]
475 + fn validate_mixed_failures() {
476 + let mut fields = HashMap::new();
477 + fields.insert("status".to_string(), "operational".to_string());
478 + let expect = HealthExpectation {
479 + status_code: Some(200),
480 + body_contains: Some("healthy".to_string()),
481 + json_fields: fields,
482 + };
483 + let json = serde_json::json!({"status": "degraded"});
484 + let failures = validate_expectations(&expect, 503, r#"{"status":"degraded"}"#, Some(&json));
485 + assert_eq!(failures.len(), 3); // status code + body + json field
486 + }
487 +
488 + #[test]
489 + fn validate_empty_expectations_always_pass() {
490 + let expect = HealthExpectation::default();
491 + let failures = validate_expectations(&expect, 500, "garbage", None);
Lines truncated
@@ -1,3 +1,4 @@
1 1 pub mod http;
2 2 pub mod parse;
3 3 pub mod ssh;
4 + pub mod tls;
M src/tools/health.rs +145 -11
@@ -3,7 +3,7 @@
3 3
4 4 use crate::checks::http;
5 5 use crate::db;
6 - use crate::types::TargetInfo;
6 + use crate::types::{LatencyStats, TargetInfo};
7 7
8 8 use super::PomServer;
9 9
@@ -22,9 +22,9 @@
22 22 }
23 23
24 24 impl PomServer {
25 - pub(crate) async fn get_status_impl(
25 + pub async fn get_status_impl(
26 26 &self,
27 - ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
27 + ) -> crate::error::Result<String> {
28 28 let mut status_parts = Vec::new();
29 29
30 30 for name in self.config.target_names() {
@@ -52,8 +52,47 @@
52 52 target_status.push_str("Health: no data\n");
53 53 }
54 54
55 + // 24h latency stats
56 + let latency_cutoff = (chrono::Utc::now() - chrono::Duration::hours(24)).to_rfc3339();
57 + if let Ok(times) = db::get_response_times(&self.pool, &name, &latency_cutoff).await {
58 + let operational_times: Vec<i64> = times.iter()
59 + .filter(|(_, ms)| *ms > 0)
60 + .map(|(_, ms)| *ms)
61 + .collect();
62 + if let Some(l) = LatencyStats::from_times(&operational_times) {
63 + target_status.push_str(&format!(
64 + "Latency (24h): avg {:.0}ms, p95 {}ms, range {}-{}ms ({} samples)\n",
65 + l.avg_ms, l.p95_ms, l.min_ms, l.max_ms, l.sample_count
66 + ));
67 + }
68 + }
69 +
70 + // Active incident
71 + if let Ok(Some(incident)) = db::get_open_incident(&self.pool, &name).await {
72 + target_status.push_str(&format!(
73 + "Incident: [ACTIVE] {} since {}\n",
74 + incident.to_status, incident.started_at
75 + ));
76 + }
77 +
78 + // Recent incidents
79 + if let Ok(incidents) = db::get_recent_incidents(&self.pool, &name, 5).await {
80 + let closed: Vec<_> = incidents.iter().filter(|i| i.ended_at.is_some()).collect();
81 + if !closed.is_empty() {
82 + target_status.push_str("Recent incidents:\n");
83 + for inc in closed {
84 + let duration = inc.duration_secs.map(|d| format!(" ({d}s)")).unwrap_or_default();
85 + target_status.push_str(&format!(
86 + " {} -> {} at {}{}\n",
87 + inc.from_status, inc.to_status, inc.started_at, duration
88 + ));
89 + }
90 + }
91 + }
92 +
55 93 // Latest test run
56 - if let Ok(Some(test)) = db::get_latest_test_run(&self.pool, &name).await {
94 + let latest_test = db::get_latest_test_run(&self.pool, &name).await.ok().flatten();
95 + if let Some(ref test) = latest_test {
57 96 let result = if test.passed { "PASSED" } else { "FAILED" };
58 97 target_status.push_str(&format!("Tests: {result}"));
59 98 if let Some(d) = test.duration_secs {
@@ -71,6 +110,37 @@
71 110 target_status.push_str("Tests: no data\n");
72 111 }
73 112
113 + // Test staleness
114 + if let Some(tests_config) = &target.tests {
115 + let current_version = db::get_latest_health(&self.pool, &name)
116 + .await
117 + .ok()
118 + .flatten()
119 + .and_then(|h| h.details)
120 + .and_then(|d| d.version);
121 +
122 + let tested_version = if let Some(ref test) = latest_test {
123 + db::get_version_at_time(&self.pool, &name, &test.started_at)
124 + .await
125 + .unwrap_or(None)
126 + } else {
127 + None
128 + };
129 +
130 + let staleness = http::compute_test_staleness(
131 + current_version.as_deref(),
132 + tested_version.as_deref(),
133 + latest_test.as_ref().map(|t| t.started_at.as_str()),
134 + tests_config.staleness_days,
135 + );
136 +
137 + if staleness.stale
138 + && let Some(reason) = &staleness.reason
139 + {
140 + target_status.push_str(&format!("Tests: STALE \u{2014} {reason}\n"));
141 + }
142 + }
143 +
74 144 status_parts.push(target_status);
75 145 }
76 146
@@ -81,10 +151,10 @@
81 151 Ok(status_parts.join("\n"))
82 152 }
83 153
84 - pub(crate) async fn check_health_impl(
154 + pub async fn check_health_impl(
85 155 &self,
86 156 params: CheckHealthParams,
87 - ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
157 + ) -> crate::error::Result<String> {
88 158 let targets: Vec<String> = match &params.target {
89 159 Some(t) => {
90 160 if self.config.get_target(t).is_none() {
@@ -100,7 +170,7 @@
100 170 for name in &targets {
101 171 let target = self.config.get_target(name).unwrap();
102 172 if let Some(health_config) = &target.health {
103 - let snapshot = http::check_health(name, health_config).await;
173 + let snapshot = http::check_health(name, health_config, health_config.expect.as_ref()).await;
104 174 db::insert_health_check(&self.pool, &snapshot).await?;
105 175 results.push(serde_json::to_string_pretty(&snapshot)?);
106 176 } else {
@@ -111,10 +181,10 @@
111 181 Ok(results.join("\n\n"))
112 182 }
113 183
114 - pub(crate) async fn health_history_impl(
184 + pub async fn health_history_impl(
115 185 &self,
116 186 params: HealthHistoryParams,
117 - ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
187 + ) -> crate::error::Result<String> {
118 188 let limit = params.limit.unwrap_or(10);
119 189 let history = db::get_health_history(&self.pool, params.target.as_deref(), limit).await?;
120 190
@@ -125,9 +195,73 @@
125 195 Ok(serde_json::to_string_pretty(&history)?)
126 196 }
127 197
128 - pub(crate) async fn list_targets_impl(
198 + pub async fn get_mesh_status_impl(
129 199 &self,
130 - ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
200 + ) -> crate::error::Result<String> {
201 + let listen = &self.config.serve.listen;
202 + let url = format!("http://{listen}/api/mesh");
203 +
204 + let client = reqwest::Client::builder()
205 + .timeout(std::time::Duration::from_secs(5))
206 + .build()?;
207 +
208 + let response = client.get(&url).send().await.map_err(|e| {
209 + crate::error::PomError::Config(format!(
210 + "Could not reach local PoM instance at {listen}: {e}"
211 + ))
212 + })?;
213 +
214 + let data: serde_json::Value = response.json().await?;
215 +
216 + let Some(instances) = data.get("instances").and_then(|v| v.as_object()) else {
217 + return Ok("No mesh data available. Is serve mode running?".to_string());
218 + };
219 +
220 + let mut output = String::from("# Peer Mesh Status\n\n");
221 +
222 + for (name, instance_data) in instances {
223 + let instance = instance_data.get("instance");
224 + let version = instance
225 + .and_then(|i| i.get("version"))
226 + .and_then(|v| v.as_str())
227 + .unwrap_or("?");
228 +
229 + output.push_str(&format!("## {name} (v{version})\n"));
230 +
231 + if let Some(targets) = instance_data.get("targets").and_then(|v| v.as_object()) {
232 + for (target_name, target_data) in targets {
233 + let status = target_data.get("status").and_then(|v| v.as_str()).unwrap_or("?");
234 + let ms = target_data.get("response_time_ms").and_then(|v| v.as_i64());
235 + let ms_str = ms.map(|m| format!(" ({m}ms)")).unwrap_or_default();
236 + output.push_str(&format!("- Target {target_name}: {status}{ms_str}\n"));
237 + }
238 + }
239 +
240 + if let Some(peers) = instance_data.get("peers").and_then(|v| v.as_object()) {
241 + for (peer_name, peer_data) in peers {
242 + let status = peer_data.get("status").and_then(|v| v.as_str()).unwrap_or("?");
243 + let latency = peer_data
244 + .get("latency_ms")
245 + .and_then(|v| v.as_u64())
246 + .map(|ms| format!(" ({ms}ms)"))
247 + .unwrap_or_default();
248 + output.push_str(&format!("- Peer {peer_name}: {status}{latency}\n"));
249 + }
250 + }
251 +
252 + if let Some(err) = instance_data.get("error").and_then(|v| v.as_str()) {
253 + output.push_str(&format!("- ({err})\n"));
254 + }
255 +
256 + output.push('\n');
257 + }
258 +
259 + Ok(output)
260 + }
261 +
262 + pub async fn list_targets_impl(
263 + &self,
264 + ) -> crate::error::Result<String> {
131 265 let targets: Vec<TargetInfo> = self
132 266 .config
133 267 .target_names()
M src/tools/mod.rs +15 -5
@@ -1,5 +1,5 @@
1 - mod health;
2 - mod tests;
1 + pub mod health;
2 + pub mod tests;
3 3
4 4 use rmcp::tool;
5 5 use rmcp::model::{ServerCapabilities, ServerInfo};
@@ -10,8 +10,8 @@
10 10
11 11 #[derive(Clone)]
12 12 pub struct PomServer {
13 - pub(crate) pool: SqlitePool,
14 - pub(crate) config: Config,
13 + pub pool: SqlitePool,
14 + pub config: Config,
15 15 }
16 16
17 17 impl PomServer {
@@ -99,6 +99,15 @@
99 99 Err(e) => format!("Error getting test output: {e}"),
100 100 }
101 101 }
102 +
103 + /// Get peer mesh status.
104 + #[tool(description = "Get the peer mesh status showing all PoM instances, their connectivity, versions, and target health. Requires serve mode to be running.")]
105 + pub async fn get_mesh_status(&self) -> String {
106 + match self.get_mesh_status_impl().await {
107 + Ok(result) => result,
108 + Err(e) => format!("Error getting mesh status: {e}"),
109 + }
110 + }
102 111 }
103 112
104 113 #[tool(tool_box)]
@@ -108,7 +117,8 @@
108 117 instructions: Some(
109 118 "Peace of Mind (PoM) server for monitoring production health and running tests. \
110 119 Tools: get_status (dashboard), check_health (live health check), health_history, \
111 - list_targets, run_tests (SSH test execution), test_history, last_test_output."
120 + list_targets, run_tests (SSH test execution), test_history, last_test_output, \
121 + get_mesh_status (peer mesh overview)."
112 122 .into(),
113 123 ),
114 124 capabilities: ServerCapabilities::builder().enable_tools().build(),
A src/alerts.rs +389
A src/api.rs +411
A src/cli.rs +564
A src/error.rs +32
A src/peer.rs +536