Skip to main content

max / makenotwork

sando: typed gate outcomes and observability scaffolding Replace the daemon's stringly-typed gate/deploy/event surface with a typed graph: new domain/outcome/classify/lib/live_log modules, two migrations (003_typed_outcomes, 004_typed_deploy_outcomes), and the events/gates/routes/topology/build/deploy callsites rewired through the new types. TUI consumes the richer payloads. Captures the design in plans/observability.md and the prod work in plans/session-3-prod.md.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-03 22:15 UTC
Signed with PGP, not checked
Commit: d972fe5779a43128a662f7a7791fbb3cca8db49b
Parent: f076692
23 files changed, +2910 insertions, -342 deletions
@@ -61,8 +61,12 @@
61 61 ]
62 62 [[tier.node]]
63 63 name = "prod-1"
64 - ssh_target = "deploy@prod-1.makenot.work"
64 + # Tailnet name; port 2200 supplied via /srv/sando/.ssh/config Host block.
65 + # Service user is "makenotwork" (pre-existing on prod), not "deploy" — chose
66 + # not to chown 885M of backups + redo postgres peer auth for a cosmetic rename.
67 + ssh_target = "makenotwork@alpha-west-1"
65 68 release_root = "/opt/mnw"
69 + service_name = "makenotwork.service"
66 70
67 71 # ---- C: prod-2 (declared, not yet provisioned) ----
68 72 [[tier]]
@@ -1623,6 +1623,7 @@
1623 1623 "metrics",
1624 1624 "metrics-exporter-prometheus",
1625 1625 "reqwest",
1626 + "semver",
1626 1627 "serde",
1627 1628 "serde_json",
1628 1629 "sqlx",
@@ -1647,6 +1648,16 @@
1647 1648 source = "registry+https://github.com/rust-lang/crates.io-index"
1648 1649 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1649 1650
1651 + [[package]]
1652 + name = "semver"
1653 + version = "1.0.28"
1654 + source = "registry+https://github.com/rust-lang/crates.io-index"
1655 + checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1656 + dependencies = [
1657 + "serde",
1658 + "serde_core",
1659 + ]
1660 +
1650 1661 [[package]]
1651 1662 name = "serde"
1652 1663 version = "1.0.228"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "sando-daemon"
3 - version = "0.1.0"
3 + version = "0.2.0"
4 4 edition = "2024"
5 5 license = "MIT"
6 6
@@ -22,6 +22,7 @@
22 22 anyhow = "1.0.102"
23 23 thiserror = "2.0.18"
24 24 chrono = { version = "0.4", features = ["serde"] }
25 + semver = { version = "1.0", features = ["serde"] }
25 26
26 27 [dev-dependencies]
27 28 tempfile = "3.20"
@@ -2,6 +2,15 @@
2 2 # It is not intended for manual editing.
3 3 version = 4
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 +
5 14 [[package]]
6 15 name = "allocator-api2"
7 16 version = "0.2.21"
@@ -23,6 +32,15 @@
23 32 source = "registry+https://github.com/rust-lang/crates.io-index"
24 33 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
25 34
35 + [[package]]
36 + name = "atoi"
37 + version = "2.0.0"
38 + source = "registry+https://github.com/rust-lang/crates.io-index"
39 + checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
40 + dependencies = [
41 + "num-traits",
42 + ]
43 +
26 44 [[package]]
27 45 name = "atomic-waker"
28 46 version = "1.1.2"
@@ -35,17 +53,93 @@
35 53 source = "registry+https://github.com/rust-lang/crates.io-index"
36 54 checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
37 55
56 + [[package]]
57 + name = "axum"
58 + version = "0.8.9"
59 + source = "registry+https://github.com/rust-lang/crates.io-index"
60 + checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
61 + dependencies = [
62 + "axum-core",
63 + "axum-macros",
64 + "base64",
65 + "bytes",
66 + "form_urlencoded",
67 + "futures-util",
68 + "http",
69 + "http-body",
70 + "http-body-util",
71 + "hyper",
72 + "hyper-util",
73 + "itoa",
74 + "matchit",
75 + "memchr",
76 + "mime",
77 + "percent-encoding",
78 + "pin-project-lite",
79 + "serde_core",
80 + "serde_json",
81 + "serde_path_to_error",
82 + "serde_urlencoded",
83 + "sha1",
84 + "sync_wrapper",
85 + "tokio",
86 + "tokio-tungstenite 0.29.0",
87 + "tower",
88 + "tower-layer",
89 + "tower-service",
90 + "tracing",
91 + ]
92 +
93 + [[package]]
94 + name = "axum-core"
95 + version = "0.5.6"
96 + source = "registry+https://github.com/rust-lang/crates.io-index"
97 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
98 + dependencies = [
99 + "bytes",
100 + "futures-core",
101 + "http",
102 + "http-body",
103 + "http-body-util",
104 + "mime",
105 + "pin-project-lite",
106 + "sync_wrapper",
107 + "tower-layer",
108 + "tower-service",
109 + "tracing",
110 + ]
111 +
112 + [[package]]
113 + name = "axum-macros"
114 + version = "0.5.1"
115 + source = "registry+https://github.com/rust-lang/crates.io-index"
116 + checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
117 + dependencies = [
118 + "proc-macro2",
119 + "quote",
120 + "syn",
121 + ]
122 +
38 123 [[package]]
39 124 name = "base64"
40 125 version = "0.22.1"
41 126 source = "registry+https://github.com/rust-lang/crates.io-index"
42 127 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
43 128
129 + [[package]]
130 + name = "base64ct"
131 + version = "1.8.3"
132 + source = "registry+https://github.com/rust-lang/crates.io-index"
133 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
134 +
44 135 [[package]]
45 136 name = "bitflags"
46 137 version = "2.11.1"
47 138 source = "registry+https://github.com/rust-lang/crates.io-index"
48 139 checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
140 + dependencies = [
141 + "serde_core",
142 + ]
49 143
50 144 [[package]]
51 145 name = "block-buffer"
@@ -118,8 +212,10 @@
118 212 checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
119 213 dependencies = [
120 214 "iana-time-zone",
215 + "js-sys",
121 216 "num-traits",
122 217 "serde",
218 + "wasm-bindgen",
123 219 "windows-link",
124 220 ]
125 221
@@ -137,6 +233,21 @@
137 233 "static_assertions",
138 234 ]
139 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 +
140 251 [[package]]
141 252 name = "core-foundation-sys"
142 253 version = "0.8.7"
@@ -152,6 +263,45 @@
152 263 "libc",
153 264 ]
154 265
266 + [[package]]
267 + name = "crc"
268 + version = "3.4.0"
269 + source = "registry+https://github.com/rust-lang/crates.io-index"
270 + checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
271 + dependencies = [
272 + "crc-catalog",
273 + ]
274 +
275 + [[package]]
276 + name = "crc-catalog"
277 + version = "2.5.0"
278 + source = "registry+https://github.com/rust-lang/crates.io-index"
279 + checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
280 +
281 + [[package]]
282 + name = "crossbeam-epoch"
283 + version = "0.9.18"
284 + source = "registry+https://github.com/rust-lang/crates.io-index"
285 + checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
286 + dependencies = [
287 + "crossbeam-utils",
288 + ]
289 +
290 + [[package]]
291 + name = "crossbeam-queue"
292 + version = "0.3.12"
293 + source = "registry+https://github.com/rust-lang/crates.io-index"
294 + checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
295 + dependencies = [
296 + "crossbeam-utils",
297 + ]
298 +
299 + [[package]]
300 + name = "crossbeam-utils"
301 + version = "0.8.21"
302 + source = "registry+https://github.com/rust-lang/crates.io-index"
303 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
304 +
155 305 [[package]]
156 306 name = "crossterm"
157 307 version = "0.28.1"
@@ -227,6 +377,17 @@
227 377 source = "registry+https://github.com/rust-lang/crates.io-index"
228 378 checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
229 379
380 + [[package]]
381 + name = "der"
382 + version = "0.7.10"
383 + source = "registry+https://github.com/rust-lang/crates.io-index"
384 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
385 + dependencies = [
386 + "const-oid",
387 + "pem-rfc7468",
388 + "zeroize",
389 + ]
390 +
230 391 [[package]]
231 392 name = "digest"
232 393 version = "0.10.7"
@@ -234,7 +395,9 @@
234 395 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
235 396 dependencies = [
236 397 "block-buffer",
398 + "const-oid",
237 399 "crypto-common",
400 + "subtle",
238 401 ]
239 402
240 403 [[package]]
@@ -248,11 +411,20 @@
248 411 "syn",
249 412 ]
250 413
414 + [[package]]
415 + name = "dotenvy"
416 + version = "0.15.7"
417 + source = "registry+https://github.com/rust-lang/crates.io-index"
418 + checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
419 +
251 420 [[package]]
252 421 name = "either"
253 422 version = "1.16.0"
254 423 source = "registry+https://github.com/rust-lang/crates.io-index"
255 424 checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
425 + dependencies = [
426 + "serde",
427 + ]
256 428
257 429 [[package]]
258 430 name = "equivalent"
@@ -270,18 +442,68 @@
270 442 "windows-sys 0.61.2",
271 443 ]
272 444
445 + [[package]]
446 + name = "etcetera"
447 + version = "0.8.0"
448 + source = "registry+https://github.com/rust-lang/crates.io-index"
449 + checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
450 + dependencies = [
451 + "cfg-if",
452 + "home",
453 + "windows-sys 0.48.0",
454 + ]
455 +
456 + [[package]]
457 + name = "event-listener"
458 + version = "5.4.1"
459 + source = "registry+https://github.com/rust-lang/crates.io-index"
460 + checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
461 + dependencies = [
462 + "concurrent-queue",
463 + "parking",
464 + "pin-project-lite",
465 + ]
466 +
467 + [[package]]
468 + name = "evmap"
469 + version = "11.0.0"
470 + source = "registry+https://github.com/rust-lang/crates.io-index"
471 + checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8"
472 + dependencies = [
473 + "hashbag",
474 + "left-right",
475 + "smallvec",
476 + ]
477 +
273 478 [[package]]
274 479 name = "find-msvc-tools"
275 480 version = "0.1.9"
276 481 source = "registry+https://github.com/rust-lang/crates.io-index"
277 482 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
278 483
484 + [[package]]
485 + name = "flume"
486 + version = "0.11.1"
487 + source = "registry+https://github.com/rust-lang/crates.io-index"
488 + checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
489 + dependencies = [
490 + "futures-core",
491 + "futures-sink",
492 + "spin",
493 + ]
494 +
279 495 [[package]]
280 496 name = "foldhash"
281 497 version = "0.1.5"
282 498 source = "registry+https://github.com/rust-lang/crates.io-index"
283 499 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
284 500
501 + [[package]]
502 + name = "foldhash"
503 + version = "0.2.0"
504 + source = "registry+https://github.com/rust-lang/crates.io-index"
505 + checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
506 +
285 507 [[package]]
286 508 name = "form_urlencoded"
287 509 version = "1.2.2"
@@ -298,6 +520,7 @@
298 520 checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
299 521 dependencies = [
300 522 "futures-core",
523 + "futures-sink",
301 524 ]
302 525
303 526 [[package]]
@@ -306,6 +529,34 @@
306 529 source = "registry+https://github.com/rust-lang/crates.io-index"
307 530 checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
308 531
532 + [[package]]
533 + name = "futures-executor"
534 + version = "0.3.32"
535 + source = "registry+https://github.com/rust-lang/crates.io-index"
536 + checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
537 + dependencies = [
538 + "futures-core",
539 + "futures-task",
540 + "futures-util",
541 + ]
542 +
543 + [[package]]
544 + name = "futures-intrusive"
545 + version = "0.5.0"
546 + source = "registry+https://github.com/rust-lang/crates.io-index"
547 + checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
548 + dependencies = [
549 + "futures-core",
550 + "lock_api",
551 + "parking_lot",
552 + ]
553 +
554 + [[package]]
555 + name = "futures-io"
556 + version = "0.3.32"
557 + source = "registry+https://github.com/rust-lang/crates.io-index"
558 + checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
559 +
309 560 [[package]]
310 561 name = "futures-sink"
311 562 version = "0.3.32"
@@ -325,12 +576,29 @@
325 576 checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
326 577 dependencies = [
327 578 "futures-core",
579 + "futures-io",
328 580 "futures-sink",
329 581 "futures-task",
582 + "memchr",
330 583 "pin-project-lite",
331 584 "slab",
332 585 ]
333 586
587 + [[package]]
588 + name = "generator"
589 + version = "0.8.9"
590 + source = "registry+https://github.com/rust-lang/crates.io-index"
591 + checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
592 + dependencies = [
593 + "cc",
594 + "cfg-if",
595 + "libc",
596 + "log",
597 + "rustversion",
598 + "windows-link",
599 + "windows-result",
600 + ]
601 +
334 602 [[package]]
335 603 name = "generic-array"
336 604 version = "0.14.7"
@@ -368,6 +636,12 @@
368 636 "wasm-bindgen",
369 637 ]
370 638
639 + [[package]]
640 + name = "hashbag"
641 + version = "0.1.13"
642 + source = "registry+https://github.com/rust-lang/crates.io-index"
643 + checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064"
644 +
371 645 [[package]]
372 646 name = "hashbrown"
373 647 version = "0.15.5"
@@ -376,7 +650,31 @@
376 650 dependencies = [
377 651 "allocator-api2",
378 652 "equivalent",
379 - "foldhash",
653 + "foldhash 0.1.5",
654 + ]
655 +
656 + [[package]]
657 + name = "hashbrown"
658 + version = "0.16.1"
659 + source = "registry+https://github.com/rust-lang/crates.io-index"
660 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
661 + dependencies = [
662 + "foldhash 0.2.0",
663 + ]
664 +
665 + [[package]]
666 + name = "hashbrown"
667 + version = "0.17.1"
668 + source = "registry+https://github.com/rust-lang/crates.io-index"
669 + checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
670 +
671 + [[package]]
672 + name = "hashlink"
673 + version = "0.10.0"
674 + source = "registry+https://github.com/rust-lang/crates.io-index"
675 + checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
676 + dependencies = [
677 + "hashbrown 0.15.5",
380 678 ]
381 679
382 680 [[package]]
@@ -385,6 +683,39 @@
385 683 source = "registry+https://github.com/rust-lang/crates.io-index"
386 684 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
387 685
686 + [[package]]
687 + name = "hex"
688 + version = "0.4.3"
689 + source = "registry+https://github.com/rust-lang/crates.io-index"
690 + checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
691 +
692 + [[package]]
693 + name = "hkdf"
694 + version = "0.12.4"
695 + source = "registry+https://github.com/rust-lang/crates.io-index"
696 + checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
697 + dependencies = [
698 + "hmac",
699 + ]
700 +
701 + [[package]]
702 + name = "hmac"
703 + version = "0.12.1"
704 + source = "registry+https://github.com/rust-lang/crates.io-index"
705 + checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
706 + dependencies = [
707 + "digest",
708 + ]
709 +
710 + [[package]]
711 + name = "home"
712 + version = "0.5.12"
713 + source = "registry+https://github.com/rust-lang/crates.io-index"
714 + checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
715 + dependencies = [
716 + "windows-sys 0.61.2",
717 + ]
718 +
388 719 [[package]]
389 720 name = "http"
390 721 version = "1.4.0"
@@ -424,6 +755,12 @@
424 755 source = "registry+https://github.com/rust-lang/crates.io-index"
425 756 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
426 757
758 + [[package]]
759 + name = "httpdate"
760 + version = "1.0.3"
761 + source = "registry+https://github.com/rust-lang/crates.io-index"
762 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
763 +
427 764 [[package]]
428 765 name = "hyper"
429 766 version = "1.9.0"
@@ -437,6 +774,7 @@
437 774 "http",
438 775 "http-body",
439 776 "httparse",
777 + "httpdate",
440 778 "itoa",
441 779 "pin-project-lite",
442 780 "smallvec",
@@ -616,6 +954,16 @@
616 954 "icu_properties",
617 955 ]
618 956
957 + [[package]]
958 + name = "indexmap"
959 + version = "2.14.0"
960 + source = "registry+https://github.com/rust-lang/crates.io-index"
961 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
962 + dependencies = [
963 + "equivalent",
964 + "hashbrown 0.17.1",
965 + ]
966 +
619 967 [[package]]
620 968 name = "indoc"
Lines truncated
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "sando-tui"
3 - version = "0.1.0"
3 + version = "0.2.0"
4 4 edition = "2024"
5 5 license = "MIT"
6 6
@@ -9,6 +9,7 @@
9 9 path = "src/main.rs"
10 10
11 11 [dependencies]
12 + sando-daemon = { path = "../daemon" }
12 13 ratatui = "0.29"
13 14 crossterm = "0.28"
14 15 tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
@@ -6,6 +6,7 @@
6 6
7 7 use crate::config::Config;
8 8 use crate::deploy;
9 + use crate::domain::{GitSha, TierId, Version};
9 10 use crate::gates::{self, GateCtx};
10 11 use crate::git;
11 12 use crate::topology::Topology;
@@ -18,8 +19,8 @@
18 19
19 20 #[derive(Debug, Clone)]
20 21 pub struct BuildArtifact {
21 - pub version: String,
22 - pub git_sha: String,
22 + pub version: Version,
23 + pub git_sha: GitSha,
23 24 pub worktree: PathBuf,
24 25 /// One entry per `cfg.bin_names` in declared order. First is the primary
25 26 /// (referenced by the systemd unit's ExecStart). Paths are inside the
@@ -31,12 +32,12 @@
31 32 pool: SqlitePool,
32 33 cfg: Arc<Config>,
33 34 topo: Arc<Topology>,
34 - sha: String,
35 + sha: GitSha,
35 36 events: crate::events::EventTx,
36 37 ) -> Result<BuildArtifact> {
37 - let worktree = cfg.workdir.join(&sha);
38 + let worktree = cfg.workdir.join(sha.as_str());
38 39 let bare = PathBuf::from(&topo.repo.bare_path);
39 - git::checkout_worktree(&bare, &sha, &worktree).await?;
40 + git::checkout_worktree(&bare, sha.as_str(), &worktree).await?;
40 41
41 42 let server_dir = worktree.join("server");
42 43 let version = read_pkg_version(&server_dir.join("Cargo.toml")).await
@@ -53,7 +54,7 @@
53 54 .current_dir(&server_dir)
54 55 .kill_on_drop(true);
55 56 if let Some(scratch_url) = cfg.scratch_db_url.as_deref() {
56 - tracing::info!(sha = %sha, "preparing scratch DB schema for sqlx compile-time checks");
57 + tracing::info!(sha = %sha.as_str(), "preparing scratch DB schema for sqlx compile-time checks");
57 58 crate::gates::reset_scratch(scratch_url).await
58 59 .context("scratch DB reset before build")?;
59 60 crate::gates::run_migrator(scratch_url, &server_dir.join("migrations")).await
@@ -124,7 +125,7 @@
124 125 pool: SqlitePool,
125 126 cfg: Arc<Config>,
126 127 topo: Arc<Topology>,
127 - sha: String,
128 + sha: GitSha,
128 129 events: crate::events::EventTx,
129 130 ) -> Result<()> {
130 131 let art = run(pool.clone(), cfg.clone(), topo.clone(), sha, events.clone()).await?;
@@ -149,13 +150,13 @@
149 150 .execute(&pool)
150 151 .await?;
151 152
152 - let host = topo.tiers.iter().find(|t| t.name == "host")
153 + let host = topo.tiers.iter().find(|t| t.name.as_str() == "host")
153 154 .context("topology has no `host` tier")?;
154 155
155 156 let ctx = GateCtx {
156 157 pool: pool.clone(),
157 158 cfg: cfg.clone(),
158 - tier: "host".to_string(),
159 + tier: TierId::new("host"),
159 160 version: art.version.clone(),
160 161 worktree: art.worktree.clone(),
161 162 events: events.clone(),
@@ -183,7 +184,7 @@
183 184 Ok(())
184 185 }
185 186
186 - async fn read_pkg_version(cargo_toml: &Path) -> Result<String> {
187 + async fn read_pkg_version(cargo_toml: &Path) -> Result<Version> {
187 188 let raw = tokio::fs::read_to_string(cargo_toml).await?;
188 189 let parsed: toml::Value = toml::from_str(&raw)?;
189 190 let v = parsed
@@ -191,7 +192,7 @@
191 192 .and_then(|p| p.get("version"))
192 193 .and_then(|v| v.as_str())
193 194 .context("package.version not found")?;
194 - Ok(v.to_string())
195 + Version::parse(v).with_context(|| format!("parsing package.version `{v}`"))
195 196 }
196 197
197 198 fn tail(buf: &[u8], max: usize) -> String {
@@ -66,4 +66,19 @@
66 66 .with_context(|| format!("reading daemon config at {path}"))?;
67 67 Ok(toml::from_str(&raw)?)
68 68 }
69 +
70 + #[cfg(test)]
71 + pub fn for_tests() -> Self {
72 + Self {
73 + listen: "127.0.0.1:0".into(),
74 + db_path: PathBuf::from(":memory:"),
75 + topology_path: PathBuf::from("/tmp/sando-test-topology.toml"),
76 + workdir: PathBuf::from("/tmp/sando-test-workdir"),
77 + release_root: PathBuf::from("/tmp/sando-test-release-root"),
78 + scratch_db_url: None,
79 + bin_names: vec!["server".into()],
80 + logs_root: PathBuf::from("/tmp/sando-test-logs"),
81 + release_contents: Vec::new(),
82 + }
83 + }
69 84 }
@@ -38,10 +38,10 @@
38 38
39 39 pub async fn deploy_local(
40 40 release_root: &Path,
41 - version: &str,
41 + version: &crate::domain::Version,
42 42 binaries: &[PathBuf],
43 43 ) -> Result<PathBuf> {
44 - let release_dir = release_root.join("releases").join(version);
44 + let release_dir = release_root.join("releases").join(version.to_string());
45 45 tokio::fs::create_dir_all(&release_dir).await?;
46 46 for binary in binaries {
47 47 let name = binary.file_name()
@@ -272,7 +272,7 @@
272 272
273 273 let staged = deploy_local(
274 274 &release_root,
275 - "0.8.12",
275 + &"0.8.12".parse().unwrap(),
276 276 &[primary.clone(), admin.clone()],
277 277 )
278 278 .await
@@ -303,10 +303,10 @@
303 303 let release_root = root.join("rr");
304 304 tokio::fs::create_dir_all(&release_root).await.unwrap();
305 305
306 - deploy_local(&release_root, "0.1.0", &[bin.clone()]).await.unwrap();
306 + deploy_local(&release_root, &"0.1.0".parse().unwrap(), &[bin.clone()]).await.unwrap();
307 307 // Rewrite source then deploy 0.2.0.
308 308 tokio::fs::write(&bin, b"V2").await.unwrap();
309 - deploy_local(&release_root, "0.2.0", &[bin.clone()]).await.unwrap();
309 + deploy_local(&release_root, &"0.2.0".parse().unwrap(), &[bin.clone()]).await.unwrap();
310 310
311 311 // Both versions present on disk.
312 312 assert!(release_root.join("releases/0.1.0/server").exists());
@@ -5,8 +5,10 @@
5 5 //! subscribes to the bus and forwards each envelope to the connected TUI as
6 6 //! a JSON text frame.
7 7
8 + use crate::domain::{GateKind, GateRunId, GitSha, NodeId, TierId, Version};
9 + use crate::outcome::{DeployFailureKind, GateOutcome};
8 10 use chrono::{DateTime, Utc};
9 - use serde::Serialize;
11 + use serde::{Deserialize, Serialize};
10 12 use tokio::sync::broadcast;
11 13
12 14 /// Capacity of the broadcast channel. Slow subscribers that fall behind by
@@ -16,32 +18,65 @@
16 18
17 19 pub type EventTx = broadcast::Sender<EventEnvelope>;
18 20
19 - #[derive(Clone, Debug, Serialize)]
21 + #[derive(Clone, Debug, Serialize, Deserialize)]
20 22 pub struct EventEnvelope {
21 23 pub at: DateTime<Utc>,
22 24 #[serde(flatten)]
23 25 pub event: Event,
24 26 }
25 27
26 - #[derive(Clone, Debug, Serialize)]
28 + #[derive(Clone, Debug, Serialize, Deserialize)]
27 29 #[serde(tag = "kind", rename_all = "snake_case")]
28 30 pub enum Event {
29 31 /// A /rebuild was accepted (post-receive hook or operator).
30 - RebuildRequested { sha: String },
32 + RebuildRequested { sha: GitSha },
31 33 /// A previous in-flight build was aborted because a newer /rebuild arrived.
32 - BuildAborted { sha_aborted: String },
33 - BuildStart { sha: String, version: String },
34 - BuildOk { sha: String, version: String, elapsed_s: u64 },
35 - BuildFailed { sha: String, version: String, elapsed_s: u64 },
36 - GateStart { tier: String, version: String, gate: String },
37 - GateDone { tier: String, version: String, gate: String, passed: bool },
38 - DeployStart { tier: String, node: String, version: String },
39 - DeployOk { tier: String, node: String, version: String },
40 - DeployFailed { tier: String, node: String, version: String, error: String },
41 - PromoteComplete { tier: String, version: String },
42 - Rollback { tier: String, from: String, to: String },
34 + BuildAborted { sha_aborted: GitSha },
35 + BuildStart { sha: GitSha, version: Version },
36 + BuildOk { sha: GitSha, version: Version, elapsed_s: u64 },
37 + BuildFailed { sha: GitSha, version: Version, elapsed_s: u64 },
38 + GateStart {
39 + run_id: GateRunId,
40 + tier: TierId,
41 + version: Version,
42 + gate: GateKind,
43 + },
44 + /// Chunk of combined stdout+stderr from a gate that's currently running.
45 + /// `run_id` correlates back to the `GateStart` for the same gate run; the
46 + /// TUI uses it to group chunks if it wants a per-run buffer. `seq` is a
47 + /// monotonic counter scoped to one run (resets across runs). `text` is a
48 + /// UTF-8-lossy slice of bytes — chunks reflect tokio read boundaries, not
49 + /// line boundaries; the on-disk log at `outcome.log_ref` is the full,
50 + /// byte-exact stream.
51 + GateLogChunk {
52 + run_id: GateRunId,
53 + seq: u32,
54 + text: String,
55 + },
56 + /// `passed` is a shadow field. `outcome` is the source of truth and
57 + /// carries classification, blocker variants, and the log_ref.
58 + GateDone {
59 + run_id: GateRunId,
60 + tier: TierId,
61 + version: Version,
62 + gate: GateKind,
63 + passed: bool,
64 + outcome: GateOutcome,
65 + },
66 + DeployStart { tier: TierId, node: NodeId, version: Version },
67 + DeployOk { tier: TierId, node: NodeId, version: Version },
68 + DeployFailed {
69 + tier: TierId,
70 + node: NodeId,
71 + version: Version,
72 + failure: DeployFailureKind,
73 + },
74 + PromoteComplete { tier: TierId, version: Version },
75 + Rollback { tier: TierId, from: Version, to: Version },
76 + /// `source` is an ssh URL, kept freeform on purpose — it's a transport
77 + /// detail, not a domain identifier.
43 78 BackupFetched { source: String, byte_size: i64 },
44 - ManualConfirm { tier: String, version: String },
79 + ManualConfirm { tier: TierId, version: Version },
45 80 }
46 81
47 82 pub fn channel() -> EventTx {
@@ -67,7 +102,7 @@
67 102 // to `.unwrap()` someday, every build/deploy site will start
68 103 // crashing.
69 104 let tx = channel();
70 - emit(&tx, Event::RebuildRequested { sha: "abc".into() });
105 + emit(&tx, Event::RebuildRequested { sha: GitSha::parse("abc1234").unwrap() });
71 106 emit(&tx, Event::BackupFetched { source: "x".into(), byte_size: 1 });
72 107 }
73 108
@@ -75,12 +110,15 @@
75 110 async fn emit_reaches_a_subscriber() {
76 111 let tx = channel();
77 112 let mut rx = tx.subscribe();
78 - emit(&tx, Event::PromoteComplete { tier: "a".into(), version: "0.8.12".into() });
113 + emit(&tx, Event::PromoteComplete {
114 + tier: TierId::new("a"),
115 + version: "0.8.12".parse().unwrap(),
116 + });
79 117 let env = rx.recv().await.expect("envelope");
80 118 match env.event {
81 119 Event::PromoteComplete { tier, version } => {
82 - assert_eq!(tier, "a");
83 - assert_eq!(version, "0.8.12");
120 + assert_eq!(tier.as_str(), "a");
121 + assert_eq!(version.to_string(), "0.8.12");
84 122 }
85 123 _ => panic!("wrong event kind"),
86 124 }
@@ -93,9 +131,10 @@
93 131 let env = EventEnvelope {
94 132 at: Utc::now(),
95 133 event: Event::GateStart {
96 - tier: "host".into(),
97 - version: "0.8.12".into(),
98 - gate: "cargo_test".into(),
134 + run_id: GateRunId(42),
135 + tier: TierId::new("host"),
136 + version: "0.8.12".parse().unwrap(),
137 + gate: GateKind::CargoTest,
99 138 },
100 139 };
101 140 let s = serde_json::to_string(&env).unwrap();
@@ -115,7 +154,9 @@
115 154 let tx = channel();
116 155 let mut rx = tx.subscribe();
117 156 for i in 0..(CAPACITY + 10) {
118 - emit(&tx, Event::RebuildRequested { sha: format!("{i}") });
157 + // 7+ hex chars satisfy GitSha::parse; pad i into that shape.
158 + let sha = GitSha::parse(&format!("{i:0>7x}")).unwrap();
159 + emit(&tx, Event::RebuildRequested { sha });
119 160 }
120 161 let err = rx.recv().await.expect_err("expected Lagged");
121 162 match err {
@@ -3,8 +3,12 @@
3 3 //! human-readable reason). Outcomes are persisted to `gate_runs` so /state
4 4 //! and the TUI can show them.
5 5
6 + use crate::classify;
6 7 use crate::config::Config;
8 + use crate::domain::{GateKind, GateRunId, TierId, Version};
7 9 use crate::events::{self, Event, EventTx};
10 + use crate::live_log::LiveLog;
11 + use crate::outcome::{GateBlocker, GateFailure, GateOutcome, LogRef, PassNote};
8 12 use crate::topology::Gate;
9 13 use anyhow::Result;
10 14 use chrono::Utc;
@@ -17,22 +21,16 @@
17 21 pub struct GateCtx {
18 22 pub pool: SqlitePool,
19 23 pub cfg: Arc<Config>,
20 - pub tier: String,
21 - pub version: String,
24 + pub tier: TierId,
25 + pub version: Version,
22 26 pub worktree: PathBuf,
23 27 pub events: EventTx,
24 28 }
25 29
26 - #[derive(Debug, Clone)]
27 - pub struct GateOutcome {
28 - pub passed: bool,
29 - pub detail: Option<String>,
30 - }
31 -
32 30 /// Run a single gate end-to-end: insert the in-flight row, execute the gate,
33 31 /// update the row with the outcome. Returns the outcome for the caller.
34 32 pub async fn run(ctx: &GateCtx, gate: &Gate) -> Result<GateOutcome> {
35 - let kind = kind_str(gate);
33 + let kind = gate.kind();
36 34 let started_at = Utc::now().to_rfc3339();
37 35
38 36 let id: i64 = sqlx::query_scalar(
@@ -45,46 +43,63 @@
45 43 .bind(&started_at)
46 44 .fetch_one(&ctx.pool)
47 45 .await?;
46 + let run_id = GateRunId(id);
48 47
49 - tracing::info!(tier = %ctx.tier, version = %ctx.version, gate = kind, "gate start");
48 + tracing::info!(
49 + run_id = %run_id, tier = %ctx.tier, version = %ctx.version, gate = %kind,
50 + "gate start",
51 + );
50 52 events::emit(&ctx.events, Event::GateStart {
53 + run_id,
51 54 tier: ctx.tier.clone(),
52 55 version: ctx.version.clone(),
53 - gate: kind.into(),
56 + gate: kind,
54 57 });
55 58
56 59 let outcome = match gate {
57 - Gate::CargoTest => cargo_test(ctx).await,
60 + Gate::CargoTest => cargo_test(ctx, run_id).await,
58 61 Gate::MigrationDryRun => migration_dry_run(ctx).await,
59 - Gate::BootSmoke => boot_smoke(ctx).await,
62 + Gate::BootSmoke => boot_smoke(ctx, run_id).await,
60 63 Gate::BurnIn { hours } => burn_in(ctx, *hours).await,
61 64 Gate::ManualConfirm => manual_confirm(ctx).await,
62 65 };
63 66
64 - let outcome = outcome.unwrap_or_else(|e| GateOutcome {
65 - passed: false,
66 - detail: Some(format!("gate runner errored: {e}")),
67 - });
67 + let outcome = outcome.unwrap_or_else(|e| GateOutcome::failed(GateFailure::Unclassified {
68 + legacy_detail: Some(format!("gate runner errored: {e}")),
69 + }));
68 70
71 + // Migration 003 added status/outcome_json/log_ref. Until migration 004
72 + // drops them, we double-write the shadow `passed` and `detail` columns
73 + // so any consumer still reading the old schema keeps working.
74 + let outcome_json = serde_json::to_string(&outcome)
75 + .unwrap_or_else(|e| format!("{{\"_serialize_error\":{e:?}}}"));
69 76 sqlx::query(
70 - "UPDATE gate_runs SET finished_at = ?, passed = ?, detail = ? WHERE id = ?",
77 + "UPDATE gate_runs
78 + SET finished_at = ?, passed = ?, detail = ?,
79 + status = ?, outcome_json = ?, log_ref = ?
80 + WHERE id = ?",
71 81 )
72 82 .bind(Utc::now().to_rfc3339())
73 - .bind(outcome.passed as i64)
74 - .bind(outcome.detail.as_deref())
83 + .bind(outcome.legacy_passed() as i64)
84 + .bind(outcome.legacy_detail())
85 + .bind(outcome.status_str())
86 + .bind(&outcome_json)
87 + .bind(outcome.log_ref.as_ref().map(|l| l.as_str()))
75 88 .bind(id)
76 89 .execute(&ctx.pool)
77 90 .await?;
78 91
79 92 tracing::info!(
80 - tier = %ctx.tier, version = %ctx.version, gate = kind,
81 - passed = outcome.passed, "gate done",
93 + tier = %ctx.tier, version = %ctx.version, gate = %kind,
94 + status = outcome.status_str(), "gate done",
82 95 );
83 96 events::emit(&ctx.events, Event::GateDone {
97 + run_id,
84 98 tier: ctx.tier.clone(),
85 99 version: ctx.version.clone(),
86 - gate: kind.into(),
87 - passed: outcome.passed,
100 + gate: kind,
101 + passed: outcome.legacy_passed(),
102 + outcome: outcome.clone(),
88 103 });
89 104
90 105 Ok(outcome)
@@ -99,26 +114,16 @@
99 114 let mut all_ok = true;
100 115 for g in gates {
101 116 let o = run(ctx, g).await?;
102 - if !o.passed {
117 + if !o.legacy_passed() {
103 118 all_ok = false;
104 119 }
105 120 }
106 121 Ok(all_ok)
107 122 }
108 123
109 - fn kind_str(g: &Gate) -> &'static str {
110 - match g {
111 - Gate::CargoTest => "cargo_test",
112 - Gate::MigrationDryRun => "migration_dry_run",
113 - Gate::BootSmoke => "boot_smoke",
114 - Gate::BurnIn { .. } => "burn_in",
115 - Gate::ManualConfirm => "manual_confirm",
116 - }
117 - }
118 -
119 124 // ---- individual gate runners ----
120 125
121 - async fn cargo_test(ctx: &GateCtx) -> Result<GateOutcome> {
126 + async fn cargo_test(ctx: &GateCtx, run_id: GateRunId) -> Result<GateOutcome> {
122 127 let server_dir = ctx.worktree.join("server");
123 128 let mut cmd = Command::new("cargo");
124 129 // Match CI (`server/deploy/run-ci.sh`): `--features fast-tests` relaxes
@@ -128,6 +133,8 @@
128 133 // this in `server/src/constants.rs:87`.
129 134 cmd.args(["test", "--release", "--features", "fast-tests"])
130 135 .current_dir(&server_dir)
136 + .stdout(std::process::Stdio::piped())
137 + .stderr(std::process::Stdio::piped())
131 138 .kill_on_drop(true);
132 139 // Same online-mode rationale as the build step: sqlx query macros need a
133 140 // live DB to type-check against. The scratch DB is left in migrated state
@@ -144,27 +151,42 @@
144 151 .unwrap_or(scratch_url);
145 152 cmd.env("TEST_DATABASE_URL", test_url);
146 153 }
147 - let out = cmd.output().await?;
148 - persist_gate_log(ctx, "cargo_test", &out.stdout, &out.stderr).await;
149 - Ok(GateOutcome {
150 - passed: out.status.success(),
151 - detail: Some(combined_tail(&out.stdout, &out.stderr, 4_000)),
152 - })
154 + let started = std::time::Instant::now();
155 + let log_path = gate_log_path(ctx, GateKind::CargoTest);
156 + let log_ref = LogRef::new(&ctx.version, GateKind::CargoTest);
157 + let mut child = match cmd.spawn() {
158 + Ok(c) => c,
159 + Err(e) => {
160 + return Ok(GateOutcome::failed(GateFailure::SpawnFailed {
161 + message: e.to_string(),
162 + }).with_log_ref(log_ref));
163 + }
164 + };
165 + let (stdout_buf, stderr_buf, status) =
166 + stream_child_to_live_log(&mut child, ctx.events.clone(), run_id, log_path).await?;
167 + let duration_s = started.elapsed().as_secs() as u32;
168 + if status.success() {
169 + Ok(GateOutcome::passed(PassNote::TestsPassed { duration_s }).with_log_ref(log_ref))
170 + } else {
171 + let failure = classify::classify_cargo_test(&stdout_buf, &stderr_buf);
172 + Ok(GateOutcome::failed(failure).with_log_ref(log_ref))
173 + }
153 174 }
154 175
155 176 async fn migration_dry_run(ctx: &GateCtx) -> Result<GateOutcome> {
156 177 let mut log_buf: Vec<u8> = Vec::new();
178 + let log_ref = LogRef::new(&ctx.version, GateKind::MigrationDryRun);
157 179 let finish = |outcome: GateOutcome, buf: Vec<u8>| async move {
158 - persist_gate_log(ctx, "migration_dry_run", &buf, &[]).await;
180 + persist_gate_log(ctx, GateKind::MigrationDryRun, &buf, &[]).await;
159 181 outcome
160 182 };
161 183
162 184 let Some(db_url) = ctx.cfg.scratch_db_url.as_deref() else {
163 185 log_buf.extend_from_slice(b"scratch_db_url unset in daemon config\n");
164 - return Ok(finish(GateOutcome {
165 - passed: false,
166 - detail: Some("scratch_db_url unset in daemon config".into()),
167 - }, log_buf).await);
186 + return Ok(finish(
187 + GateOutcome::blocked(GateBlocker::ScratchDbUrlUnset).with_log_ref(log_ref),
188 + log_buf,
189 + ).await);
168 190 };
169 191
170 192 let backup: Option<(String,)> = sqlx::query_as(
@@ -174,23 +196,29 @@
174 196 .await?;
175 197 let Some((backup_path,)) = backup else {
176 198 log_buf.extend_from_slice(b"no backup fetched; call /backup/fetch first\n");
177 - return Ok(finish(GateOutcome {
178 - passed: false,
179 - detail: Some("no backup fetched; call /backup/fetch first".into()),
180 - }, log_buf).await);
199 + return Ok(finish(
200 + GateOutcome::blocked(GateBlocker::NoBackupAvailable).with_log_ref(log_ref),
201 + log_buf,
202 + ).await);
181 203 };
182 204
183 - log_buf.extend_from_slice(format!("---- reset_scratch ----\n").as_bytes());
205 + log_buf.extend_from_slice(b"---- reset_scratch ----\n");
184 206 if let Err(e) = reset_scratch(db_url).await {
185 207 let msg = format!("scratch reset: {e}");
186 208 log_buf.extend_from_slice(msg.as_bytes());
187 - return Ok(finish(GateOutcome { passed: false, detail: Some(msg) }, log_buf).await);
209 + return Ok(finish(
210 + GateOutcome::failed(GateFailure::RestoreFailed { reason: msg }).with_log_ref(log_ref),
211 + log_buf,
212 + ).await);
188 213 }
189 214 log_buf.extend_from_slice(format!("---- restore_dump ({backup_path}) ----\n").as_bytes());
190 215 if let Err(e) = restore_dump(db_url, &backup_path, &mut log_buf).await {
191 216 let msg = format!("restore: {e}");
192 217 log_buf.extend_from_slice(msg.as_bytes());
193 - return Ok(finish(GateOutcome { passed: false, detail: Some(msg) }, log_buf).await);
218 + return Ok(finish(
219 + GateOutcome::failed(GateFailure::RestoreFailed { reason: msg }).with_log_ref(log_ref),
220 + log_buf,
221 + ).await);
194 222 }
195 223
196 224 let migrations_dir = ctx.worktree.join("server").join("migrations");
@@ -199,12 +227,20 @@
199 227 Ok(()) => {
200 228 let detail = format!("restored {backup_path} + migrated");
201 229 log_buf.extend_from_slice(detail.as_bytes());
202 - Ok(finish(GateOutcome { passed: true, detail: Some(detail) }, log_buf).await)
230 + Ok(finish(
231 + GateOutcome::passed(PassNote::Migrated { backup_path: backup_path.clone() })
232 + .with_log_ref(log_ref),
233 + log_buf,
234 + ).await)
203 235 }
204 236 Err(e) => {
205 237 let err_s = e.to_string();
206 238 log_buf.extend_from_slice(err_s.as_bytes());
207 - Ok(finish(GateOutcome { passed: false, detail: Some(tail(err_s.as_bytes(), 4_000)) }, log_buf).await)
239 + let failure = classify::classify_migration_error(&err_s, None);
240 + Ok(finish(
241 + GateOutcome::failed(failure).with_log_ref(log_ref),
242 + log_buf,
243 + ).await)
208 244 }
209 245 }
210 246 }
@@ -271,7 +307,7 @@
271 307 format!("'{}'", s.replace('\'', "'\\''"))
272 308 }
273 309
274 - async fn boot_smoke(ctx: &GateCtx) -> Result<GateOutcome> {
310 + async fn boot_smoke(ctx: &GateCtx, run_id: GateRunId) -> Result<GateOutcome> {
275 311 let bin: Option<(String,)> = sqlx::query_as(
276 312 "SELECT artifact_path FROM versions WHERE version = ?",
277 313 )
@@ -279,7 +315,9 @@
279 315 .fetch_optional(&ctx.pool)
280 316 .await?;
281 317 let Some((bin,)) = bin else {
282 - return Ok(GateOutcome { passed: false, detail: Some("no artifact for version".into()) });
318 + return Ok(GateOutcome::blocked(GateBlocker::ArtifactMissing {
319 + version: ctx.version.clone(),
320 + }));
283 321 };
284 322
285 323 // Lowest-bar smoke: start the binary and verify it stays up for a few
@@ -302,34 +340,32 @@
302 340 if let Some(scratch_url) = ctx.cfg.scratch_db_url.as_deref() {
303 341 cmd.env("DATABASE_URL", scratch_url);
304 342 }
343 + let log_path = gate_log_path(ctx, GateKind::BootSmoke);
344 + let log_ref = LogRef::new(&ctx.version, GateKind::BootSmoke);
305 345 let mut child = match cmd.spawn() {
306 346 Ok(c) => c,
307 347 Err(e) => {
308 - persist_gate_log(ctx, "boot_smoke", format!("spawn: {e}").as_bytes(), &[]).await;
309 - return Ok(GateOutcome { passed: false, detail: Some(format!("spawn: {e}")) });
348 + // Spawn failures get a one-off log line via LiveLog so the
349 + // on-disk file still exists for `GET /logs/...`.
350 + let mut log = LiveLog::open(ctx.events.clone(), run_id, log_path).await;
351 + log.write_chunk(format!("spawn: {e}\n").as_bytes()).await;
352 + log.close().await;
353 + return Ok(GateOutcome::failed(GateFailure::SpawnFailed {
354 + message: e.to_string(),
355 + }).with_log_ref(log_ref));
310 356 }
311 357 };
312 358
313 - // Drain stdout/stderr concurrently into in-memory buffers so the log
314 - // captures whatever the binary printed before exiting (or being killed
315 - // after the 3s smoke window). Without piping + draining, output goes to
316 - // sandod's own stdio and is lost.
317 - let mut stdout_h = child.stdout.take();
318 - let mut stderr_h = child.stderr.take();
319 - let stdout_task = tokio::spawn(async move {
320 - let mut buf = Vec::new();
321 - if let Some(s) = stdout_h.as_mut() {
322 - let _ = s.read_to_end(&mut buf).await;
323 - }
324 - buf
325 - });
326 - let stderr_task = tokio::spawn(async move {
327 - let mut buf = Vec::new();
328 - if let Some(s) = stderr_h.as_mut() {
329 - let _ = s.read_to_end(&mut buf).await;
330 - }
331 - buf
332 - });
359 + // The boot smoke window is 3s. Drain stdout/stderr concurrently through
360 + // a shared LiveLog sink so the operator sees panics/log lines stream in
361 + // real time before the kill, AND the on-disk log gets the full byte
362 + // stream for post-mortem reads. The drainers exit when their pipe
363 + // closes — which happens when the child exits naturally or after kill.
364 + let log = std::sync::Arc::new(tokio::sync::Mutex::new(
365 + LiveLog::open(ctx.events.clone(), run_id, log_path).await,
366 + ));
367 + let stdout_task = tokio::spawn(stream_into_log(child.stdout.take(), log.clone()));
368 + let stderr_task = tokio::spawn(stream_into_log(child.stderr.take(), log.clone()));
333 369
334 370 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
335 371
@@ -337,24 +373,81 @@
337 373 if exit.is_none() {
338 374 let _ = child.kill().await;
339 375 }
340 - // Both read tasks complete once the child's stdio is closed (kill closes
341 - // the pipes via the dropped Child on exit-after-kill).
342 - let stdout_buf = stdout_task.await.unwrap_or_default();
343 - let stderr_buf = stderr_task.await.unwrap_or_default();
344 - persist_gate_log(ctx, "boot_smoke", &stdout_buf, &stderr_buf).await;
376 + // The boot_smoke classifier looks at exit code only — the streamed
377 + // bytes already landed in the live log and the on-disk file for the
378 + // post-mortem reader. Drain the join handles to avoid hangs.
379 + let _ = stdout_task.await;
380 + let _ = stderr_task.await;
381 + // Unique owner of the Arc at this point (both tasks dropped their clones).
382 + if let Ok(mutex) = std::sync::Arc::try_unwrap(log) {
383 + mutex.into_inner().close().await;
384 + }
345 385
346 386 match exit {
347 - Some(status) => Ok(GateOutcome {
348 - passed: false,
349 - detail: Some(format!(
350 - "binary exited early: {status}\n{}",
351 - combined_tail(&stdout_buf, &stderr_buf, 4_000),
352 - )),
353 - }),
354 - None => Ok(GateOutcome { passed: true, detail: Some("stayed up for 3s".into()) }),
387 + Some(status) => {
388 + let failure = classify::classify_boot_smoke(status.code());
389 + Ok(GateOutcome::failed(failure).with_log_ref(log_ref))
390 + }
391 + None => Ok(GateOutcome::passed(PassNote::StayedUp { duration_s: 3 })
392 + .with_log_ref(log_ref)),
355 393 }
356 394 }
357 395
396 + /// Drain `stream` into the shared `LiveLog` (which forwards each chunk to
397 + /// the on-disk log file AND broadcasts a `GateLogChunk` event), and return
398 + /// the concatenated bytes so the classifier can still operate on the full
399 + /// output post-hoc.
400 + async fn stream_into_log<R>(
401 + stream: Option<R>,
402 + log: std::sync::Arc<tokio::sync::Mutex<LiveLog>>,
403 + ) -> Vec<u8>
404 + where
405 + R: tokio::io::AsyncRead + Unpin + Send + 'static,
406 + {
407 + let mut total = Vec::new();
408 + let Some(mut s) = stream else { return total };
409 + let mut buf = [0u8; 4096];
410 + loop {
411 + match s.read(&mut buf).await {
412 + Ok(0) => break,
413 + Err(_) => break,
414 + Ok(n) => {
415 + total.extend_from_slice(&buf[..n]);
416 + log.lock().await.write_chunk(&buf[..n]).await;
417 + }
418 + }
419 + }
420 + total
421 + }
422 +
423 + /// Spawn a child, drain its stdout/stderr through a `LiveLog`, return the
424 + /// combined buffers and exit status. Shared by `cargo_test` (no deadline)
425 + /// and ad-hoc callers — `boot_smoke` rolls its own variant because of its
426 + /// 3s kill window.
427 + async fn stream_child_to_live_log(
428 + child: &mut tokio::process::Child,
429 + events: EventTx,
430 + run_id: GateRunId,
431 + log_path: PathBuf,
432 + ) -> Result<(Vec<u8>, Vec<u8>, std::process::ExitStatus)> {
433 + let log = std::sync::Arc::new(tokio::sync::Mutex::new(
434 + LiveLog::open(events, run_id, log_path).await,
435 + ));
436 + let stdout_task = tokio::spawn(stream_into_log(child.stdout.take(), log.clone()));
437 + let stderr_task = tokio::spawn(stream_into_log(child.stderr.take(), log.clone()));
438 + let status = child.wait().await?;
439 + let stdout_buf = stdout_task.await.unwrap_or_default();
440 + let stderr_buf = stderr_task.await.unwrap_or_default();
441 + if let Ok(mutex) = std::sync::Arc::try_unwrap(log) {
442 + mutex.into_inner().close().await;
443 + }
444 + Ok((stdout_buf, stderr_buf, status))
445 + }
446 +
447 + fn gate_log_path(ctx: &GateCtx, gate: GateKind) -> PathBuf {
448 + ctx.cfg.logs_root.join(ctx.version.to_string()).join(format!("{}.log", gate.as_str()))
449 + }
450 +
358 451 async fn burn_in(ctx: &GateCtx, hours: u32) -> Result<GateOutcome> {
359 452 // Check tier_state.burn_in_started_at on this tier; pass if enough time
360 453 // has elapsed. The clock is started by /promote when a version lands on
@@ -367,19 +460,19 @@
367 460 .await?
368 461 .flatten();
369 462 let Some(started) = started else {
370 - return Ok(GateOutcome { passed: false, detail: Some("burn-in clock not started".into()) });
463 + return Ok(GateOutcome::blocked(GateBlocker::BurnInClockNotStarted));
371 464 };
372 465 let started = chrono::DateTime::parse_from_rfc3339(&started)?.with_timezone(&Utc);
373 466 let elapsed = Utc::now() - started;
374 467 let needed = chrono::Duration::hours(hours as i64);
375 468 if elapsed >= needed {
376 - Ok(GateOutcome { passed: true, detail: Some(format!("{} hours elapsed", elapsed.num_hours())) })
469 + Ok(GateOutcome::passed(PassNote::BurnInElapsed { hours: elapsed.num_hours() as u32 }))
377 470 } else {
378 - let remaining = needed - elapsed;
379 - Ok(GateOutcome {
380 - passed: false,
381 - detail: Some(format!("{} hours remaining of {hours}", remaining.num_hours())),
382 - })
471 + let remaining = (needed - elapsed).num_hours().max(0) as u32;
472 + Ok(GateOutcome::blocked(GateBlocker::BurnInRemaining {
473 + hours_remaining: remaining,
474 + hours_total: hours,
475 + }))
383 476 }
384 477 }
385 478
@@ -387,40 +480,24 @@
387 480 // Pass iff a row in gate_runs exists with passed=1 for this (tier, version, manual_confirm)
388 481 // that was inserted out-of-band by an operator action. Since the harness inserts the
389 482 // in-flight row itself, look for a prior confirmation row.
390 - let prior: Option<i64> = sqlx::query_scalar(
391 - "SELECT COUNT(*) FROM gate_runs
392 - WHERE tier = ? AND version = ? AND gate_kind = 'manual_confirm' AND passed = 1",
483 + let prior_at: Option<String> = sqlx::query_scalar(
484 + "SELECT finished_at FROM gate_runs
485 + WHERE tier = ? AND version = ? AND gate_kind = 'manual_confirm' AND passed = 1
486 + ORDER BY id DESC LIMIT 1",
393 487 )
394 488 .bind(&ctx.tier)
395 489 .bind(&ctx.version)
396 490 .fetch_optional(&ctx.pool)
397 491 .await?;
398 - let passed = prior.unwrap_or(0) > 0;
399 - Ok(GateOutcome {
400 - passed,
401 - detail: if passed { None } else { Some("waiting on operator confirmation".into()) },
402 - })
403 - }
404 -
405 - fn tail(buf: &[u8], max: usize) -> String {
406 - let s = String::from_utf8_lossy(buf);
407 - if s.len() <= max { s.into_owned() } else { format!("...{}", &s[s.len() - max..]) }
408 - }
409 -
410 - /// Tail combining stdout + stderr. Phase A doesn't preserve true interleaving
411 - /// (the streams were captured separately); we concatenate stderr after stdout
412 - /// so the failing test output (cargo writes it to stdout) is no longer hidden
413 - /// behind the 4KB stderr tail. Full unsliced output lives in the on-disk log.
414 - fn combined_tail(stdout: &[u8], stderr: &[u8], max: usize) -> String {
415 - let mut joined = Vec::with_capacity(stdout.len() + stderr.len() + 32);
416 - joined.extend_from_slice(b"==== stdout ====\n");
417 - joined.extend_from_slice(stdout);
418 - if !stdout.last().is_some_and(|b| *b == b'\n') {
419 - joined.push(b'\n');
492 + match prior_at {
493 + Some(at_str) => {
494 + let at = chrono::DateTime::parse_from_rfc3339(&at_str)
495 + .map(|d| d.with_timezone(&Utc))
496 + .unwrap_or_else(|_| Utc::now());
497 + Ok(GateOutcome::passed(PassNote::OperatorConfirmed { at }))
498 + }
499 + None => Ok(GateOutcome::blocked(GateBlocker::AwaitingOperatorConfirmation)),
420 500 }
Lines truncated
@@ -1,23 +1,9 @@
1 1 use anyhow::Result;
2 + use sando_daemon::{config, db, events, git, metrics, routes, state, sync, topology};
2 3 use std::net::SocketAddr;
3 4 use std::path::Path;
4 5 use std::sync::Arc;
5 6
6 - mod backup;
7 - mod build;
8 - mod config;
9 - mod db;
10 - mod deploy;
11 - mod error;
12 - mod events;
13 - mod gates;
14 - mod git;
15 - mod metrics;
16 - mod routes;
17 - mod state;
18 - mod sync;
19 - mod topology;
20 -
21 7 #[tokio::main]
22 8 async fn main() -> Result<()> {
23 9 tracing_subscriber::fmt()
@@ -27,9 +13,10 @@
27 13 .with_writer(std::io::stderr)
28 14 .with_env_filter(
29 15 tracing_subscriber::EnvFilter::try_from_default_env()
30 - // bin target name is `sandod`, NOT the package name `sando-daemon` —
31 - // `module_path!()` uses the binary's crate name, so events come from `sandod::*`.
32 - .unwrap_or_else(|_| "sandod=info,tower_http=info".into()),
16 + // Modules live under the library crate `sando_daemon` (since
17 + // the step-5 lib/bin split). `sandod` is kept for any
18 + // top-level events that originate in main.rs itself.
19 + .unwrap_or_else(|_| "sando_daemon=info,sandod=info,tower_http=info".into()),
33 20 )
34 21 .init();
35 22
@@ -43,9 +43,22 @@
43 43 #[derive(Serialize)]
44 44 struct GateView {
45 45 kind: String,
46 + /// Shadow field — kept until step 5 (TUI typed-event handling) so
47 + /// the current TUI keeps rendering pass/fail without consulting
48 + /// `outcome`. Goes away in migration 004.
46 49 passed: Option<bool>,
47 50 finished_at: Option<String>,
51 + /// Shadow field — superseded by `outcome.status`.
48 52 detail: Option<String>,
53 + /// `'passed' | 'failed' | 'blocked'` or NULL while in-flight. The
54 + /// TUI can rely on this to choose green/red/yellow rendering once
55 + /// step 5 lands; until then it falls back to `passed`.
56 + status: Option<String>,
57 + /// Full typed `GateOutcome` as a JSON object, when present.
58 + /// Deserialized lazily by the consumer; sandod doesn't re-parse it.
59 + outcome: Option<serde_json::Value>,
60 + /// Relative path under `cfg.logs_root` to the persisted stdout/stderr.
61 + log_ref: Option<String>,
49 62 }
50 63
51 64 async fn get_state(State(s): State<AppState>) -> Result<Json<StateView>> {
@@ -89,7 +102,7 @@
89 102 let gates: Vec<GateView> = if let Some(ver) = gate_version.as_ref() {
90 103 // Most recent gate_runs row per gate_kind for (tier, ver).
91 104 sqlx::query(
92 - "SELECT gate_kind, passed, finished_at, detail
105 + "SELECT gate_kind, passed, finished_at, detail, status, outcome_json, log_ref
93 106 FROM gate_runs g
94 107 WHERE tier = ?1 AND version = ?2
95 108 AND id = (SELECT MAX(id) FROM gate_runs
@@ -106,6 +119,10 @@
106 119 passed: gr.get::<Option<i64>, _>("passed").map(|v| v != 0),
107 120 finished_at: gr.get("finished_at"),
108 121 detail: gr.get("detail"),
122 + status: gr.get("status"),
123 + outcome: gr.get::<Option<String>, _>("outcome_json")
124 + .and_then(|s| serde_json::from_str(&s).ok()),
125 + log_ref: gr.get("log_ref"),
109 126 })
110 127 .collect()
111 128 } else {
@@ -146,6 +163,7 @@
146 163 body: Option<Json<PromoteBody>>,
147 164 ) -> Result<Json<serde_json::Value>> {
148 165 let body = body.map(|Json(b)| b).unwrap_or_default();
166 + let tier = crate::domain::TierId::new(tier);
149 167 let idx = s.topo.tiers.iter().position(|t| t.name == tier)
150 168 .ok_or(crate::error::Error::NotFound)?;
151 169 if idx == 0 {
@@ -157,7 +175,7 @@
157 175 let source = &s.topo.tiers[idx - 1];
158 176
159 177 // Resolve version: explicit if given, else the source tier's current.
160 - let version = match body.version {
178 + let version_str = match body.version {
161 179 Some(v) => v,
162 180 None => sqlx::query_scalar::<_, Option<String>>(
163 181 "SELECT current_version FROM tier_state WHERE tier = ?",
@@ -170,10 +188,12 @@
170 188 format!("no version specified and tier {} has no current_version", source.name),
171 189 ))?,
172 190 };
191 + let version = crate::domain::Version::parse(&version_str)
192 + .map_err(|e| crate::error::Error::Other(anyhow::anyhow!(e)))?;
173 193
174 194 // 1. Predecessor must have all of its gates green for this version (with
175 195 // optional hotfix override that skips burn_in).
176 - let pending = unsatisfied_gates(&s.pool, &source.name, &version, body.hotfix).await?;
196 + let pending = unsatisfied_gates(&s.pool, source.name.as_str(), &version_str, body.hotfix).await?;
177 197 if !pending.is_empty() {
178 198 return Err(crate::error::Error::GateBlocked(format!(
179 199 "{} gate(s) not satisfied on tier {}: {}",
@@ -208,30 +228,39 @@
208 228 crate::events::emit(&s.events, crate::events::Event::DeployStart {
209 229 tier: target.name.clone(), node: node.name.clone(), version: version.clone(),
210 230 });
211 - let result = crate::deploy::deploy_node(node, &version, &staged_dir, s.cfg.primary_bin()).await;
231 + let result = crate::deploy::deploy_node(node, &version_str, &staged_dir, s.cfg.primary_bin()).await;
212 232 let finished = chrono::Utc::now().to_rfc3339();
213 - let (outcome, err_msg) = match &result {
214 - Ok(_) => ("ok", None),
215 - Err(e) => ("failed", Some(format!("{e:#}"))),
233 + let (outcome_obj, err_for_propagation) = match result {
234 + Ok(_) => (crate::outcome::DeployOutcome::ok(), None),
235 + Err(e) => {
236 + let msg = format!("{e:#}");
237 + let kind = crate::classify::classify_deploy_error(&msg);
238 + (crate::outcome::DeployOutcome::failed(kind), Some(e))
239 + }
216 240 };
241 + let outcome_json = serde_json::to_string(&outcome_obj)
242 + .unwrap_or_else(|e| format!("{{\"_serialize_error\":{e:?}}}"));
217 243 sqlx::query(
218 - "INSERT INTO deploys (version, tier, node, started_at, finished_at, outcome, hotfix, reset_burn_in)
219 - VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
244 + "INSERT INTO deploys (version, tier, node, started_at, finished_at, outcome, outcome_json, hotfix, reset_burn_in)
245 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
220 246 )
221 247 .bind(&version).bind(&target.name).bind(&node.name)
222 - .bind(&started).bind(&finished).bind(outcome)
248 + .bind(&started).bind(&finished).bind(outcome_obj.status_str())
249 + .bind(&outcome_json)
223 250 .bind(body.hotfix as i64).bind(body.reset_burn_in as i64)
224 251 .execute(&s.pool).await.map_err(crate::error::Error::Db)?;
225 - if let Err(e) = result {
226 - let msg = err_msg.unwrap_or_default();
252 + if let Some(e) = err_for_propagation {
253 + let crate::outcome::DeployStatus::Failed { failure } = outcome_obj.status else {
254 + unreachable!("err_for_propagation is Some iff status is Failed");
255 + };
227 256 tracing::error!(
228 257 tier = %target.name, node = %node.name, version = %version,
229 - error = %msg,
258 + failure = failure.summary(),
230 259 "deploy failed; current symlink left intact, tier_state not advanced"
231 260 );
232 261 crate::events::emit(&s.events, crate::events::Event::DeployFailed {
233 262 tier: target.name.clone(), node: node.name.clone(),
234 - version: version.clone(), error: msg,
263 + version: version.clone(), failure,
235 264 });
236 265 return Err(crate::error::Error::Other(e));
237 266 }
@@ -317,6 +346,7 @@
317 346 State(s): State<AppState>,
318 347 Path(tier): Path<String>,
319 348 ) -> Result<Json<serde_json::Value>> {
349 + let tier = crate::domain::TierId::new(tier);
320 350 let target = s.topo.tiers.iter().find(|t| t.name == tier)
321 351 .ok_or(crate::error::Error::NotFound)?;
322 352
@@ -325,11 +355,15 @@
325 355 )
326 356 .bind(&tier)
327 357 .fetch_optional(&s.pool).await.map_err(crate::error::Error::Db)?;
328 - let (Some(current), Some(previous)) = row.unwrap_or((None, None)) else {
358 + let (Some(current_str), Some(previous_str)) = row.unwrap_or((None, None)) else {
329 359 return Err(crate::error::Error::GateBlocked(
330 360 "no previous_version to roll back to".into(),
331 361 ));
332 362 };
363 + let current = crate::domain::Version::parse(&current_str)
364 + .map_err(|e| crate::error::Error::Other(anyhow::anyhow!(e)))?;
365 + let previous = crate::domain::Version::parse(&previous_str)
366 + .map_err(|e| crate::error::Error::Other(anyhow::anyhow!(e)))?;
333 367
334 368 let bin: Option<(String,)> = sqlx::query_as(
335 369 "SELECT artifact_path FROM versions WHERE version = ?",
@@ -347,7 +381,7 @@
347 381 .to_path_buf();
348 382
349 383 for node in &target.nodes {
350 - crate::deploy::deploy_node(node, &previous, &staged_dir, s.cfg.primary_bin())
384 + crate::deploy::deploy_node(node, &previous_str, &staged_dir, s.cfg.primary_bin())
351 385 .await
352 386 .map_err(crate::error::Error::Other)?;
353 387 }
@@ -395,6 +429,11 @@
395 429 .map_err(crate::error::Error::Other)?,
396 430 };
397 431
432 + // Boundary parse: a sha entering Sando must be hex of plausible length.
433 + // The build pipeline downstream only ever sees `GitSha`.
434 + let sha = crate::domain::GitSha::parse(&sha)
435 + .map_err(|e| crate::error::Error::Other(anyhow::anyhow!(e)))?;
436 +
398 437 tracing::info!(sha = %sha, "rebuild requested");
399 438 crate::events::emit(&s.events, crate::events::Event::RebuildRequested { sha: sha.clone() });
400 439
@@ -416,6 +455,7 @@
416 455 let topo = s.topo.clone();
417 456 let events_for_task = s.events.clone();
418 457 let sha_for_task = sha.clone();
458 + let sha_response = sha.to_string();
419 459 let handle = tokio::spawn(async move {
420 460 if let Err(e) = crate::build::build_and_run_host(pool, cfg, topo, sha_for_task.clone(), events_for_task).await {
421 461 tracing::error!(sha = %sha_for_task, error = %e, "rebuild pipeline failed");
@@ -423,7 +463,7 @@
423 463 });
424 464 *slot = Some(handle.abort_handle());
425 465
426 - Ok(Json(serde_json::json!({ "accepted": true, "sha": sha })))
466 + Ok(Json(serde_json::json!({ "accepted": true, "sha": sha_response })))
427 467 }
428 468
429 469 async fn confirm(
@@ -433,17 +473,20 @@
433 473 // Operator-driven satisfaction of a `manual_confirm` gate. Looks up the
434 474 // pending version (current MM version, or the tier's own if non-mm) and
435 475 // inserts a passing gate_runs row so /promote can advance.
476 + let tier = crate::domain::TierId::new(tier);
436 477 let target = s.topo.tiers.iter().find(|t| t.name == tier)
437 478 .ok_or(crate::error::Error::NotFound)?;
438 479
439 - let version: Option<String> = sqlx::query_scalar(
480 + let version_str: Option<String> = sqlx::query_scalar(
440 481 "SELECT current_version FROM tier_state WHERE tier = ?",
441 482 )
442 483 .bind(&target.name)
443 484 .fetch_optional(&s.pool).await.map_err(crate::error::Error::Db)?.flatten();
444 - let version = version.ok_or_else(|| crate::error::Error::GateBlocked(
485 + let version_str = version_str.ok_or_else(|| crate::error::Error::GateBlocked(
445 486 format!("tier {tier} has no current_version; nothing to confirm"),
446 487 ))?;
488 + let version = crate::domain::Version::parse(&version_str)
489 + .map_err(|e| crate::error::Error::Other(anyhow::anyhow!(e)))?;
447 490
448 491 let now = chrono::Utc::now().to_rfc3339();
449 492 sqlx::query(
@@ -1,3 +1,4 @@
1 + use crate::domain::{GateKind, NodeId, TierId};
1 2 use anyhow::{Context, Result};
2 3 use serde::{Deserialize, Serialize};
3 4 use std::path::Path;
@@ -24,7 +25,7 @@
24 25
25 26 #[derive(Debug, Clone, Serialize, Deserialize)]
26 27 pub struct Tier {
27 - pub name: String,
28 + pub name: TierId,
28 29 #[serde(default)]
29 30 pub provisioned: bool,
30 31 pub gates: Vec<Gate>,
@@ -36,7 +37,7 @@
36 37
37 38 #[derive(Debug, Clone, Serialize, Deserialize)]
38 39 pub struct Node {
39 - pub name: String,
40 + pub name: NodeId,
40 41 pub ssh_target: String,
41 42 pub release_root: String,
42 43 /// systemd unit name to reload-or-restart after the symlink swap.
@@ -74,6 +75,21 @@
74 75 ManualConfirm,
75 76 }
76 77
78 + impl Gate {
79 + /// The discriminant — the identifier we use in events, schema columns,
80 + /// and the TUI. Gate parameters (e.g. `BurnIn.hours`) stay with `Gate`
81 + /// and are not carried into `gate_runs` history.
82 + pub fn kind(&self) -> GateKind {
83 + match self {
84 + Gate::CargoTest => GateKind::CargoTest,
85 + Gate::MigrationDryRun => GateKind::MigrationDryRun,
86 + Gate::BootSmoke => GateKind::BootSmoke,
87 + Gate::BurnIn { .. } => GateKind::BurnIn,
88 + Gate::ManualConfirm => GateKind::ManualConfirm,
89 + }
90 + }
91 + }
92 +
77 93 impl Topology {
78 94 pub fn load(path: &Path) -> Result<Self> {
79 95 let raw = std::fs::read_to_string(path)
@@ -86,7 +102,7 @@
86 102 fn validate(&self) -> Result<()> {
87 103 anyhow::ensure!(!self.tiers.is_empty(), "topology must declare at least one tier");
88 104 for t in &self.tiers {
89 - if t.provisioned && t.nodes.is_empty() && t.name != "host" {
105 + if t.provisioned && t.nodes.is_empty() && t.name.as_str() != "host" {
90 106 anyhow::bail!("tier {} is provisioned but has no nodes", t.name);
91 107 }
92 108 }
M sando/tui/src/main.rs +290 -133