Skip to main content

max / pom

Initial commit: CLI + MCP server + serve daemon mode Health checks, SSH test orchestration, SQLite history, MCP tools. Serve mode runs health checks on interval with graceful shutdown. Deploy infrastructure for astra (aarch64) and hetzner (x86_64). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-10 03:15 UTC
Commit: 0d08f703430fc14915950339d1b8869371c17a45
20 files changed, +2515 insertions, -0 deletions
A .gitignore +4
@@ -0,0 +1,4 @@
1 + /target/
2 + *.db
3 + *.db-wal
4 + *.db-shm
A .mcp.json +8
@@ -0,0 +1,8 @@
1 + {
2 + "mcpServers": {
3 + "pom": {
4 + "command": "/Users/max/Git/active/pom/target/release/pom",
5 + "args": []
6 + }
7 + }
8 + }
A Cargo.lock +500
@@ -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
A Cargo.toml +46
@@ -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
A pom.toml +15
@@ -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 + }
A src/config.rs +107
@@ -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 + }
A src/db.rs +303
@@ -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 + }
A src/lib.rs +5
@@ -0,0 +1,5 @@
1 + pub mod checks;
2 + pub mod config;
3 + pub mod db;
4 + pub mod tools;
5 + pub mod types;
A src/main.rs +473
@@ -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 &params.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(&params.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(&params.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, &params.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 + }
A src/types.rs +88
@@ -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 + }