max / pom
- Co-Authored-By
- Claude Opus 4.6 <noreply@anthropic.com>
25 files changed,
+5679 insertions,
-474 deletions
| @@ -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/ |
| @@ -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
| @@ -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" |
| @@ -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" |
| @@ -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 |
| @@ -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
| @@ -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
| @@ -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; |
| @@ -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 | - | } |
| @@ -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(×)?; | |
| 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(×).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(×).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
| @@ -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; |
| @@ -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 ¶ms.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() |
| @@ -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(), |