max / pom
20 files changed,
+2515 insertions,
-0 deletions
| @@ -0,0 +1,4 @@ | |||
| 1 | + | /target/ | |
| 2 | + | *.db | |
| 3 | + | *.db-wal | |
| 4 | + | *.db-shm |
| @@ -0,0 +1,8 @@ | |||
| 1 | + | { | |
| 2 | + | "mcpServers": { | |
| 3 | + | "pom": { | |
| 4 | + | "command": "/Users/max/Git/active/pom/target/release/pom", | |
| 5 | + | "args": [] | |
| 6 | + | } | |
| 7 | + | } | |
| 8 | + | } |
| @@ -0,0 +1,3156 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "aho-corasick" | |
| 7 | + | version = "1.1.4" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | |
| 10 | + | dependencies = [ | |
| 11 | + | "memchr", | |
| 12 | + | ] | |
| 13 | + | ||
| 14 | + | [[package]] | |
| 15 | + | name = "allocator-api2" | |
| 16 | + | version = "0.2.21" | |
| 17 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 18 | + | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 19 | + | ||
| 20 | + | [[package]] | |
| 21 | + | name = "android_system_properties" | |
| 22 | + | version = "0.1.5" | |
| 23 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 24 | + | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 25 | + | dependencies = [ | |
| 26 | + | "libc", | |
| 27 | + | ] | |
| 28 | + | ||
| 29 | + | [[package]] | |
| 30 | + | name = "anstream" | |
| 31 | + | version = "0.6.21" | |
| 32 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 33 | + | checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" | |
| 34 | + | dependencies = [ | |
| 35 | + | "anstyle", | |
| 36 | + | "anstyle-parse", | |
| 37 | + | "anstyle-query", | |
| 38 | + | "anstyle-wincon", | |
| 39 | + | "colorchoice", | |
| 40 | + | "is_terminal_polyfill", | |
| 41 | + | "utf8parse", | |
| 42 | + | ] | |
| 43 | + | ||
| 44 | + | [[package]] | |
| 45 | + | name = "anstyle" | |
| 46 | + | version = "1.0.13" | |
| 47 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 48 | + | checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" | |
| 49 | + | ||
| 50 | + | [[package]] | |
| 51 | + | name = "anstyle-parse" | |
| 52 | + | version = "0.2.7" | |
| 53 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 54 | + | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" | |
| 55 | + | dependencies = [ | |
| 56 | + | "utf8parse", | |
| 57 | + | ] | |
| 58 | + | ||
| 59 | + | [[package]] | |
| 60 | + | name = "anstyle-query" | |
| 61 | + | version = "1.1.5" | |
| 62 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 63 | + | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | |
| 64 | + | dependencies = [ | |
| 65 | + | "windows-sys 0.61.2", | |
| 66 | + | ] | |
| 67 | + | ||
| 68 | + | [[package]] | |
| 69 | + | name = "anstyle-wincon" | |
| 70 | + | version = "3.0.11" | |
| 71 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 72 | + | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | |
| 73 | + | dependencies = [ | |
| 74 | + | "anstyle", | |
| 75 | + | "once_cell_polyfill", | |
| 76 | + | "windows-sys 0.61.2", | |
| 77 | + | ] | |
| 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 = "atoi" | |
| 87 | + | version = "2.0.0" | |
| 88 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 89 | + | checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" | |
| 90 | + | dependencies = [ | |
| 91 | + | "num-traits", | |
| 92 | + | ] | |
| 93 | + | ||
| 94 | + | [[package]] | |
| 95 | + | name = "atomic-waker" | |
| 96 | + | version = "1.1.2" | |
| 97 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 98 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 99 | + | ||
| 100 | + | [[package]] | |
| 101 | + | name = "autocfg" | |
| 102 | + | version = "1.5.0" | |
| 103 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 104 | + | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" | |
| 105 | + | ||
| 106 | + | [[package]] | |
| 107 | + | name = "base64" | |
| 108 | + | version = "0.21.7" | |
| 109 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 110 | + | checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" | |
| 111 | + | ||
| 112 | + | [[package]] | |
| 113 | + | name = "base64" | |
| 114 | + | version = "0.22.1" | |
| 115 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 116 | + | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 117 | + | ||
| 118 | + | [[package]] | |
| 119 | + | name = "base64ct" | |
| 120 | + | version = "1.8.3" | |
| 121 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 122 | + | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | |
| 123 | + | ||
| 124 | + | [[package]] | |
| 125 | + | name = "bitflags" | |
| 126 | + | version = "2.11.0" | |
| 127 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 128 | + | checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" | |
| 129 | + | dependencies = [ | |
| 130 | + | "serde_core", | |
| 131 | + | ] | |
| 132 | + | ||
| 133 | + | [[package]] | |
| 134 | + | name = "block-buffer" | |
| 135 | + | version = "0.10.4" | |
| 136 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 137 | + | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 138 | + | dependencies = [ | |
| 139 | + | "generic-array", | |
| 140 | + | ] | |
| 141 | + | ||
| 142 | + | [[package]] | |
| 143 | + | name = "bumpalo" | |
| 144 | + | version = "3.20.2" | |
| 145 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 146 | + | checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" | |
| 147 | + | ||
| 148 | + | [[package]] | |
| 149 | + | name = "byteorder" | |
| 150 | + | version = "1.5.0" | |
| 151 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 152 | + | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 153 | + | ||
| 154 | + | [[package]] | |
| 155 | + | name = "bytes" | |
| 156 | + | version = "1.11.1" | |
| 157 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 158 | + | checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" | |
| 159 | + | ||
| 160 | + | [[package]] | |
| 161 | + | name = "cc" | |
| 162 | + | version = "1.2.56" | |
| 163 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 164 | + | checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" | |
| 165 | + | dependencies = [ | |
| 166 | + | "find-msvc-tools", | |
| 167 | + | "shlex", | |
| 168 | + | ] | |
| 169 | + | ||
| 170 | + | [[package]] | |
| 171 | + | name = "cfg-if" | |
| 172 | + | version = "1.0.4" | |
| 173 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 174 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 175 | + | ||
| 176 | + | [[package]] | |
| 177 | + | name = "chrono" | |
| 178 | + | version = "0.4.44" | |
| 179 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 180 | + | checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" | |
| 181 | + | dependencies = [ | |
| 182 | + | "iana-time-zone", | |
| 183 | + | "js-sys", | |
| 184 | + | "num-traits", | |
| 185 | + | "serde", | |
| 186 | + | "wasm-bindgen", | |
| 187 | + | "windows-link", | |
| 188 | + | ] | |
| 189 | + | ||
| 190 | + | [[package]] | |
| 191 | + | name = "clap" | |
| 192 | + | version = "4.5.60" | |
| 193 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 194 | + | checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" | |
| 195 | + | dependencies = [ | |
| 196 | + | "clap_builder", | |
| 197 | + | "clap_derive", | |
| 198 | + | ] | |
| 199 | + | ||
| 200 | + | [[package]] | |
| 201 | + | name = "clap_builder" | |
| 202 | + | version = "4.5.60" | |
| 203 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 204 | + | checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" | |
| 205 | + | dependencies = [ | |
| 206 | + | "anstream", | |
| 207 | + | "anstyle", | |
| 208 | + | "clap_lex", | |
| 209 | + | "strsim", | |
| 210 | + | ] | |
| 211 | + | ||
| 212 | + | [[package]] | |
| 213 | + | name = "clap_derive" | |
| 214 | + | version = "4.5.55" | |
| 215 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 216 | + | checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" | |
| 217 | + | dependencies = [ | |
| 218 | + | "heck", | |
| 219 | + | "proc-macro2", | |
| 220 | + | "quote", | |
| 221 | + | "syn", | |
| 222 | + | ] | |
| 223 | + | ||
| 224 | + | [[package]] | |
| 225 | + | name = "clap_lex" | |
| 226 | + | version = "1.0.0" | |
| 227 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 228 | + | checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" | |
| 229 | + | ||
| 230 | + | [[package]] | |
| 231 | + | name = "colorchoice" | |
| 232 | + | version = "1.0.4" | |
| 233 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 234 | + | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" | |
| 235 | + | ||
| 236 | + | [[package]] | |
| 237 | + | name = "concurrent-queue" | |
| 238 | + | version = "2.5.0" | |
| 239 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 240 | + | checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | |
| 241 | + | dependencies = [ | |
| 242 | + | "crossbeam-utils", | |
| 243 | + | ] | |
| 244 | + | ||
| 245 | + | [[package]] | |
| 246 | + | name = "const-oid" | |
| 247 | + | version = "0.9.6" | |
| 248 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 249 | + | checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | |
| 250 | + | ||
| 251 | + | [[package]] | |
| 252 | + | name = "core-foundation" | |
| 253 | + | version = "0.9.4" | |
| 254 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 255 | + | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" | |
| 256 | + | dependencies = [ | |
| 257 | + | "core-foundation-sys", | |
| 258 | + | "libc", | |
| 259 | + | ] | |
| 260 | + | ||
| 261 | + | [[package]] | |
| 262 | + | name = "core-foundation" | |
| 263 | + | version = "0.10.1" | |
| 264 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 265 | + | checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" | |
| 266 | + | dependencies = [ | |
| 267 | + | "core-foundation-sys", | |
| 268 | + | "libc", | |
| 269 | + | ] | |
| 270 | + | ||
| 271 | + | [[package]] | |
| 272 | + | name = "core-foundation-sys" | |
| 273 | + | version = "0.8.7" | |
| 274 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 275 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 276 | + | ||
| 277 | + | [[package]] | |
| 278 | + | name = "cpufeatures" | |
| 279 | + | version = "0.2.17" | |
| 280 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 281 | + | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | |
| 282 | + | dependencies = [ | |
| 283 | + | "libc", | |
| 284 | + | ] | |
| 285 | + | ||
| 286 | + | [[package]] | |
| 287 | + | name = "crc" | |
| 288 | + | version = "3.4.0" | |
| 289 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 290 | + | checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" | |
| 291 | + | dependencies = [ | |
| 292 | + | "crc-catalog", | |
| 293 | + | ] | |
| 294 | + | ||
| 295 | + | [[package]] | |
| 296 | + | name = "crc-catalog" | |
| 297 | + | version = "2.4.0" | |
| 298 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 299 | + | checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" | |
| 300 | + | ||
| 301 | + | [[package]] | |
| 302 | + | name = "crossbeam-queue" | |
| 303 | + | version = "0.3.12" | |
| 304 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 305 | + | checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" | |
| 306 | + | dependencies = [ | |
| 307 | + | "crossbeam-utils", | |
| 308 | + | ] | |
| 309 | + | ||
| 310 | + | [[package]] | |
| 311 | + | name = "crossbeam-utils" | |
| 312 | + | version = "0.8.21" | |
| 313 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 314 | + | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" | |
| 315 | + | ||
| 316 | + | [[package]] | |
| 317 | + | name = "crypto-common" | |
| 318 | + | version = "0.1.7" | |
| 319 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 320 | + | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | |
| 321 | + | dependencies = [ | |
| 322 | + | "generic-array", | |
| 323 | + | "typenum", | |
| 324 | + | ] | |
| 325 | + | ||
| 326 | + | [[package]] | |
| 327 | + | name = "der" | |
| 328 | + | version = "0.7.10" | |
| 329 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 330 | + | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" | |
| 331 | + | dependencies = [ | |
| 332 | + | "const-oid", | |
| 333 | + | "pem-rfc7468", | |
| 334 | + | "zeroize", | |
| 335 | + | ] | |
| 336 | + | ||
| 337 | + | [[package]] | |
| 338 | + | name = "digest" | |
| 339 | + | version = "0.10.7" | |
| 340 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 341 | + | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 342 | + | dependencies = [ | |
| 343 | + | "block-buffer", | |
| 344 | + | "const-oid", | |
| 345 | + | "crypto-common", | |
| 346 | + | "subtle", | |
| 347 | + | ] | |
| 348 | + | ||
| 349 | + | [[package]] | |
| 350 | + | name = "dirs" | |
| 351 | + | version = "6.0.0" | |
| 352 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 353 | + | checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" | |
| 354 | + | dependencies = [ | |
| 355 | + | "dirs-sys", | |
| 356 | + | ] | |
| 357 | + | ||
| 358 | + | [[package]] | |
| 359 | + | name = "dirs-sys" | |
| 360 | + | version = "0.5.0" | |
| 361 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 362 | + | checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" | |
| 363 | + | dependencies = [ | |
| 364 | + | "libc", | |
| 365 | + | "option-ext", | |
| 366 | + | "redox_users", | |
| 367 | + | "windows-sys 0.61.2", | |
| 368 | + | ] | |
| 369 | + | ||
| 370 | + | [[package]] | |
| 371 | + | name = "displaydoc" | |
| 372 | + | version = "0.2.5" | |
| 373 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 374 | + | checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" | |
| 375 | + | dependencies = [ | |
| 376 | + | "proc-macro2", | |
| 377 | + | "quote", | |
| 378 | + | "syn", | |
| 379 | + | ] | |
| 380 | + | ||
| 381 | + | [[package]] | |
| 382 | + | name = "dotenvy" | |
| 383 | + | version = "0.15.7" | |
| 384 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 385 | + | checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" | |
| 386 | + | ||
| 387 | + | [[package]] | |
| 388 | + | name = "dyn-clone" | |
| 389 | + | version = "1.0.20" | |
| 390 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 391 | + | checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" | |
| 392 | + | ||
| 393 | + | [[package]] | |
| 394 | + | name = "either" | |
| 395 | + | version = "1.15.0" | |
| 396 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 397 | + | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" | |
| 398 | + | dependencies = [ | |
| 399 | + | "serde", | |
| 400 | + | ] | |
| 401 | + | ||
| 402 | + | [[package]] | |
| 403 | + | name = "encoding_rs" | |
| 404 | + | version = "0.8.35" | |
| 405 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 406 | + | checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" | |
| 407 | + | dependencies = [ | |
| 408 | + | "cfg-if", | |
| 409 | + | ] | |
| 410 | + | ||
| 411 | + | [[package]] | |
| 412 | + | name = "equivalent" | |
| 413 | + | version = "1.0.2" | |
| 414 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 415 | + | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | |
| 416 | + | ||
| 417 | + | [[package]] | |
| 418 | + | name = "errno" | |
| 419 | + | version = "0.3.14" | |
| 420 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 421 | + | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 422 | + | dependencies = [ | |
| 423 | + | "libc", | |
| 424 | + | "windows-sys 0.61.2", | |
| 425 | + | ] | |
| 426 | + | ||
| 427 | + | [[package]] | |
| 428 | + | name = "etcetera" | |
| 429 | + | version = "0.8.0" | |
| 430 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 431 | + | checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" | |
| 432 | + | dependencies = [ | |
| 433 | + | "cfg-if", | |
| 434 | + | "home", | |
| 435 | + | "windows-sys 0.48.0", | |
| 436 | + | ] | |
| 437 | + | ||
| 438 | + | [[package]] | |
| 439 | + | name = "event-listener" | |
| 440 | + | version = "5.4.1" | |
| 441 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 442 | + | checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" | |
| 443 | + | dependencies = [ | |
| 444 | + | "concurrent-queue", | |
| 445 | + | "parking", | |
| 446 | + | "pin-project-lite", | |
| 447 | + | ] | |
| 448 | + | ||
| 449 | + | [[package]] | |
| 450 | + | name = "fastrand" | |
| 451 | + | version = "2.3.0" | |
| 452 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 453 | + | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" | |
| 454 | + | ||
| 455 | + | [[package]] | |
| 456 | + | name = "find-msvc-tools" | |
| 457 | + | version = "0.1.9" | |
| 458 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 459 | + | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 460 | + | ||
| 461 | + | [[package]] | |
| 462 | + | name = "flume" | |
| 463 | + | version = "0.11.1" | |
| 464 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 465 | + | checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" | |
| 466 | + | dependencies = [ | |
| 467 | + | "futures-core", | |
| 468 | + | "futures-sink", | |
| 469 | + | "spin", | |
| 470 | + | ] | |
| 471 | + | ||
| 472 | + | [[package]] | |
| 473 | + | name = "fnv" | |
| 474 | + | version = "1.0.7" | |
| 475 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 476 | + | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | |
| 477 | + | ||
| 478 | + | [[package]] | |
| 479 | + | name = "foldhash" | |
| 480 | + | version = "0.1.5" | |
| 481 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 482 | + | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" | |
| 483 | + | ||
| 484 | + | [[package]] | |
| 485 | + | name = "foreign-types" | |
| 486 | + | version = "0.3.2" | |
| 487 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 488 | + | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" | |
| 489 | + | dependencies = [ | |
| 490 | + | "foreign-types-shared", | |
| 491 | + | ] | |
| 492 | + | ||
| 493 | + | [[package]] | |
| 494 | + | name = "foreign-types-shared" | |
| 495 | + | version = "0.1.1" | |
| 496 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 497 | + | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" | |
| 498 | + | ||
| 499 | + | [[package]] | |
| 500 | + | name = "form_urlencoded" |
Lines truncated
| @@ -0,0 +1,46 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "pom" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | ||
| 6 | + | [lib] | |
| 7 | + | name = "pom" | |
| 8 | + | path = "src/lib.rs" | |
| 9 | + | ||
| 10 | + | [[bin]] | |
| 11 | + | name = "pom" | |
| 12 | + | path = "src/main.rs" | |
| 13 | + | ||
| 14 | + | [dependencies] | |
| 15 | + | # MCP protocol | |
| 16 | + | rmcp = { version = "0.1", features = ["server", "transport-io"] } | |
| 17 | + | ||
| 18 | + | # CLI | |
| 19 | + | clap = { version = "4", features = ["derive"] } | |
| 20 | + | ||
| 21 | + | # Async runtime | |
| 22 | + | tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util", "process", "signal"] } | |
| 23 | + | ||
| 24 | + | # HTTP client | |
| 25 | + | reqwest = { version = "0.12", features = ["json"] } | |
| 26 | + | ||
| 27 | + | # Database | |
| 28 | + | sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } | |
| 29 | + | ||
| 30 | + | # Serialization | |
| 31 | + | serde = { version = "1", features = ["derive"] } | |
| 32 | + | serde_json = "1" | |
| 33 | + | schemars = "0.8" | |
| 34 | + | ||
| 35 | + | # Config | |
| 36 | + | toml = "0.8" | |
| 37 | + | ||
| 38 | + | # Time | |
| 39 | + | chrono = { version = "0.4", features = ["serde"] } | |
| 40 | + | ||
| 41 | + | # Paths | |
| 42 | + | dirs = "6" | |
| 43 | + | ||
| 44 | + | # Logging | |
| 45 | + | tracing = "0.1" | |
| 46 | + | tracing-subscriber = { version = "0.3", features = ["env-filter"] } |
| @@ -0,0 +1,58 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | set -euo pipefail | |
| 3 | + | ||
| 4 | + | ASTRA_HOST="max@100.106.221.39" | |
| 5 | + | HETZNER_HOST="root@5.78.144.244" | |
| 6 | + | ||
| 7 | + | SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | |
| 8 | + | PROJECT_DIR="$(dirname "$SCRIPT_DIR")" | |
| 9 | + | ||
| 10 | + | deploy_target() { | |
| 11 | + | local name="$1" | |
| 12 | + | local host target | |
| 13 | + | ||
| 14 | + | case "$name" in | |
| 15 | + | astra) | |
| 16 | + | host="$ASTRA_HOST" | |
| 17 | + | target="aarch64-unknown-linux-gnu" | |
| 18 | + | ;; | |
| 19 | + | hetzner) | |
| 20 | + | host="$HETZNER_HOST" | |
| 21 | + | target="x86_64-unknown-linux-gnu" | |
| 22 | + | ;; | |
| 23 | + | *) | |
| 24 | + | echo "Unknown target: $name (use astra, hetzner, or all)" | |
| 25 | + | exit 1 | |
| 26 | + | ;; | |
| 27 | + | esac | |
| 28 | + | ||
| 29 | + | echo "=== Building pom for $name ($target) ===" | |
| 30 | + | cargo zigbuild --release --target "$target" --manifest-path "$PROJECT_DIR/Cargo.toml" | |
| 31 | + | ||
| 32 | + | local binary="$PROJECT_DIR/target/$target/release/pom" | |
| 33 | + | ||
| 34 | + | echo "=== Deploying to $name ($host) ===" | |
| 35 | + | scp "$binary" "$host:/usr/local/bin/pom" | |
| 36 | + | scp "$PROJECT_DIR/pom.toml" "$host:/etc/pom/pom.toml" | |
| 37 | + | scp "$SCRIPT_DIR/pom.service" "$host:/etc/systemd/system/pom.service" | |
| 38 | + | ||
| 39 | + | ssh "$host" "mkdir -p /etc/pom && systemctl daemon-reload && systemctl enable pom && systemctl restart pom" | |
| 40 | + | ||
| 41 | + | echo "=== $name: deployed ===" | |
| 42 | + | ssh "$host" "systemctl status pom --no-pager" | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | if [ $# -eq 0 ]; then | |
| 46 | + | echo "Usage: $0 <astra|hetzner|all>" | |
| 47 | + | exit 1 | |
| 48 | + | fi | |
| 49 | + | ||
| 50 | + | case "$1" in | |
| 51 | + | all) | |
| 52 | + | deploy_target astra | |
| 53 | + | deploy_target hetzner | |
| 54 | + | ;; | |
| 55 | + | *) | |
| 56 | + | deploy_target "$1" | |
| 57 | + | ;; | |
| 58 | + | esac |
| @@ -0,0 +1,13 @@ | |||
| 1 | + | [Unit] | |
| 2 | + | Description=PoM Health Monitor | |
| 3 | + | After=network-online.target | |
| 4 | + | Wants=network-online.target | |
| 5 | + | ||
| 6 | + | [Service] | |
| 7 | + | Type=simple | |
| 8 | + | ExecStart=/usr/local/bin/pom serve --config /etc/pom/pom.toml | |
| 9 | + | Restart=on-failure | |
| 10 | + | RestartSec=10 | |
| 11 | + | ||
| 12 | + | [Install] | |
| 13 | + | WantedBy=multi-user.target |
| @@ -0,0 +1,15 @@ | |||
| 1 | + | [serve] | |
| 2 | + | interval_secs = 300 | |
| 3 | + | prune_days = 30 | |
| 4 | + | ||
| 5 | + | [targets.mnw] | |
| 6 | + | label = "Makenotwork Production" | |
| 7 | + | ||
| 8 | + | [targets.mnw.health] | |
| 9 | + | url = "https://makenot.work/api/health" | |
| 10 | + | timeout_secs = 10 | |
| 11 | + | ||
| 12 | + | [targets.mnw.tests] | |
| 13 | + | ssh = "max@100.106.221.39" | |
| 14 | + | command = "/home/max/staging/run-ci.sh" | |
| 15 | + | timeout_secs = 600 |
| @@ -0,0 +1,82 @@ | |||
| 1 | + | use std::time::Instant; | |
| 2 | + | ||
| 3 | + | use crate::config::HealthConfig; | |
| 4 | + | use crate::types::{HealthDetails, HealthSnapshot, HealthStatus}; | |
| 5 | + | ||
| 6 | + | pub async fn check_health( | |
| 7 | + | target_name: &str, | |
| 8 | + | config: &HealthConfig, | |
| 9 | + | ) -> HealthSnapshot { | |
| 10 | + | let client = reqwest::Client::builder() | |
| 11 | + | .timeout(std::time::Duration::from_secs(config.timeout_secs)) | |
| 12 | + | .build() | |
| 13 | + | .unwrap_or_else(|_| reqwest::Client::new()); | |
| 14 | + | ||
| 15 | + | let start = Instant::now(); | |
| 16 | + | let checked_at = chrono::Utc::now().to_rfc3339(); | |
| 17 | + | ||
| 18 | + | match client.get(&config.url).send().await { | |
| 19 | + | Ok(response) => { | |
| 20 | + | let response_time_ms = start.elapsed().as_millis() as i64; | |
| 21 | + | let status_code = response.status(); | |
| 22 | + | ||
| 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"); | |
| 29 | + | ||
| 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, | |
| 35 | + | }; | |
| 36 | + | ||
| 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 | + | }; | |
| 43 | + | ||
| 44 | + | HealthSnapshot { | |
| 45 | + | id: None, | |
| 46 | + | target: target_name.to_string(), | |
| 47 | + | status, | |
| 48 | + | checked_at, | |
| 49 | + | response_time_ms, | |
| 50 | + | details: Some(details), | |
| 51 | + | error: None, | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | Err(e) => HealthSnapshot { | |
| 55 | + | id: None, | |
| 56 | + | target: target_name.to_string(), | |
| 57 | + | status: if status_code.is_success() { | |
| 58 | + | HealthStatus::Degraded | |
| 59 | + | } else { | |
| 60 | + | HealthStatus::Error | |
| 61 | + | }, | |
| 62 | + | checked_at, | |
| 63 | + | response_time_ms, | |
| 64 | + | details: None, | |
| 65 | + | error: Some(format!("Failed to parse response: {e}")), | |
| 66 | + | }, | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | Err(e) => { | |
| 70 | + | let response_time_ms = start.elapsed().as_millis() as i64; | |
| 71 | + | HealthSnapshot { | |
| 72 | + | id: None, | |
| 73 | + | target: target_name.to_string(), | |
| 74 | + | status: HealthStatus::Unreachable, | |
| 75 | + | checked_at, | |
| 76 | + | response_time_ms, | |
| 77 | + | details: None, | |
| 78 | + | error: Some(format!("{e}")), | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } |
| @@ -0,0 +1,3 @@ | |||
| 1 | + | pub mod http; | |
| 2 | + | pub mod parse; | |
| 3 | + | pub mod ssh; |
| @@ -0,0 +1,154 @@ | |||
| 1 | + | use crate::types::{StepResult, TestSummary}; | |
| 2 | + | ||
| 3 | + | /// Parse run-ci.sh output into a structured TestSummary. | |
| 4 | + | /// | |
| 5 | + | /// Looks for: | |
| 6 | + | /// - `PASS <step name>` / `FAIL <step name>` lines from the CI summary | |
| 7 | + | /// - `test result: ok. N passed; M failed` lines from cargo test | |
| 8 | + | pub fn parse_ci_output(output: &str) -> TestSummary { | |
| 9 | + | let mut steps = Vec::new(); | |
| 10 | + | let mut total_passed: i64 = 0; | |
| 11 | + | let mut total_failed: i64 = 0; | |
| 12 | + | let mut found_test_results = false; | |
| 13 | + | ||
| 14 | + | for line in output.lines() { | |
| 15 | + | let trimmed = line.trim(); | |
| 16 | + | ||
| 17 | + | // Parse PASS/FAIL lines from run-ci.sh summary | |
| 18 | + | if let Some(name) = trimmed.strip_prefix("PASS ") { | |
| 19 | + | steps.push(StepResult { | |
| 20 | + | name: name.trim().to_string(), | |
| 21 | + | passed: true, | |
| 22 | + | }); | |
| 23 | + | } else if let Some(name) = trimmed.strip_prefix("FAIL ") { | |
| 24 | + | steps.push(StepResult { | |
| 25 | + | name: name.trim().to_string(), | |
| 26 | + | passed: false, | |
| 27 | + | }); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Parse cargo test result lines | |
| 31 | + | if trimmed.starts_with("test result:") { | |
| 32 | + | found_test_results = true; | |
| 33 | + | // "test result: ok. 42 passed; 0 failed; 0 ignored; ..." | |
| 34 | + | if let Some(counts) = parse_test_result_line(trimmed) { | |
| 35 | + | total_passed += counts.0; | |
| 36 | + | total_failed += counts.1; | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | TestSummary { | |
| 42 | + | steps, | |
| 43 | + | total_passed: if found_test_results { Some(total_passed) } else { None }, | |
| 44 | + | total_failed: if found_test_results { Some(total_failed) } else { None }, | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | fn parse_test_result_line(line: &str) -> Option<(i64, i64)> { | |
| 49 | + | // "test result: ok. 42 passed; 3 failed; 0 ignored; 0 measured; 0 filtered out" | |
| 50 | + | let mut passed = 0i64; | |
| 51 | + | let mut failed = 0i64; | |
| 52 | + | ||
| 53 | + | for part in line.split(';') { | |
| 54 | + | let part = part.trim(); | |
| 55 | + | if part.ends_with("passed") { | |
| 56 | + | // "42 passed" or "ok. 42 passed" | |
| 57 | + | let num_str = part | |
| 58 | + | .rsplit_once(". ") | |
| 59 | + | .map(|(_, r)| r) | |
| 60 | + | .unwrap_or(part) | |
| 61 | + | .trim() | |
| 62 | + | .strip_suffix(" passed")? | |
| 63 | + | .trim(); | |
| 64 | + | passed = num_str.parse().ok()?; | |
| 65 | + | } else if part.ends_with("failed") { | |
| 66 | + | let num_str = part.trim().strip_suffix(" failed")?.trim(); | |
| 67 | + | failed = num_str.parse().ok()?; | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | Some((passed, failed)) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | #[cfg(test)] | |
| 75 | + | mod tests { | |
| 76 | + | use super::*; | |
| 77 | + | ||
| 78 | + | #[test] | |
| 79 | + | fn parse_full_ci_output() { | |
| 80 | + | let output = r#" | |
| 81 | + | ======================================== | |
| 82 | + | cargo check | |
| 83 | + | ======================================== | |
| 84 | + | ||
| 85 | + | Finished `dev` profile | |
| 86 | + | ||
| 87 | + | ======================================== | |
| 88 | + | cargo test --lib | |
| 89 | + | ======================================== | |
| 90 | + | ||
| 91 | + | running 45 tests | |
| 92 | + | test result: ok. 45 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.3s | |
| 93 | + | ||
| 94 | + | ======================================== | |
| 95 | + | cargo test --test integration | |
| 96 | + | ======================================== | |
| 97 | + | ||
| 98 | + | running 714 tests | |
| 99 | + | test result: ok. 714 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 89.2s | |
| 100 | + | ||
| 101 | + | ======================================== | |
| 102 | + | CI Summary | |
| 103 | + | ======================================== | |
| 104 | + | ||
| 105 | + | PASS cargo check | |
| 106 | + | PASS cargo test --lib | |
| 107 | + | PASS cargo test --test integration | |
| 108 | + | PASS cargo clippy | |
| 109 | + | PASS cargo audit | |
| 110 | + | ||
| 111 | + | All steps passed. | |
| 112 | + | "#; | |
| 113 | + | let summary = parse_ci_output(output); | |
| 114 | + | assert_eq!(summary.steps.len(), 5); | |
| 115 | + | assert!(summary.steps.iter().all(|s| s.passed)); | |
| 116 | + | assert_eq!(summary.total_passed, Some(759)); | |
| 117 | + | assert_eq!(summary.total_failed, Some(0)); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | #[test] | |
| 121 | + | fn parse_failed_ci_output() { | |
| 122 | + | let output = r#" | |
| 123 | + | ======================================== | |
| 124 | + | CI Summary | |
| 125 | + | ======================================== | |
| 126 | + | ||
| 127 | + | PASS cargo check | |
| 128 | + | FAIL cargo test --lib | |
| 129 | + | PASS cargo clippy | |
| 130 | + | ||
| 131 | + | 1 step(s) failed. | |
| 132 | + | "#; | |
| 133 | + | let summary = parse_ci_output(output); | |
| 134 | + | assert_eq!(summary.steps.len(), 3); | |
| 135 | + | assert!(!summary.steps[1].passed); | |
| 136 | + | assert_eq!(summary.steps[1].name, "cargo test --lib"); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn parse_test_result_line_ok() { | |
| 141 | + | let line = "test result: ok. 42 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out"; | |
| 142 | + | let (p, f) = parse_test_result_line(line).unwrap(); | |
| 143 | + | assert_eq!(p, 42); | |
| 144 | + | assert_eq!(f, 0); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | #[test] | |
| 148 | + | fn parse_test_result_line_failed() { | |
| 149 | + | let line = "test result: FAILED. 40 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out"; | |
| 150 | + | let (p, f) = parse_test_result_line(line).unwrap(); | |
| 151 | + | assert_eq!(p, 40); | |
| 152 | + | assert_eq!(f, 2); | |
| 153 | + | } | |
| 154 | + | } |
| @@ -0,0 +1,74 @@ | |||
| 1 | + | use tokio::process::Command; | |
| 2 | + | ||
| 3 | + | use crate::checks::parse; | |
| 4 | + | use crate::config::TestsConfig; | |
| 5 | + | use crate::types::{TestRun, TestSummary}; | |
| 6 | + | ||
| 7 | + | pub async fn run_tests( | |
| 8 | + | target_name: &str, | |
| 9 | + | config: &TestsConfig, | |
| 10 | + | filter: Option<&str>, | |
| 11 | + | ) -> TestRun { | |
| 12 | + | let started_at = chrono::Utc::now().to_rfc3339(); | |
| 13 | + | let start = std::time::Instant::now(); | |
| 14 | + | ||
| 15 | + | let mut cmd_str = config.command.clone(); | |
| 16 | + | if let Some(f) = filter { | |
| 17 | + | cmd_str.push(' '); | |
| 18 | + | cmd_str.push_str(f); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | let result = Command::new("ssh") | |
| 22 | + | .arg("-o") | |
| 23 | + | .arg("BatchMode=yes") | |
| 24 | + | .arg("-o") | |
| 25 | + | .arg("ConnectTimeout=10") | |
| 26 | + | .arg(&config.ssh) | |
| 27 | + | .arg(&cmd_str) | |
| 28 | + | .output() | |
| 29 | + | .await; | |
| 30 | + | ||
| 31 | + | let finished_at = chrono::Utc::now().to_rfc3339(); | |
| 32 | + | let duration_secs = start.elapsed().as_secs() as i64; | |
| 33 | + | ||
| 34 | + | match result { | |
| 35 | + | Ok(output) => { | |
| 36 | + | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 37 | + | let stderr = String::from_utf8_lossy(&output.stderr); | |
| 38 | + | let raw_output = format!("{stdout}{stderr}"); | |
| 39 | + | ||
| 40 | + | let exit_code = output.status.code(); | |
| 41 | + | let passed = output.status.success(); | |
| 42 | + | let summary = parse::parse_ci_output(&raw_output); | |
| 43 | + | ||
| 44 | + | TestRun { | |
| 45 | + | id: None, | |
| 46 | + | target: target_name.to_string(), | |
| 47 | + | started_at, | |
| 48 | + | finished_at: Some(finished_at), | |
| 49 | + | duration_secs: Some(duration_secs), | |
| 50 | + | exit_code, | |
| 51 | + | passed, | |
| 52 | + | summary, | |
| 53 | + | raw_output, | |
| 54 | + | filter: filter.map(String::from), | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | Err(e) => TestRun { | |
| 58 | + | id: None, | |
| 59 | + | target: target_name.to_string(), | |
| 60 | + | started_at, | |
| 61 | + | finished_at: Some(finished_at), | |
| 62 | + | duration_secs: Some(duration_secs), | |
| 63 | + | exit_code: None, | |
| 64 | + | passed: false, | |
| 65 | + | summary: TestSummary { | |
| 66 | + | steps: vec![], | |
| 67 | + | total_passed: None, | |
| 68 | + | total_failed: None, | |
| 69 | + | }, | |
| 70 | + | raw_output: format!("SSH connection failed: {e}"), | |
| 71 | + | filter: filter.map(String::from), | |
| 72 | + | }, | |
| 73 | + | } | |
| 74 | + | } |
| @@ -0,0 +1,107 @@ | |||
| 1 | + | use serde::Deserialize; | |
| 2 | + | use std::collections::HashMap; | |
| 3 | + | use std::path::{Path, PathBuf}; | |
| 4 | + | ||
| 5 | + | #[derive(Debug, Clone, Deserialize)] | |
| 6 | + | pub struct Config { | |
| 7 | + | #[serde(default)] | |
| 8 | + | pub serve: ServeConfig, | |
| 9 | + | #[serde(default)] | |
| 10 | + | pub targets: HashMap<String, TargetConfig>, | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | #[derive(Debug, Clone, Deserialize)] | |
| 14 | + | pub struct ServeConfig { | |
| 15 | + | #[serde(default = "default_serve_interval")] | |
| 16 | + | pub interval_secs: u64, | |
| 17 | + | #[serde(default = "default_prune_days")] | |
| 18 | + | pub prune_days: i64, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl Default for ServeConfig { | |
| 22 | + | fn default() -> Self { | |
| 23 | + | Self { | |
| 24 | + | interval_secs: 300, | |
| 25 | + | prune_days: 30, | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | fn default_serve_interval() -> u64 { | |
| 31 | + | 300 | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | fn default_prune_days() -> i64 { | |
| 35 | + | 30 | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | #[derive(Debug, Clone, Deserialize)] | |
| 39 | + | pub struct TargetConfig { | |
| 40 | + | pub label: String, | |
| 41 | + | pub health: Option<HealthConfig>, | |
| 42 | + | pub tests: Option<TestsConfig>, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | #[derive(Debug, Clone, Deserialize)] | |
| 46 | + | pub struct HealthConfig { | |
| 47 | + | pub url: String, | |
| 48 | + | #[serde(default = "default_health_timeout")] | |
| 49 | + | pub timeout_secs: u64, | |
| 50 | + | /// Per-target interval override for serve mode | |
| 51 | + | pub interval_secs: Option<u64>, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | #[derive(Debug, Clone, Deserialize)] | |
| 55 | + | pub struct TestsConfig { | |
| 56 | + | pub ssh: String, | |
| 57 | + | pub command: String, | |
| 58 | + | #[serde(default = "default_test_timeout")] | |
| 59 | + | pub timeout_secs: u64, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | fn default_health_timeout() -> u64 { | |
| 63 | + | 10 | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | fn default_test_timeout() -> u64 { | |
| 67 | + | 600 | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | impl Config { | |
| 71 | + | pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { | |
| 72 | + | let config_path = match path { | |
| 73 | + | Some(p) => p.to_path_buf(), | |
| 74 | + | None => default_config_path()?, | |
| 75 | + | }; | |
| 76 | + | ||
| 77 | + | if !config_path.exists() { | |
| 78 | + | return Err(format!("Config file not found: {}", config_path.display()).into()); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | let contents = std::fs::read_to_string(&config_path)?; | |
| 82 | + | let config: Config = toml::from_str(&contents)?; | |
| 83 | + | Ok(config) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | pub fn get_target(&self, name: &str) -> Option<&TargetConfig> { | |
| 87 | + | self.targets.get(name) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | pub fn target_names(&self) -> Vec<String> { | |
| 91 | + | let mut names: Vec<_> = self.targets.keys().cloned().collect(); | |
| 92 | + | names.sort(); | |
| 93 | + | names | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 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")) | |
| 100 | + | } | |
| 101 | + | ||
| 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"); | |
| 105 | + | std::fs::create_dir_all(&pom_dir)?; | |
| 106 | + | Ok(pom_dir.join("pom.db")) | |
| 107 | + | } |
| @@ -0,0 +1,303 @@ | |||
| 1 | + | use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; | |
| 2 | + | use std::path::Path; | |
| 3 | + | use std::str::FromStr; | |
| 4 | + | ||
| 5 | + | use crate::types::{HealthDetails, HealthSnapshot, HealthStatus, TestRun, TestSummary}; | |
| 6 | + | ||
| 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 ( | |
| 35 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 36 | + | target TEXT NOT NULL, | |
| 37 | + | status TEXT NOT NULL, | |
| 38 | + | checked_at TEXT NOT NULL, | |
| 39 | + | response_time_ms INTEGER NOT NULL, | |
| 40 | + | details_json TEXT, | |
| 41 | + | error TEXT | |
| 42 | + | )", | |
| 43 | + | ) | |
| 44 | + | .execute(pool) | |
| 45 | + | .await?; | |
| 46 | + | ||
| 47 | + | sqlx::query( | |
| 48 | + | "CREATE TABLE IF NOT EXISTS test_runs ( | |
| 49 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 50 | + | target TEXT NOT NULL, | |
| 51 | + | started_at TEXT NOT NULL, | |
| 52 | + | finished_at TEXT, | |
| 53 | + | duration_secs INTEGER, | |
| 54 | + | exit_code INTEGER, | |
| 55 | + | passed INTEGER NOT NULL, | |
| 56 | + | summary_json TEXT NOT NULL, | |
| 57 | + | raw_output TEXT NOT NULL, | |
| 58 | + | filter TEXT | |
| 59 | + | )", | |
| 60 | + | ) | |
| 61 | + | .execute(pool) | |
| 62 | + | .await?; | |
| 63 | + | ||
| 64 | + | Ok(()) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // --- Health check queries --- | |
| 68 | + | ||
| 69 | + | pub async fn insert_health_check( | |
| 70 | + | pool: &SqlitePool, | |
| 71 | + | snapshot: &HealthSnapshot, | |
| 72 | + | ) -> Result<i64, sqlx::Error> { | |
| 73 | + | let status = snapshot.status.to_string(); | |
| 74 | + | let details_json = snapshot | |
| 75 | + | .details | |
| 76 | + | .as_ref() | |
| 77 | + | .map(|d| serde_json::to_string(d).unwrap_or_default()); | |
| 78 | + | ||
| 79 | + | let result = sqlx::query( | |
| 80 | + | "INSERT INTO health_checks (target, status, checked_at, response_time_ms, details_json, error) | |
| 81 | + | VALUES (?, ?, ?, ?, ?, ?)", | |
| 82 | + | ) | |
| 83 | + | .bind(&snapshot.target) | |
| 84 | + | .bind(&status) | |
| 85 | + | .bind(&snapshot.checked_at) | |
| 86 | + | .bind(snapshot.response_time_ms) | |
| 87 | + | .bind(&details_json) | |
| 88 | + | .bind(&snapshot.error) | |
| 89 | + | .execute(pool) | |
| 90 | + | .await?; | |
| 91 | + | ||
| 92 | + | Ok(result.last_insert_rowid()) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | pub async fn get_health_history( | |
| 96 | + | pool: &SqlitePool, | |
| 97 | + | target: Option<&str>, | |
| 98 | + | limit: i64, | |
| 99 | + | ) -> Result<Vec<HealthSnapshot>, sqlx::Error> { | |
| 100 | + | let rows = match target { | |
| 101 | + | Some(t) => { | |
| 102 | + | sqlx::query_as::<_, HealthCheckRow>( | |
| 103 | + | "SELECT id, target, status, checked_at, response_time_ms, details_json, error | |
| 104 | + | FROM health_checks WHERE target = ? ORDER BY id DESC LIMIT ?", | |
| 105 | + | ) | |
| 106 | + | .bind(t) | |
| 107 | + | .bind(limit) | |
| 108 | + | .fetch_all(pool) | |
| 109 | + | .await? | |
| 110 | + | } | |
| 111 | + | None => { | |
| 112 | + | sqlx::query_as::<_, HealthCheckRow>( | |
| 113 | + | "SELECT id, target, status, checked_at, response_time_ms, details_json, error | |
| 114 | + | FROM health_checks ORDER BY id DESC LIMIT ?", | |
| 115 | + | ) | |
| 116 | + | .bind(limit) | |
| 117 | + | .fetch_all(pool) | |
| 118 | + | .await? | |
| 119 | + | } | |
| 120 | + | }; | |
| 121 | + | ||
| 122 | + | Ok(rows.into_iter().map(|r| r.into_snapshot()).collect()) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | pub async fn get_latest_health( | |
| 126 | + | pool: &SqlitePool, | |
| 127 | + | target: &str, | |
| 128 | + | ) -> Result<Option<HealthSnapshot>, sqlx::Error> { | |
| 129 | + | let row = sqlx::query_as::<_, HealthCheckRow>( | |
| 130 | + | "SELECT id, target, status, checked_at, response_time_ms, details_json, error | |
| 131 | + | FROM health_checks WHERE target = ? ORDER BY id DESC LIMIT 1", | |
| 132 | + | ) | |
| 133 | + | .bind(target) | |
| 134 | + | .fetch_optional(pool) | |
| 135 | + | .await?; | |
| 136 | + | ||
| 137 | + | Ok(row.map(|r| r.into_snapshot())) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // --- Test run queries --- | |
| 141 | + | ||
| 142 | + | pub async fn insert_test_run( | |
| 143 | + | pool: &SqlitePool, | |
| 144 | + | run: &TestRun, | |
| 145 | + | ) -> Result<i64, sqlx::Error> { | |
| 146 | + | let summary_json = serde_json::to_string(&run.summary).unwrap_or_default(); | |
| 147 | + | ||
| 148 | + | let result = sqlx::query( | |
| 149 | + | "INSERT INTO test_runs (target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter) | |
| 150 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 151 | + | ) | |
| 152 | + | .bind(&run.target) | |
| 153 | + | .bind(&run.started_at) | |
| 154 | + | .bind(&run.finished_at) | |
| 155 | + | .bind(run.duration_secs) | |
| 156 | + | .bind(run.exit_code) | |
| 157 | + | .bind(run.passed) | |
| 158 | + | .bind(&summary_json) | |
| 159 | + | .bind(&run.raw_output) | |
| 160 | + | .bind(&run.filter) | |
| 161 | + | .execute(pool) | |
| 162 | + | .await?; | |
| 163 | + | ||
| 164 | + | Ok(result.last_insert_rowid()) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | pub async fn get_test_history( | |
| 168 | + | pool: &SqlitePool, | |
| 169 | + | target: Option<&str>, | |
| 170 | + | limit: i64, | |
| 171 | + | ) -> Result<Vec<TestRun>, sqlx::Error> { | |
| 172 | + | let rows = match target { | |
| 173 | + | Some(t) => { | |
| 174 | + | sqlx::query_as::<_, TestRunRow>( | |
| 175 | + | "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter | |
| 176 | + | FROM test_runs WHERE target = ? ORDER BY id DESC LIMIT ?", | |
| 177 | + | ) | |
| 178 | + | .bind(t) | |
| 179 | + | .bind(limit) | |
| 180 | + | .fetch_all(pool) | |
| 181 | + | .await? | |
| 182 | + | } | |
| 183 | + | None => { | |
| 184 | + | sqlx::query_as::<_, TestRunRow>( | |
| 185 | + | "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter | |
| 186 | + | FROM test_runs ORDER BY id DESC LIMIT ?", | |
| 187 | + | ) | |
| 188 | + | .bind(limit) | |
| 189 | + | .fetch_all(pool) | |
| 190 | + | .await? | |
| 191 | + | } | |
| 192 | + | }; | |
| 193 | + | ||
| 194 | + | Ok(rows.into_iter().map(|r| r.into_test_run()).collect()) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | pub async fn get_latest_test_run( | |
| 198 | + | pool: &SqlitePool, | |
| 199 | + | target: &str, | |
| 200 | + | ) -> Result<Option<TestRun>, sqlx::Error> { | |
| 201 | + | let row = sqlx::query_as::<_, TestRunRow>( | |
| 202 | + | "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter | |
| 203 | + | FROM test_runs WHERE target = ? ORDER BY id DESC LIMIT 1", | |
| 204 | + | ) | |
| 205 | + | .bind(target) | |
| 206 | + | .fetch_optional(pool) | |
| 207 | + | .await?; | |
| 208 | + | ||
| 209 | + | Ok(row.map(|r| r.into_test_run())) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | pub async fn prune_old_records( | |
| 213 | + | pool: &SqlitePool, | |
| 214 | + | days: i64, | |
| 215 | + | ) -> Result<(u64, u64), sqlx::Error> { | |
| 216 | + | let cutoff = chrono::Utc::now() - chrono::Duration::days(days); | |
| 217 | + | let cutoff_str = cutoff.to_rfc3339(); | |
| 218 | + | ||
| 219 | + | let health_result = sqlx::query("DELETE FROM health_checks WHERE checked_at < ?") | |
| 220 | + | .bind(&cutoff_str) | |
| 221 | + | .execute(pool) | |
| 222 | + | .await?; | |
| 223 | + | ||
| 224 | + | let test_result = sqlx::query("DELETE FROM test_runs WHERE started_at < ?") | |
| 225 | + | .bind(&cutoff_str) | |
| 226 | + | .execute(pool) | |
| 227 | + | .await?; | |
| 228 | + | ||
| 229 | + | Ok((health_result.rows_affected(), test_result.rows_affected())) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // --- Internal row types --- | |
| 233 | + | ||
| 234 | + | #[derive(sqlx::FromRow)] | |
| 235 | + | struct HealthCheckRow { | |
| 236 | + | id: i64, | |
| 237 | + | target: String, | |
| 238 | + | status: String, | |
| 239 | + | checked_at: String, | |
| 240 | + | response_time_ms: i64, | |
| 241 | + | details_json: Option<String>, | |
| 242 | + | error: Option<String>, | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | impl HealthCheckRow { | |
| 246 | + | fn into_snapshot(self) -> HealthSnapshot { | |
| 247 | + | let status = self | |
| 248 | + | .status | |
| 249 | + | .parse::<HealthStatus>() | |
| 250 | + | .unwrap_or(HealthStatus::Error); | |
| 251 | + | let details = self | |
| 252 | + | .details_json | |
| 253 | + | .as_deref() | |
| 254 | + | .and_then(|s| serde_json::from_str::<HealthDetails>(s).ok()); | |
| 255 | + | ||
| 256 | + | HealthSnapshot { | |
| 257 | + | id: Some(self.id), | |
| 258 | + | target: self.target, | |
| 259 | + | status, | |
| 260 | + | checked_at: self.checked_at, | |
| 261 | + | response_time_ms: self.response_time_ms, | |
| 262 | + | details, | |
| 263 | + | error: self.error, | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | #[derive(sqlx::FromRow)] | |
| 269 | + | struct TestRunRow { | |
| 270 | + | id: i64, | |
| 271 | + | target: String, | |
| 272 | + | started_at: String, | |
| 273 | + | finished_at: Option<String>, | |
| 274 | + | duration_secs: Option<i64>, | |
| 275 | + | exit_code: Option<i32>, | |
| 276 | + | passed: bool, | |
| 277 | + | summary_json: String, | |
| 278 | + | raw_output: String, | |
| 279 | + | filter: Option<String>, | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | impl TestRunRow { | |
| 283 | + | fn into_test_run(self) -> TestRun { | |
| 284 | + | let summary = serde_json::from_str::<TestSummary>(&self.summary_json).unwrap_or(TestSummary { | |
| 285 | + | steps: vec![], | |
| 286 | + | total_passed: None, | |
| 287 | + | total_failed: None, | |
| 288 | + | }); | |
| 289 | + | ||
| 290 | + | TestRun { | |
| 291 | + | id: Some(self.id), | |
| 292 | + | target: self.target, | |
| 293 | + | started_at: self.started_at, | |
| 294 | + | finished_at: self.finished_at, | |
| 295 | + | duration_secs: self.duration_secs, | |
| 296 | + | exit_code: self.exit_code, | |
| 297 | + | passed: self.passed, | |
| 298 | + | summary, | |
| 299 | + | raw_output: self.raw_output, | |
| 300 | + | filter: self.filter, | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | } |
| @@ -0,0 +1,5 @@ | |||
| 1 | + | pub mod checks; | |
| 2 | + | pub mod config; | |
| 3 | + | pub mod db; | |
| 4 | + | pub mod tools; | |
| 5 | + | pub mod types; |
| @@ -0,0 +1,473 @@ | |||
| 1 | + | use clap::{Parser, Subcommand}; | |
| 2 | + | use rmcp::ServiceExt; | |
| 3 | + | use tokio::io::{stdin, stdout}; | |
| 4 | + | use tracing::info; | |
| 5 | + | use tracing_subscriber::{fmt, prelude::*, EnvFilter}; | |
| 6 | + | ||
| 7 | + | use pom::checks::{http, ssh}; | |
| 8 | + | use pom::config::{self, Config}; | |
| 9 | + | use pom::db; | |
| 10 | + | use pom::tools::PomServer; | |
| 11 | + | use pom::types::HealthStatus; | |
| 12 | + | ||
| 13 | + | #[derive(Parser)] | |
| 14 | + | #[command(name = "pom", about = "Peace of Mind — health checks and test orchestration")] | |
| 15 | + | struct Cli { | |
| 16 | + | /// Path to config file (default: ~/.config/pom/pom.toml) | |
| 17 | + | #[arg(long, global = true)] | |
| 18 | + | config: Option<std::path::PathBuf>, | |
| 19 | + | ||
| 20 | + | #[command(subcommand)] | |
| 21 | + | command: Option<Commands>, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | #[derive(Subcommand)] | |
| 25 | + | enum Commands { | |
| 26 | + | /// Check health of targets | |
| 27 | + | Health { | |
| 28 | + | /// Target name (omit for all) | |
| 29 | + | target: Option<String>, | |
| 30 | + | /// Output as JSON | |
| 31 | + | #[arg(long)] | |
| 32 | + | json: bool, | |
| 33 | + | }, | |
| 34 | + | /// Run tests on a target via SSH | |
| 35 | + | Test { | |
| 36 | + | /// Target name | |
| 37 | + | target: String, | |
| 38 | + | /// Filter tests | |
| 39 | + | #[arg(long, short)] | |
| 40 | + | filter: Option<String>, | |
| 41 | + | /// Output as JSON | |
| 42 | + | #[arg(long)] | |
| 43 | + | json: bool, | |
| 44 | + | }, | |
| 45 | + | /// Show status dashboard | |
| 46 | + | Status { | |
| 47 | + | /// Output as JSON | |
| 48 | + | #[arg(long)] | |
| 49 | + | json: bool, | |
| 50 | + | }, | |
| 51 | + | /// View history | |
| 52 | + | History { | |
| 53 | + | #[command(subcommand)] | |
| 54 | + | kind: HistoryKind, | |
| 55 | + | }, | |
| 56 | + | /// Prune old records | |
| 57 | + | Prune { | |
| 58 | + | /// Number of days to keep (default 30) | |
| 59 | + | #[arg(long, default_value = "30")] | |
| 60 | + | days: i64, | |
| 61 | + | }, | |
| 62 | + | /// Run as a daemon, checking health at intervals | |
| 63 | + | 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, | |
| 86 | + | /// Output as JSON | |
| 87 | + | #[arg(long)] | |
| 88 | + | json: bool, | |
| 89 | + | }, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[tokio::main] | |
| 93 | + | async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { | |
| 94 | + | let cli = Cli::parse(); | |
| 95 | + | ||
| 96 | + | let config_path = cli.config.as_deref(); | |
| 97 | + | let config = Config::load(config_path)?; | |
| 98 | + | ||
| 99 | + | match cli.command { | |
| 100 | + | None => run_mcp_server(config).await, | |
| 101 | + | Some(cmd) => run_cli(cmd, config).await, | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | async fn run_mcp_server(config: Config) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { | |
| 106 | + | tracing_subscriber::registry() | |
| 107 | + | .with(fmt::layer().with_writer(std::io::stderr)) | |
| 108 | + | .with(EnvFilter::from_default_env().add_directive("pom=info".parse()?)) | |
| 109 | + | .init(); | |
| 110 | + | ||
| 111 | + | info!("Starting PoM MCP server"); | |
| 112 | + | ||
| 113 | + | let db_path = config::db_path()?; | |
| 114 | + | let pool = db::connect(&db_path).await?; | |
| 115 | + | info!("Database ready at {}", db_path.display()); | |
| 116 | + | ||
| 117 | + | let server = PomServer::new(pool, config); | |
| 118 | + | let transport = (stdin(), stdout()); | |
| 119 | + | ||
| 120 | + | info!("MCP server ready"); | |
| 121 | + | let service = server.serve(transport).await?; | |
| 122 | + | let quit_reason = service.waiting().await?; | |
| 123 | + | info!(?quit_reason, "MCP server shutting down"); | |
| 124 | + | ||
| 125 | + | Ok(()) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | async fn run_cli( | |
| 129 | + | cmd: Commands, | |
| 130 | + | config: Config, | |
| 131 | + | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { | |
| 132 | + | let log_level = if matches!(cmd, Commands::Serve) { "pom=info" } else { "pom=warn" }; | |
| 133 | + | tracing_subscriber::registry() | |
| 134 | + | .with(fmt::layer().with_writer(std::io::stderr)) | |
| 135 | + | .with(EnvFilter::from_default_env().add_directive(log_level.parse()?)) | |
| 136 | + | .init(); | |
| 137 | + | ||
| 138 | + | let db_path = config::db_path()?; | |
| 139 | + | let pool = db::connect(&db_path).await?; | |
| 140 | + | ||
| 141 | + | 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, | |
| 148 | + | } | |
| 149 | + | } | |
| 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 | + | } |
| @@ -0,0 +1,148 @@ | |||
| 1 | + | use schemars::JsonSchema; | |
| 2 | + | use serde::Deserialize; | |
| 3 | + | ||
| 4 | + | use crate::checks::http; | |
| 5 | + | use crate::db; | |
| 6 | + | use crate::types::TargetInfo; | |
| 7 | + | ||
| 8 | + | use super::PomServer; | |
| 9 | + | ||
| 10 | + | #[derive(Debug, Deserialize, JsonSchema)] | |
| 11 | + | pub struct CheckHealthParams { | |
| 12 | + | /// Target name to check (omit to check all targets) | |
| 13 | + | pub target: Option<String>, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | #[derive(Debug, Deserialize, JsonSchema)] | |
| 17 | + | pub struct HealthHistoryParams { | |
| 18 | + | /// Filter by target name | |
| 19 | + | pub target: Option<String>, | |
| 20 | + | /// Number of results to return (default 10) | |
| 21 | + | pub limit: Option<i64>, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | impl PomServer { | |
| 25 | + | pub(crate) async fn get_status_impl( | |
| 26 | + | &self, | |
| 27 | + | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 28 | + | let mut status_parts = Vec::new(); | |
| 29 | + | ||
| 30 | + | for name in self.config.target_names() { | |
| 31 | + | let target = self.config.get_target(&name).unwrap(); | |
| 32 | + | let mut target_status = format!("## {name} ({})\n", target.label); | |
| 33 | + | ||
| 34 | + | // Latest health | |
| 35 | + | if let Ok(Some(health)) = db::get_latest_health(&self.pool, &name).await { | |
| 36 | + | target_status.push_str(&format!( | |
| 37 | + | "Health: {} ({}ms, {})\n", | |
| 38 | + | health.status, health.response_time_ms, health.checked_at | |
| 39 | + | )); | |
| 40 | + | if let Some(details) = &health.details { | |
| 41 | + | if let Some(v) = &details.version { | |
| 42 | + | target_status.push_str(&format!("Version: {v}\n")); | |
| 43 | + | } | |
| 44 | + | if let Some(u) = &details.uptime { | |
| 45 | + | target_status.push_str(&format!("Uptime: {u}\n")); | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | if let Some(err) = &health.error { | |
| 49 | + | target_status.push_str(&format!("Error: {err}\n")); | |
| 50 | + | } | |
| 51 | + | } else { | |
| 52 | + | target_status.push_str("Health: no data\n"); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Latest test run | |
| 56 | + | if let Ok(Some(test)) = db::get_latest_test_run(&self.pool, &name).await { | |
| 57 | + | let result = if test.passed { "PASSED" } else { "FAILED" }; | |
| 58 | + | target_status.push_str(&format!("Tests: {result}")); | |
| 59 | + | if let Some(d) = test.duration_secs { | |
| 60 | + | target_status.push_str(&format!(" ({d}s)")); | |
| 61 | + | } | |
| 62 | + | target_status.push_str(&format!(" ({})\n", test.started_at)); | |
| 63 | + | if let (Some(p), Some(f)) = (test.summary.total_passed, test.summary.total_failed) { | |
| 64 | + | target_status.push_str(&format!(" {p} passed, {f} failed\n")); | |
| 65 | + | } | |
| 66 | + | for step in &test.summary.steps { | |
| 67 | + | let mark = if step.passed { "PASS" } else { "FAIL" }; | |
| 68 | + | target_status.push_str(&format!(" {mark} {}\n", step.name)); | |
| 69 | + | } | |
| 70 | + | } else { | |
| 71 | + | target_status.push_str("Tests: no data\n"); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | status_parts.push(target_status); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | if status_parts.is_empty() { | |
| 78 | + | return Ok("No targets configured.".to_string()); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | Ok(status_parts.join("\n")) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | pub(crate) async fn check_health_impl( | |
| 85 | + | &self, | |
| 86 | + | params: CheckHealthParams, | |
| 87 | + | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 88 | + | let targets: Vec<String> = match ¶ms.target { | |
| 89 | + | Some(t) => { | |
| 90 | + | if self.config.get_target(t).is_none() { | |
| 91 | + | return Ok(format!("Unknown target: {t}")); | |
| 92 | + | } | |
| 93 | + | vec![t.clone()] | |
| 94 | + | } | |
| 95 | + | None => self.config.target_names(), | |
| 96 | + | }; | |
| 97 | + | ||
| 98 | + | let mut results = Vec::new(); | |
| 99 | + | ||
| 100 | + | for name in &targets { | |
| 101 | + | let target = self.config.get_target(name).unwrap(); | |
| 102 | + | if let Some(health_config) = &target.health { | |
| 103 | + | let snapshot = http::check_health(name, health_config).await; | |
| 104 | + | db::insert_health_check(&self.pool, &snapshot).await?; | |
| 105 | + | results.push(serde_json::to_string_pretty(&snapshot)?); | |
| 106 | + | } else { | |
| 107 | + | results.push(format!("{name}: no health endpoint configured")); | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | Ok(results.join("\n\n")) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | pub(crate) async fn health_history_impl( | |
| 115 | + | &self, | |
| 116 | + | params: HealthHistoryParams, | |
| 117 | + | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 118 | + | let limit = params.limit.unwrap_or(10); | |
| 119 | + | let history = db::get_health_history(&self.pool, params.target.as_deref(), limit).await?; | |
| 120 | + | ||
| 121 | + | if history.is_empty() { | |
| 122 | + | return Ok("No health check history.".to_string()); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | Ok(serde_json::to_string_pretty(&history)?) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | pub(crate) async fn list_targets_impl( | |
| 129 | + | &self, | |
| 130 | + | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 131 | + | let targets: Vec<TargetInfo> = self | |
| 132 | + | .config | |
| 133 | + | .target_names() | |
| 134 | + | .into_iter() | |
| 135 | + | .map(|name| { | |
| 136 | + | let t = self.config.get_target(&name).unwrap(); | |
| 137 | + | TargetInfo { | |
| 138 | + | name, | |
| 139 | + | label: t.label.clone(), | |
| 140 | + | has_health: t.health.is_some(), | |
| 141 | + | has_tests: t.tests.is_some(), | |
| 142 | + | } | |
| 143 | + | }) | |
| 144 | + | .collect(); | |
| 145 | + | ||
| 146 | + | Ok(serde_json::to_string_pretty(&targets)?) | |
| 147 | + | } | |
| 148 | + | } |
| @@ -0,0 +1,118 @@ | |||
| 1 | + | mod health; | |
| 2 | + | mod tests; | |
| 3 | + | ||
| 4 | + | use rmcp::tool; | |
| 5 | + | use rmcp::model::{ServerCapabilities, ServerInfo}; | |
| 6 | + | use rmcp::ServerHandler; | |
| 7 | + | use sqlx::SqlitePool; | |
| 8 | + | ||
| 9 | + | use crate::config::Config; | |
| 10 | + | ||
| 11 | + | #[derive(Clone)] | |
| 12 | + | pub struct PomServer { | |
| 13 | + | pub(crate) pool: SqlitePool, | |
| 14 | + | pub(crate) config: Config, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | impl PomServer { | |
| 18 | + | pub fn new(pool: SqlitePool, config: Config) -> Self { | |
| 19 | + | Self { pool, config } | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | #[tool(tool_box)] | |
| 24 | + | impl PomServer { | |
| 25 | + | /// Get overall status dashboard for all configured targets. | |
| 26 | + | #[tool(description = "Get overall status dashboard: all targets' latest health check and test run results. Use this for a quick overview.")] | |
| 27 | + | pub async fn get_status(&self) -> String { | |
| 28 | + | match self.get_status_impl().await { | |
| 29 | + | Ok(result) => result, | |
| 30 | + | Err(e) => format!("Error getting status: {e}"), | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// Check health of a target (or all targets). | |
| 35 | + | #[tool(description = "Run a live health check against a target's health endpoint. Stores the result and returns the snapshot. Omit target to check all.")] | |
| 36 | + | pub async fn check_health( | |
| 37 | + | &self, | |
| 38 | + | #[tool(aggr)] params: health::CheckHealthParams, | |
| 39 | + | ) -> String { | |
| 40 | + | match self.check_health_impl(params).await { | |
| 41 | + | Ok(result) => result, | |
| 42 | + | Err(e) => format!("Error checking health: {e}"), | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Get health check history. | |
| 47 | + | #[tool(description = "Get recent health check history. Optionally filter by target and limit results (default 10).")] | |
| 48 | + | pub async fn health_history( | |
| 49 | + | &self, | |
| 50 | + | #[tool(aggr)] params: health::HealthHistoryParams, | |
| 51 | + | ) -> String { | |
| 52 | + | match self.health_history_impl(params).await { | |
| 53 | + | Ok(result) => result, | |
| 54 | + | Err(e) => format!("Error getting health history: {e}"), | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// List configured targets. | |
| 59 | + | #[tool(description = "List all configured targets with their labels and capabilities (health check, test suite).")] | |
| 60 | + | pub async fn list_targets(&self) -> String { | |
| 61 | + | match self.list_targets_impl().await { | |
| 62 | + | Ok(result) => result, | |
| 63 | + | Err(e) => format!("Error listing targets: {e}"), | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Run tests on a target via SSH. | |
| 68 | + | #[tool(description = "Run the test suite on a target via SSH. Returns a summary with pass/fail counts. Optionally provide a filter to run specific tests.")] | |
| 69 | + | pub async fn run_tests( | |
| 70 | + | &self, | |
| 71 | + | #[tool(aggr)] params: tests::RunTestsParams, | |
| 72 | + | ) -> String { | |
| 73 | + | match self.run_tests_impl(params).await { | |
| 74 | + | Ok(result) => result, | |
| 75 | + | Err(e) => format!("Error running tests: {e}"), | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Get test run history. | |
| 80 | + | #[tool(description = "Get recent test run history (without raw output). Optionally filter by target and limit results (default 10).")] | |
| 81 | + | pub async fn test_history( | |
| 82 | + | &self, | |
| 83 | + | #[tool(aggr)] params: tests::TestHistoryParams, | |
| 84 | + | ) -> String { | |
| 85 | + | match self.test_history_impl(params).await { | |
| 86 | + | Ok(result) => result, | |
| 87 | + | Err(e) => format!("Error getting test history: {e}"), | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Get raw output of the most recent test run. | |
| 92 | + | #[tool(description = "Get the full raw stdout/stderr output of the most recent test run for a target. Useful for debugging failures.")] | |
| 93 | + | pub async fn last_test_output( | |
| 94 | + | &self, | |
| 95 | + | #[tool(aggr)] params: tests::LastTestOutputParams, | |
| 96 | + | ) -> String { | |
| 97 | + | match self.last_test_output_impl(params).await { | |
| 98 | + | Ok(result) => result, | |
| 99 | + | Err(e) => format!("Error getting test output: {e}"), | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | #[tool(tool_box)] | |
| 105 | + | impl ServerHandler for PomServer { | |
| 106 | + | fn get_info(&self) -> ServerInfo { | |
| 107 | + | ServerInfo { | |
| 108 | + | instructions: Some( | |
| 109 | + | "Peace of Mind (PoM) server for monitoring production health and running tests. \ | |
| 110 | + | Tools: get_status (dashboard), check_health (live health check), health_history, \ | |
| 111 | + | list_targets, run_tests (SSH test execution), test_history, last_test_output." | |
| 112 | + | .into(), | |
| 113 | + | ), | |
| 114 | + | capabilities: ServerCapabilities::builder().enable_tools().build(), | |
| 115 | + | ..Default::default() | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } |
| @@ -0,0 +1,105 @@ | |||
| 1 | + | use schemars::JsonSchema; | |
| 2 | + | use serde::Deserialize; | |
| 3 | + | ||
| 4 | + | use crate::checks::ssh; | |
| 5 | + | use crate::db; | |
| 6 | + | ||
| 7 | + | use super::PomServer; | |
| 8 | + | ||
| 9 | + | #[derive(Debug, Deserialize, JsonSchema)] | |
| 10 | + | pub struct RunTestsParams { | |
| 11 | + | /// Target name to run tests on | |
| 12 | + | pub target: String, | |
| 13 | + | /// Optional filter to run specific tests | |
| 14 | + | pub filter: Option<String>, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | #[derive(Debug, Deserialize, JsonSchema)] | |
| 18 | + | pub struct TestHistoryParams { | |
| 19 | + | /// Filter by target name | |
| 20 | + | pub target: Option<String>, | |
| 21 | + | /// Number of results to return (default 10) | |
| 22 | + | pub limit: Option<i64>, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | #[derive(Debug, Deserialize, JsonSchema)] | |
| 26 | + | pub struct LastTestOutputParams { | |
| 27 | + | /// Target name to get output for | |
| 28 | + | pub target: String, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl PomServer { | |
| 32 | + | pub(crate) async fn run_tests_impl( | |
| 33 | + | &self, | |
| 34 | + | params: RunTestsParams, | |
| 35 | + | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 36 | + | let target = self.config.get_target(¶ms.target).ok_or_else(|| { | |
| 37 | + | format!("Unknown target: {}", params.target) | |
| 38 | + | })?; | |
| 39 | + | ||
| 40 | + | let tests_config = target.tests.as_ref().ok_or_else(|| { | |
| 41 | + | format!("Target '{}' has no test configuration", params.target) | |
| 42 | + | })?; | |
| 43 | + | ||
| 44 | + | let run = ssh::run_tests(¶ms.target, tests_config, params.filter.as_deref()).await; | |
| 45 | + | db::insert_test_run(&self.pool, &run).await?; | |
| 46 | + | ||
| 47 | + | // Return summary without raw_output (it can be huge) | |
| 48 | + | let summary = serde_json::json!({ | |
| 49 | + | "target": run.target, | |
| 50 | + | "passed": run.passed, | |
| 51 | + | "exit_code": run.exit_code, | |
| 52 | + | "duration_secs": run.duration_secs, | |
| 53 | + | "started_at": run.started_at, | |
| 54 | + | "finished_at": run.finished_at, | |
| 55 | + | "filter": run.filter, | |
| 56 | + | "summary": run.summary, | |
| 57 | + | }); | |
| 58 | + | ||
| 59 | + | Ok(serde_json::to_string_pretty(&summary)?) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | pub(crate) async fn test_history_impl( | |
| 63 | + | &self, | |
| 64 | + | params: TestHistoryParams, | |
| 65 | + | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 66 | + | let limit = params.limit.unwrap_or(10); | |
| 67 | + | let history = db::get_test_history(&self.pool, params.target.as_deref(), limit).await?; | |
| 68 | + | ||
| 69 | + | if history.is_empty() { | |
| 70 | + | return Ok("No test run history.".to_string()); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Return without raw_output | |
| 74 | + | let summaries: Vec<serde_json::Value> = history | |
| 75 | + | .iter() | |
| 76 | + | .map(|run| { | |
| 77 | + | serde_json::json!({ | |
| 78 | + | "id": run.id, | |
| 79 | + | "target": run.target, | |
| 80 | + | "passed": run.passed, | |
| 81 | + | "exit_code": run.exit_code, | |
| 82 | + | "duration_secs": run.duration_secs, | |
| 83 | + | "started_at": run.started_at, | |
| 84 | + | "finished_at": run.finished_at, | |
| 85 | + | "filter": run.filter, | |
| 86 | + | "summary": run.summary, | |
| 87 | + | }) | |
| 88 | + | }) | |
| 89 | + | .collect(); | |
| 90 | + | ||
| 91 | + | Ok(serde_json::to_string_pretty(&summaries)?) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | pub(crate) async fn last_test_output_impl( | |
| 95 | + | &self, | |
| 96 | + | params: LastTestOutputParams, | |
| 97 | + | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 98 | + | let run = db::get_latest_test_run(&self.pool, ¶ms.target).await?; | |
| 99 | + | ||
| 100 | + | match run { | |
| 101 | + | Some(r) => Ok(r.raw_output), | |
| 102 | + | None => Ok(format!("No test runs found for target '{}'", params.target)), | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } |
| @@ -0,0 +1,88 @@ | |||
| 1 | + | use serde::{Deserialize, Serialize}; | |
| 2 | + | ||
| 3 | + | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] | |
| 4 | + | #[serde(rename_all = "lowercase")] | |
| 5 | + | pub enum HealthStatus { | |
| 6 | + | Operational, | |
| 7 | + | Degraded, | |
| 8 | + | Error, | |
| 9 | + | Unreachable, | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | impl std::fmt::Display for HealthStatus { | |
| 13 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 14 | + | match self { | |
| 15 | + | Self::Operational => write!(f, "operational"), | |
| 16 | + | Self::Degraded => write!(f, "degraded"), | |
| 17 | + | Self::Error => write!(f, "error"), | |
| 18 | + | Self::Unreachable => write!(f, "unreachable"), | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | impl std::str::FromStr for HealthStatus { | |
| 24 | + | type Err = String; | |
| 25 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 26 | + | match s { | |
| 27 | + | "operational" => Ok(Self::Operational), | |
| 28 | + | "degraded" => Ok(Self::Degraded), | |
| 29 | + | "error" => Ok(Self::Error), | |
| 30 | + | "unreachable" => Ok(Self::Unreachable), | |
| 31 | + | other => Err(format!("unknown health status: {other}")), | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 37 | + | pub struct HealthSnapshot { | |
| 38 | + | pub id: Option<i64>, | |
| 39 | + | pub target: String, | |
| 40 | + | pub status: HealthStatus, | |
| 41 | + | pub checked_at: String, | |
| 42 | + | pub response_time_ms: i64, | |
| 43 | + | pub details: Option<HealthDetails>, | |
| 44 | + | pub error: Option<String>, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 48 | + | pub struct HealthDetails { | |
| 49 | + | pub version: Option<String>, | |
| 50 | + | pub uptime: Option<String>, | |
| 51 | + | pub checks: Option<serde_json::Value>, | |
| 52 | + | pub monitoring: Option<serde_json::Value>, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 56 | + | pub struct TestRun { | |
| 57 | + | pub id: Option<i64>, | |
| 58 | + | pub target: String, | |
| 59 | + | pub started_at: String, | |
| 60 | + | pub finished_at: Option<String>, | |
| 61 | + | pub duration_secs: Option<i64>, | |
| 62 | + | pub exit_code: Option<i32>, | |
| 63 | + | pub passed: bool, | |
| 64 | + | pub summary: TestSummary, | |
| 65 | + | pub raw_output: String, | |
| 66 | + | pub filter: Option<String>, | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 70 | + | pub struct TestSummary { | |
| 71 | + | pub steps: Vec<StepResult>, | |
| 72 | + | pub total_passed: Option<i64>, | |
| 73 | + | pub total_failed: Option<i64>, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 77 | + | pub struct StepResult { | |
| 78 | + | pub name: String, | |
| 79 | + | pub passed: bool, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 83 | + | pub struct TargetInfo { | |
| 84 | + | pub name: String, | |
| 85 | + | pub label: String, | |
| 86 | + | pub has_health: bool, | |
| 87 | + | pub has_tests: bool, | |
| 88 | + | } |
| @@ -0,0 +1,211 @@ | |||
| 1 | + | use pom::db; | |
| 2 | + | use pom::types::*; | |
| 3 | + | ||
| 4 | + | #[tokio::test] | |
| 5 | + | async fn health_check_insert_and_query() { | |
| 6 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 7 | + | ||
| 8 | + | let snapshot = HealthSnapshot { | |
| 9 | + | id: None, | |
| 10 | + | target: "test-target".to_string(), | |
| 11 | + | status: HealthStatus::Operational, | |
| 12 | + | checked_at: "2026-03-10T00:00:00Z".to_string(), | |
| 13 | + | response_time_ms: 150, | |
| 14 | + | details: Some(HealthDetails { | |
| 15 | + | version: Some("1.0.0".to_string()), | |
| 16 | + | uptime: Some("5h 30m".to_string()), | |
| 17 | + | checks: None, | |
| 18 | + | monitoring: None, | |
| 19 | + | }), | |
| 20 | + | error: None, | |
| 21 | + | }; | |
| 22 | + | ||
| 23 | + | let id = db::insert_health_check(&pool, &snapshot).await.unwrap(); | |
| 24 | + | assert!(id > 0); | |
| 25 | + | ||
| 26 | + | let latest = db::get_latest_health(&pool, "test-target").await.unwrap(); | |
| 27 | + | assert!(latest.is_some()); | |
| 28 | + | let latest = latest.unwrap(); | |
| 29 | + | assert_eq!(latest.status, HealthStatus::Operational); | |
| 30 | + | assert_eq!(latest.response_time_ms, 150); | |
| 31 | + | assert_eq!(latest.details.unwrap().version.unwrap(), "1.0.0"); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | #[tokio::test] | |
| 35 | + | async fn health_history_returns_ordered() { | |
| 36 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 37 | + | ||
| 38 | + | for i in 0..5 { | |
| 39 | + | let snapshot = HealthSnapshot { | |
| 40 | + | id: None, | |
| 41 | + | target: "mnw".to_string(), | |
| 42 | + | status: HealthStatus::Operational, | |
| 43 | + | checked_at: format!("2026-03-10T0{}:00:00Z", i), | |
| 44 | + | response_time_ms: 100 + i * 10, | |
| 45 | + | details: None, | |
| 46 | + | error: None, | |
| 47 | + | }; | |
| 48 | + | db::insert_health_check(&pool, &snapshot).await.unwrap(); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | let history = db::get_health_history(&pool, Some("mnw"), 3).await.unwrap(); | |
| 52 | + | assert_eq!(history.len(), 3); | |
| 53 | + | // Most recent first (DESC) | |
| 54 | + | assert!(history[0].response_time_ms > history[1].response_time_ms); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[tokio::test] | |
| 58 | + | async fn health_history_filters_by_target() { | |
| 59 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 60 | + | ||
| 61 | + | for target in &["alpha", "beta"] { | |
| 62 | + | let snapshot = HealthSnapshot { | |
| 63 | + | id: None, | |
| 64 | + | target: target.to_string(), | |
| 65 | + | status: HealthStatus::Operational, | |
| 66 | + | checked_at: "2026-03-10T00:00:00Z".to_string(), | |
| 67 | + | response_time_ms: 100, | |
| 68 | + | details: None, | |
| 69 | + | error: None, | |
| 70 | + | }; | |
| 71 | + | db::insert_health_check(&pool, &snapshot).await.unwrap(); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | let all = db::get_health_history(&pool, None, 10).await.unwrap(); | |
| 75 | + | assert_eq!(all.len(), 2); | |
| 76 | + | ||
| 77 | + | let alpha_only = db::get_health_history(&pool, Some("alpha"), 10).await.unwrap(); | |
| 78 | + | assert_eq!(alpha_only.len(), 1); | |
| 79 | + | assert_eq!(alpha_only[0].target, "alpha"); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | #[tokio::test] | |
| 83 | + | async fn test_run_insert_and_query() { | |
| 84 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 85 | + | ||
| 86 | + | let run = TestRun { | |
| 87 | + | id: None, | |
| 88 | + | target: "mnw".to_string(), | |
| 89 | + | started_at: "2026-03-10T00:00:00Z".to_string(), | |
| 90 | + | finished_at: Some("2026-03-10T00:02:00Z".to_string()), | |
| 91 | + | duration_secs: Some(120), | |
| 92 | + | exit_code: Some(0), | |
| 93 | + | passed: true, | |
| 94 | + | summary: TestSummary { | |
| 95 | + | steps: vec![ | |
| 96 | + | StepResult { name: "cargo check".to_string(), passed: true }, | |
| 97 | + | StepResult { name: "cargo test --lib".to_string(), passed: true }, | |
| 98 | + | ], | |
| 99 | + | total_passed: Some(759), | |
| 100 | + | total_failed: Some(0), | |
| 101 | + | }, | |
| 102 | + | raw_output: "test output here".to_string(), | |
| 103 | + | filter: None, | |
| 104 | + | }; | |
| 105 | + | ||
| 106 | + | let id = db::insert_test_run(&pool, &run).await.unwrap(); | |
| 107 | + | assert!(id > 0); | |
| 108 | + | ||
| 109 | + | let latest = db::get_latest_test_run(&pool, "mnw").await.unwrap(); | |
| 110 | + | assert!(latest.is_some()); | |
| 111 | + | let latest = latest.unwrap(); | |
| 112 | + | assert!(latest.passed); | |
| 113 | + | assert_eq!(latest.summary.total_passed, Some(759)); | |
| 114 | + | assert_eq!(latest.summary.steps.len(), 2); | |
| 115 | + | assert_eq!(latest.raw_output, "test output here"); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | #[tokio::test] | |
| 119 | + | async fn test_history_excludes_other_targets() { | |
| 120 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 121 | + | ||
| 122 | + | for target in &["mnw", "other"] { | |
| 123 | + | let run = TestRun { | |
| 124 | + | id: None, | |
| 125 | + | target: target.to_string(), | |
| 126 | + | started_at: "2026-03-10T00:00:00Z".to_string(), | |
| 127 | + | finished_at: None, | |
| 128 | + | duration_secs: None, | |
| 129 | + | exit_code: None, | |
| 130 | + | passed: true, | |
| 131 | + | summary: TestSummary { steps: vec![], total_passed: None, total_failed: None }, | |
| 132 | + | raw_output: String::new(), | |
| 133 | + | filter: None, | |
| 134 | + | }; | |
| 135 | + | db::insert_test_run(&pool, &run).await.unwrap(); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | let mnw_only = db::get_test_history(&pool, Some("mnw"), 10).await.unwrap(); | |
| 139 | + | assert_eq!(mnw_only.len(), 1); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[tokio::test] | |
| 143 | + | async fn prune_removes_old_records() { | |
| 144 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 145 | + | ||
| 146 | + | // Insert an old health check (60 days ago) | |
| 147 | + | let old = HealthSnapshot { | |
| 148 | + | id: None, | |
| 149 | + | target: "mnw".to_string(), | |
| 150 | + | status: HealthStatus::Operational, | |
| 151 | + | checked_at: (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339(), | |
| 152 | + | response_time_ms: 100, | |
| 153 | + | details: None, | |
| 154 | + | error: None, | |
| 155 | + | }; | |
| 156 | + | db::insert_health_check(&pool, &old).await.unwrap(); | |
| 157 | + | ||
| 158 | + | // Insert a recent one | |
| 159 | + | let recent = HealthSnapshot { | |
| 160 | + | id: None, | |
| 161 | + | target: "mnw".to_string(), | |
| 162 | + | status: HealthStatus::Operational, | |
| 163 | + | checked_at: chrono::Utc::now().to_rfc3339(), | |
| 164 | + | response_time_ms: 100, | |
| 165 | + | details: None, | |
| 166 | + | error: None, | |
| 167 | + | }; | |
| 168 | + | db::insert_health_check(&pool, &recent).await.unwrap(); | |
| 169 | + | ||
| 170 | + | let (health_pruned, _) = db::prune_old_records(&pool, 30).await.unwrap(); | |
| 171 | + | assert_eq!(health_pruned, 1); | |
| 172 | + | ||
| 173 | + | let remaining = db::get_health_history(&pool, None, 10).await.unwrap(); | |
| 174 | + | assert_eq!(remaining.len(), 1); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | #[tokio::test] | |
| 178 | + | async fn parse_ci_output_integration() { | |
| 179 | + | use pom::checks::parse; | |
| 180 | + | ||
| 181 | + | let output = r#" | |
| 182 | + | ======================================== | |
| 183 | + | cargo check | |
| 184 | + | ======================================== | |
| 185 | + | ||
| 186 | + | Finished `dev` profile | |
| 187 | + | ||
| 188 | + | ======================================== | |
| 189 | + | cargo test --lib | |
| 190 | + | ======================================== | |
| 191 | + | ||
| 192 | + | running 45 tests | |
| 193 | + | test result: ok. 45 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.3s | |
| 194 | + | ||
| 195 | + | ======================================== | |
| 196 | + | CI Summary | |
| 197 | + | ======================================== | |
| 198 | + | ||
| 199 | + | PASS cargo check | |
| 200 | + | PASS cargo test --lib | |
| 201 | + | PASS cargo clippy | |
| 202 | + | ||
| 203 | + | All steps passed. | |
| 204 | + | "#; | |
| 205 | + | ||
| 206 | + | let summary = parse::parse_ci_output(output); | |
| 207 | + | assert_eq!(summary.steps.len(), 3); | |
| 208 | + | assert!(summary.steps.iter().all(|s| s.passed)); | |
| 209 | + | assert_eq!(summary.total_passed, Some(45)); | |
| 210 | + | assert_eq!(summary.total_failed, Some(0)); | |
| 211 | + | } |