max / makenotwork
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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(¤t_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 | } |