max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
30 files changed,
+6056 insertions,
-0 deletions
| @@ -1,0 +1,3343 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "ahash" | |
| 7 | + | version = "0.8.12" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 10 | + | dependencies = [ | |
| 11 | + | "cfg-if", | |
| 12 | + | "const-random", | |
| 13 | + | "getrandom 0.3.4", | |
| 14 | + | "once_cell", | |
| 15 | + | "version_check", | |
| 16 | + | "zerocopy", | |
| 17 | + | ] | |
| 18 | + | ||
| 19 | + | [[package]] | |
| 20 | + | name = "aho-corasick" | |
| 21 | + | version = "1.1.4" | |
| 22 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 23 | + | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | |
| 24 | + | dependencies = [ | |
| 25 | + | "memchr", | |
| 26 | + | ] | |
| 27 | + | ||
| 28 | + | [[package]] | |
| 29 | + | name = "allocator-api2" | |
| 30 | + | version = "0.2.21" | |
| 31 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 32 | + | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 33 | + | ||
| 34 | + | [[package]] | |
| 35 | + | name = "android_system_properties" | |
| 36 | + | version = "0.1.5" | |
| 37 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 38 | + | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 39 | + | dependencies = [ | |
| 40 | + | "libc", | |
| 41 | + | ] | |
| 42 | + | ||
| 43 | + | [[package]] | |
| 44 | + | name = "anyhow" | |
| 45 | + | version = "1.0.102" | |
| 46 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 47 | + | checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" | |
| 48 | + | ||
| 49 | + | [[package]] | |
| 50 | + | name = "atoi" | |
| 51 | + | version = "2.0.0" | |
| 52 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 53 | + | checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" | |
| 54 | + | dependencies = [ | |
| 55 | + | "num-traits", | |
| 56 | + | ] | |
| 57 | + | ||
| 58 | + | [[package]] | |
| 59 | + | name = "atomic-waker" | |
| 60 | + | version = "1.1.2" | |
| 61 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 62 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 63 | + | ||
| 64 | + | [[package]] | |
| 65 | + | name = "autocfg" | |
| 66 | + | version = "1.5.1" | |
| 67 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 68 | + | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 69 | + | ||
| 70 | + | [[package]] | |
| 71 | + | name = "axum" | |
| 72 | + | version = "0.8.9" | |
| 73 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 74 | + | checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" | |
| 75 | + | dependencies = [ | |
| 76 | + | "axum-core", | |
| 77 | + | "axum-macros", | |
| 78 | + | "base64", | |
| 79 | + | "bytes", | |
| 80 | + | "form_urlencoded", | |
| 81 | + | "futures-util", | |
| 82 | + | "http", | |
| 83 | + | "http-body", | |
| 84 | + | "http-body-util", | |
| 85 | + | "hyper", | |
| 86 | + | "hyper-util", | |
| 87 | + | "itoa", | |
| 88 | + | "matchit", | |
| 89 | + | "memchr", | |
| 90 | + | "mime", | |
| 91 | + | "percent-encoding", | |
| 92 | + | "pin-project-lite", | |
| 93 | + | "serde_core", | |
| 94 | + | "serde_json", | |
| 95 | + | "serde_path_to_error", | |
| 96 | + | "serde_urlencoded", | |
| 97 | + | "sha1", | |
| 98 | + | "sync_wrapper", | |
| 99 | + | "tokio", | |
| 100 | + | "tokio-tungstenite", | |
| 101 | + | "tower", | |
| 102 | + | "tower-layer", | |
| 103 | + | "tower-service", | |
| 104 | + | "tracing", | |
| 105 | + | ] | |
| 106 | + | ||
| 107 | + | [[package]] | |
| 108 | + | name = "axum-core" | |
| 109 | + | version = "0.5.6" | |
| 110 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 111 | + | checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" | |
| 112 | + | dependencies = [ | |
| 113 | + | "bytes", | |
| 114 | + | "futures-core", | |
| 115 | + | "http", | |
| 116 | + | "http-body", | |
| 117 | + | "http-body-util", | |
| 118 | + | "mime", | |
| 119 | + | "pin-project-lite", | |
| 120 | + | "sync_wrapper", | |
| 121 | + | "tower-layer", | |
| 122 | + | "tower-service", | |
| 123 | + | "tracing", | |
| 124 | + | ] | |
| 125 | + | ||
| 126 | + | [[package]] | |
| 127 | + | name = "axum-macros" | |
| 128 | + | version = "0.5.1" | |
| 129 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 130 | + | checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" | |
| 131 | + | dependencies = [ | |
| 132 | + | "proc-macro2", | |
| 133 | + | "quote", | |
| 134 | + | "syn", | |
| 135 | + | ] | |
| 136 | + | ||
| 137 | + | [[package]] | |
| 138 | + | name = "base64" | |
| 139 | + | version = "0.22.1" | |
| 140 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 141 | + | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 142 | + | ||
| 143 | + | [[package]] | |
| 144 | + | name = "base64ct" | |
| 145 | + | version = "1.8.3" | |
| 146 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 147 | + | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | |
| 148 | + | ||
| 149 | + | [[package]] | |
| 150 | + | name = "bento-daemon" | |
| 151 | + | version = "0.1.0" | |
| 152 | + | dependencies = [ | |
| 153 | + | "anyhow", | |
| 154 | + | "axum", | |
| 155 | + | "chrono", | |
| 156 | + | "http-body-util", | |
| 157 | + | "metrics", | |
| 158 | + | "metrics-exporter-prometheus", | |
| 159 | + | "ops-core", | |
| 160 | + | "reqwest", | |
| 161 | + | "rhai", | |
| 162 | + | "semver", | |
| 163 | + | "serde", | |
| 164 | + | "serde_json", | |
| 165 | + | "sqlx", | |
| 166 | + | "tempfile", | |
| 167 | + | "thiserror", | |
| 168 | + | "tokio", | |
| 169 | + | "toml", | |
| 170 | + | "tower", | |
| 171 | + | "tracing", | |
| 172 | + | "tracing-subscriber", | |
| 173 | + | ] | |
| 174 | + | ||
| 175 | + | [[package]] | |
| 176 | + | name = "bitflags" | |
| 177 | + | version = "2.13.0" | |
| 178 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 179 | + | checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" | |
| 180 | + | dependencies = [ | |
| 181 | + | "serde_core", | |
| 182 | + | ] | |
| 183 | + | ||
| 184 | + | [[package]] | |
| 185 | + | name = "block-buffer" | |
| 186 | + | version = "0.10.4" | |
| 187 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 188 | + | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 189 | + | dependencies = [ | |
| 190 | + | "generic-array", | |
| 191 | + | ] | |
| 192 | + | ||
| 193 | + | [[package]] | |
| 194 | + | name = "bumpalo" | |
| 195 | + | version = "3.20.3" | |
| 196 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 197 | + | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 198 | + | ||
| 199 | + | [[package]] | |
| 200 | + | name = "byteorder" | |
| 201 | + | version = "1.5.0" | |
| 202 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 203 | + | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 204 | + | ||
| 205 | + | [[package]] | |
| 206 | + | name = "bytes" | |
| 207 | + | version = "1.11.1" | |
| 208 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 209 | + | checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" | |
| 210 | + | ||
| 211 | + | [[package]] | |
| 212 | + | name = "cc" | |
| 213 | + | version = "1.2.63" | |
| 214 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 215 | + | checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" | |
| 216 | + | dependencies = [ | |
| 217 | + | "find-msvc-tools", | |
| 218 | + | "shlex", | |
| 219 | + | ] | |
| 220 | + | ||
| 221 | + | [[package]] | |
| 222 | + | name = "cfg-if" | |
| 223 | + | version = "1.0.4" | |
| 224 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 225 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 226 | + | ||
| 227 | + | [[package]] | |
| 228 | + | name = "cfg_aliases" | |
| 229 | + | version = "0.2.1" | |
| 230 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 231 | + | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" | |
| 232 | + | ||
| 233 | + | [[package]] | |
| 234 | + | name = "chrono" | |
| 235 | + | version = "0.4.45" | |
| 236 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 237 | + | checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" | |
| 238 | + | dependencies = [ | |
| 239 | + | "iana-time-zone", | |
| 240 | + | "js-sys", | |
| 241 | + | "num-traits", | |
| 242 | + | "serde", | |
| 243 | + | "wasm-bindgen", | |
| 244 | + | "windows-link", | |
| 245 | + | ] | |
| 246 | + | ||
| 247 | + | [[package]] | |
| 248 | + | name = "concurrent-queue" | |
| 249 | + | version = "2.5.0" | |
| 250 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 251 | + | checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | |
| 252 | + | dependencies = [ | |
| 253 | + | "crossbeam-utils", | |
| 254 | + | ] | |
| 255 | + | ||
| 256 | + | [[package]] | |
| 257 | + | name = "const-oid" | |
| 258 | + | version = "0.9.6" | |
| 259 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 260 | + | checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | |
| 261 | + | ||
| 262 | + | [[package]] | |
| 263 | + | name = "const-random" | |
| 264 | + | version = "0.1.18" | |
| 265 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 266 | + | checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" | |
| 267 | + | dependencies = [ | |
| 268 | + | "const-random-macro", | |
| 269 | + | ] | |
| 270 | + | ||
| 271 | + | [[package]] | |
| 272 | + | name = "const-random-macro" | |
| 273 | + | version = "0.1.16" | |
| 274 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 275 | + | checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" | |
| 276 | + | dependencies = [ | |
| 277 | + | "getrandom 0.2.17", | |
| 278 | + | "once_cell", | |
| 279 | + | "tiny-keccak", | |
| 280 | + | ] | |
| 281 | + | ||
| 282 | + | [[package]] | |
| 283 | + | name = "core-foundation-sys" | |
| 284 | + | version = "0.8.7" | |
| 285 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 286 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 287 | + | ||
| 288 | + | [[package]] | |
| 289 | + | name = "cpufeatures" | |
| 290 | + | version = "0.2.17" | |
| 291 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 292 | + | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | |
| 293 | + | dependencies = [ | |
| 294 | + | "libc", | |
| 295 | + | ] | |
| 296 | + | ||
| 297 | + | [[package]] | |
| 298 | + | name = "crc" | |
| 299 | + | version = "3.4.0" | |
| 300 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 301 | + | checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" | |
| 302 | + | dependencies = [ | |
| 303 | + | "crc-catalog", | |
| 304 | + | ] | |
| 305 | + | ||
| 306 | + | [[package]] | |
| 307 | + | name = "crc-catalog" | |
| 308 | + | version = "2.5.0" | |
| 309 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 310 | + | checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" | |
| 311 | + | ||
| 312 | + | [[package]] | |
| 313 | + | name = "crossbeam-epoch" | |
| 314 | + | version = "0.9.18" | |
| 315 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 316 | + | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" | |
| 317 | + | dependencies = [ | |
| 318 | + | "crossbeam-utils", | |
| 319 | + | ] | |
| 320 | + | ||
| 321 | + | [[package]] | |
| 322 | + | name = "crossbeam-queue" | |
| 323 | + | version = "0.3.12" | |
| 324 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 325 | + | checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" | |
| 326 | + | dependencies = [ | |
| 327 | + | "crossbeam-utils", | |
| 328 | + | ] | |
| 329 | + | ||
| 330 | + | [[package]] | |
| 331 | + | name = "crossbeam-utils" | |
| 332 | + | version = "0.8.21" | |
| 333 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 334 | + | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" | |
| 335 | + | ||
| 336 | + | [[package]] | |
| 337 | + | name = "crunchy" | |
| 338 | + | version = "0.2.4" | |
| 339 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 340 | + | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | |
| 341 | + | ||
| 342 | + | [[package]] | |
| 343 | + | name = "crypto-common" | |
| 344 | + | version = "0.1.7" | |
| 345 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 346 | + | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | |
| 347 | + | dependencies = [ | |
| 348 | + | "generic-array", | |
| 349 | + | "typenum", | |
| 350 | + | ] | |
| 351 | + | ||
| 352 | + | [[package]] | |
| 353 | + | name = "data-encoding" | |
| 354 | + | version = "2.11.0" | |
| 355 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 356 | + | checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" | |
| 357 | + | ||
| 358 | + | [[package]] | |
| 359 | + | name = "der" | |
| 360 | + | version = "0.7.10" | |
| 361 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 362 | + | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" | |
| 363 | + | dependencies = [ | |
| 364 | + | "const-oid", | |
| 365 | + | "pem-rfc7468", | |
| 366 | + | "zeroize", | |
| 367 | + | ] | |
| 368 | + | ||
| 369 | + | [[package]] | |
| 370 | + | name = "digest" | |
| 371 | + | version = "0.10.7" | |
| 372 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 373 | + | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 374 | + | dependencies = [ | |
| 375 | + | "block-buffer", | |
| 376 | + | "const-oid", | |
| 377 | + | "crypto-common", | |
| 378 | + | "subtle", | |
| 379 | + | ] | |
| 380 | + | ||
| 381 | + | [[package]] | |
| 382 | + | name = "displaydoc" | |
| 383 | + | version = "0.2.6" | |
| 384 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 385 | + | checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" | |
| 386 | + | dependencies = [ | |
| 387 | + | "proc-macro2", | |
| 388 | + | "quote", | |
| 389 | + | "syn", | |
| 390 | + | ] | |
| 391 | + | ||
| 392 | + | [[package]] | |
| 393 | + | name = "dotenvy" | |
| 394 | + | version = "0.15.7" | |
| 395 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 396 | + | checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" | |
| 397 | + | ||
| 398 | + | [[package]] | |
| 399 | + | name = "either" | |
| 400 | + | version = "1.16.0" | |
| 401 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 402 | + | checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" | |
| 403 | + | dependencies = [ | |
| 404 | + | "serde", | |
| 405 | + | ] | |
| 406 | + | ||
| 407 | + | [[package]] | |
| 408 | + | name = "equivalent" | |
| 409 | + | version = "1.0.2" | |
| 410 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 411 | + | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | |
| 412 | + | ||
| 413 | + | [[package]] | |
| 414 | + | name = "errno" | |
| 415 | + | version = "0.3.14" | |
| 416 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 417 | + | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 418 | + | dependencies = [ | |
| 419 | + | "libc", | |
| 420 | + | "windows-sys 0.61.2", | |
| 421 | + | ] | |
| 422 | + | ||
| 423 | + | [[package]] | |
| 424 | + | name = "etcetera" | |
| 425 | + | version = "0.8.0" | |
| 426 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 427 | + | checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" | |
| 428 | + | dependencies = [ | |
| 429 | + | "cfg-if", | |
| 430 | + | "home", | |
| 431 | + | "windows-sys 0.48.0", | |
| 432 | + | ] | |
| 433 | + | ||
| 434 | + | [[package]] | |
| 435 | + | name = "event-listener" | |
| 436 | + | version = "5.4.1" | |
| 437 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 438 | + | checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" | |
| 439 | + | dependencies = [ | |
| 440 | + | "concurrent-queue", | |
| 441 | + | "parking", | |
| 442 | + | "pin-project-lite", | |
| 443 | + | ] | |
| 444 | + | ||
| 445 | + | [[package]] | |
| 446 | + | name = "evmap" | |
| 447 | + | version = "11.0.0" | |
| 448 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 449 | + | checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" | |
| 450 | + | dependencies = [ | |
| 451 | + | "hashbag", | |
| 452 | + | "left-right", | |
| 453 | + | "smallvec", | |
| 454 | + | ] | |
| 455 | + | ||
| 456 | + | [[package]] | |
| 457 | + | name = "fastrand" | |
| 458 | + | version = "2.4.1" | |
| 459 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 460 | + | checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" | |
| 461 | + | ||
| 462 | + | [[package]] | |
| 463 | + | name = "find-msvc-tools" | |
| 464 | + | version = "0.1.9" | |
| 465 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 466 | + | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 467 | + | ||
| 468 | + | [[package]] | |
| 469 | + | name = "flume" | |
| 470 | + | version = "0.11.1" | |
| 471 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 472 | + | checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" | |
| 473 | + | dependencies = [ | |
| 474 | + | "futures-core", | |
| 475 | + | "futures-sink", | |
| 476 | + | "spin 0.9.8", | |
| 477 | + | ] | |
| 478 | + | ||
| 479 | + | [[package]] | |
| 480 | + | name = "foldhash" | |
| 481 | + | version = "0.1.5" | |
| 482 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 483 | + | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" | |
| 484 | + | ||
| 485 | + | [[package]] | |
| 486 | + | name = "foldhash" | |
| 487 | + | version = "0.2.0" | |
| 488 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 489 | + | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 490 | + | ||
| 491 | + | [[package]] | |
| 492 | + | name = "form_urlencoded" | |
| 493 | + | version = "1.2.2" | |
| 494 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 495 | + | checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" | |
| 496 | + | dependencies = [ | |
| 497 | + | "percent-encoding", | |
| 498 | + | ] | |
| 499 | + | ||
| 500 | + | [[package]] |
Lines truncated
| @@ -1,0 +1,33 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "bento-daemon" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | license = "MIT" | |
| 6 | + | ||
| 7 | + | [[bin]] | |
| 8 | + | name = "bentod" | |
| 9 | + | path = "src/main.rs" | |
| 10 | + | ||
| 11 | + | [dependencies] | |
| 12 | + | ops-core = { path = "../../shared/ops-core" } | |
| 13 | + | axum = { version = "0.8.8", features = ["macros", "ws"] } | |
| 14 | + | tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal", "fs", "process", "sync"] } | |
| 15 | + | serde = { version = "1.0.228", features = ["derive"] } | |
| 16 | + | serde_json = "1" | |
| 17 | + | toml = "0.8" | |
| 18 | + | sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] } | |
| 19 | + | rhai = { version = "1.20", features = ["sync"] } | |
| 20 | + | tracing = "0.1.44" | |
| 21 | + | tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } | |
| 22 | + | metrics = "0.24" | |
| 23 | + | metrics-exporter-prometheus = { version = "0.18.1", default-features = false } | |
| 24 | + | anyhow = "1.0.102" | |
| 25 | + | thiserror = "2.0.18" | |
| 26 | + | chrono = { version = "0.4", features = ["serde"] } | |
| 27 | + | semver = { version = "1.0", features = ["serde"] } | |
| 28 | + | ||
| 29 | + | [dev-dependencies] | |
| 30 | + | tempfile = "3.20" | |
| 31 | + | tower = { version = "0.5", features = ["util"] } | |
| 32 | + | http-body-util = "0.1" | |
| 33 | + | reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } |
| @@ -1,0 +1,50 @@ | |||
| 1 | + | -- Bento run history. Three nested tables mirror the App x Target x Step model: | |
| 2 | + | -- a build fans out to per-target runs, each of which walks the step sequence. | |
| 3 | + | ||
| 4 | + | CREATE TABLE builds ( | |
| 5 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 6 | + | app TEXT NOT NULL, | |
| 7 | + | version TEXT NOT NULL, | |
| 8 | + | status TEXT NOT NULL, -- pending | running | ok | failed | |
| 9 | + | created_at TEXT NOT NULL, | |
| 10 | + | finished_at TEXT | |
| 11 | + | ); | |
| 12 | + | ||
| 13 | + | CREATE TABLE target_runs ( | |
| 14 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 15 | + | build_id INTEGER NOT NULL REFERENCES builds(id) ON DELETE CASCADE, | |
| 16 | + | app TEXT NOT NULL, | |
| 17 | + | version TEXT NOT NULL, | |
| 18 | + | target TEXT NOT NULL, -- "platform/arch" | |
| 19 | + | status TEXT NOT NULL, -- pending | running | ok | failed | |
| 20 | + | current_step TEXT, -- step name while running | |
| 21 | + | error TEXT, | |
| 22 | + | started_at TEXT NOT NULL, | |
| 23 | + | finished_at TEXT | |
| 24 | + | ); | |
| 25 | + | ||
| 26 | + | CREATE INDEX target_runs_build ON target_runs(build_id); | |
| 27 | + | ||
| 28 | + | CREATE TABLE step_runs ( | |
| 29 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 30 | + | target_run_id INTEGER NOT NULL REFERENCES target_runs(id) ON DELETE CASCADE, | |
| 31 | + | step TEXT NOT NULL, -- canonical Step name | |
| 32 | + | status TEXT NOT NULL, -- running | ok | failed | |
| 33 | + | log_ref TEXT, -- path under logs_root | |
| 34 | + | started_at TEXT NOT NULL, | |
| 35 | + | finished_at TEXT | |
| 36 | + | ); | |
| 37 | + | ||
| 38 | + | CREATE INDEX step_runs_target ON step_runs(target_run_id); | |
| 39 | + | ||
| 40 | + | -- Published releases, for publish idempotency + version-monotonicity guards. | |
| 41 | + | CREATE TABLE releases ( | |
| 42 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 43 | + | app TEXT NOT NULL, | |
| 44 | + | target TEXT NOT NULL, | |
| 45 | + | version TEXT NOT NULL, | |
| 46 | + | channel TEXT NOT NULL, | |
| 47 | + | artifact_hash TEXT, | |
| 48 | + | published_at TEXT NOT NULL, | |
| 49 | + | UNIQUE(app, target, channel, version) | |
| 50 | + | ); |
| @@ -1,0 +1,48 @@ | |||
| 1 | + | //! Daemon-local config (`bento-daemon.toml`) — paths and listen address that | |
| 2 | + | //! belong to the machine bentod runs on, not to the build matrix (which lives | |
| 3 | + | //! in the separate topology file, see [`crate::topology`]). | |
| 4 | + | ||
| 5 | + | use anyhow::{Context, Result}; | |
| 6 | + | use serde::Deserialize; | |
| 7 | + | use std::path::PathBuf; | |
| 8 | + | ||
| 9 | + | #[derive(Debug, Clone, Deserialize)] | |
| 10 | + | pub struct Config { | |
| 11 | + | pub listen: String, | |
| 12 | + | pub db_path: PathBuf, | |
| 13 | + | pub topology_path: PathBuf, | |
| 14 | + | /// Root of the Syncthing private layer (`~/Code/_private`); the `secret()` | |
| 15 | + | /// host function reads credential files relative to here. Never logged. | |
| 16 | + | pub secrets_root: PathBuf, | |
| 17 | + | /// Where collected artifacts land (`<dist_root>/<app>/<version>/`). | |
| 18 | + | pub dist_root: PathBuf, | |
| 19 | + | /// Root for per-step run logs | |
| 20 | + | /// (`<logs_root>/<app>/<version>/<target>/<step>.log`). | |
| 21 | + | #[serde(default = "default_logs_root")] | |
| 22 | + | pub logs_root: PathBuf, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | fn default_logs_root() -> PathBuf { | |
| 26 | + | PathBuf::from("/srv/bento/logs") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | impl Config { | |
| 30 | + | pub fn load() -> Result<Self> { | |
| 31 | + | let path = std::env::var("BENTO_CONFIG").unwrap_or_else(|_| "bento-daemon.toml".into()); | |
| 32 | + | let raw = std::fs::read_to_string(&path) | |
| 33 | + | .with_context(|| format!("reading daemon config at {path}"))?; | |
| 34 | + | Ok(toml::from_str(&raw)?) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | #[cfg(test)] | |
| 38 | + | pub fn for_tests(root: &std::path::Path) -> Self { | |
| 39 | + | Self { | |
| 40 | + | listen: "127.0.0.1:0".into(), | |
| 41 | + | db_path: root.join("bento.db"), | |
| 42 | + | topology_path: root.join("bento.toml"), | |
| 43 | + | secrets_root: root.join("secrets"), | |
| 44 | + | dist_root: root.join("dist"), | |
| 45 | + | logs_root: root.join("logs"), | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } |
| @@ -1,0 +1,14 @@ | |||
| 1 | + | //! Database access. Connection comes from `ops_core::sqlite`; migrations are | |
| 2 | + | //! per-crate (the `sqlx::migrate!` path is compile-time, relative to here). | |
| 3 | + | ||
| 4 | + | use anyhow::Result; | |
| 5 | + | use sqlx::SqlitePool; | |
| 6 | + | use std::path::Path; | |
| 7 | + | ||
| 8 | + | pub use ops_core::sqlite::connect; | |
| 9 | + | ||
| 10 | + | pub async fn open(path: &Path) -> Result<SqlitePool> { | |
| 11 | + | let pool = connect(path).await?; | |
| 12 | + | sqlx::migrate!("./migrations").run(&pool).await?; | |
| 13 | + | Ok(pool) | |
| 14 | + | } |
| @@ -1,0 +1,343 @@ | |||
| 1 | + | //! Domain vocabulary for Bento — the types every module speaks. | |
| 2 | + | //! | |
| 3 | + | //! Bento's axes are App x Target x Step. A `(app, target)` resolves to a Rhai | |
| 4 | + | //! recipe; the recipe walks the canonical [`Step`] sequence. Newtypes carry | |
| 5 | + | //! the boundary parse: a [`Target`] exists because some `"platform/arch"` | |
| 6 | + | //! string validated, so downstream code never re-parses. | |
| 7 | + | ||
| 8 | + | use serde::{Deserialize, Serialize}; | |
| 9 | + | use std::fmt; | |
| 10 | + | use std::str::FromStr; | |
| 11 | + | ||
| 12 | + | // --------------------------------------------------------------------- | |
| 13 | + | // App identifier | |
| 14 | + | // --------------------------------------------------------------------- | |
| 15 | + | ||
| 16 | + | /// An app Bento can release: `goingson`, `balanced_breakfast`, `audiofiles`. | |
| 17 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 18 | + | #[serde(transparent)] | |
| 19 | + | pub struct AppId(String); | |
| 20 | + | ||
| 21 | + | impl AppId { | |
| 22 | + | pub fn new(s: impl Into<String>) -> Self { | |
| 23 | + | Self(s.into()) | |
| 24 | + | } | |
| 25 | + | pub fn as_str(&self) -> &str { | |
| 26 | + | &self.0 | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | impl fmt::Display for AppId { | |
| 31 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 32 | + | self.0.fmt(f) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | impl From<&str> for AppId { | |
| 37 | + | fn from(s: &str) -> Self { | |
| 38 | + | Self(s.to_owned()) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // --------------------------------------------------------------------- | |
| 43 | + | // Platform / Arch / Target | |
| 44 | + | // --------------------------------------------------------------------- | |
| 45 | + | ||
| 46 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 47 | + | #[serde(rename_all = "snake_case")] | |
| 48 | + | pub enum Platform { | |
| 49 | + | Macos, | |
| 50 | + | Ios, | |
| 51 | + | Linux, | |
| 52 | + | Windows, | |
| 53 | + | Android, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | impl Platform { | |
| 57 | + | pub fn as_str(self) -> &'static str { | |
| 58 | + | match self { | |
| 59 | + | Platform::Macos => "macos", | |
| 60 | + | Platform::Ios => "ios", | |
| 61 | + | Platform::Linux => "linux", | |
| 62 | + | Platform::Windows => "windows", | |
| 63 | + | Platform::Android => "android", | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | impl FromStr for Platform { | |
| 69 | + | type Err = String; | |
| 70 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 71 | + | Ok(match s { | |
| 72 | + | "macos" => Platform::Macos, | |
| 73 | + | "ios" => Platform::Ios, | |
| 74 | + | "linux" => Platform::Linux, | |
| 75 | + | "windows" => Platform::Windows, | |
| 76 | + | "android" => Platform::Android, | |
| 77 | + | other => return Err(format!("unknown platform `{other}`")), | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 83 | + | #[serde(rename_all = "snake_case")] | |
| 84 | + | pub enum Arch { | |
| 85 | + | Aarch64, | |
| 86 | + | X86_64, | |
| 87 | + | Universal, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | impl Arch { | |
| 91 | + | pub fn as_str(self) -> &'static str { | |
| 92 | + | match self { | |
| 93 | + | Arch::Aarch64 => "aarch64", | |
| 94 | + | Arch::X86_64 => "x86_64", | |
| 95 | + | Arch::Universal => "universal", | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl FromStr for Arch { | |
| 101 | + | type Err = String; | |
| 102 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 103 | + | Ok(match s { | |
| 104 | + | "aarch64" => Arch::Aarch64, | |
| 105 | + | "x86_64" => Arch::X86_64, | |
| 106 | + | "universal" => Arch::Universal, | |
| 107 | + | other => return Err(format!("unknown arch `{other}`")), | |
| 108 | + | }) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// A build target: `(platform, arch)`, rendered `platform/arch` | |
| 113 | + | /// (e.g. `macos/aarch64`). This is the dispatch unit — a target only runs on a | |
| 114 | + | /// host that declares it can build it natively. | |
| 115 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
| 116 | + | pub struct Target { | |
| 117 | + | pub platform: Platform, | |
| 118 | + | pub arch: Arch, | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | impl Target { | |
| 122 | + | pub fn new(platform: Platform, arch: Arch) -> Self { | |
| 123 | + | Self { platform, arch } | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | impl fmt::Display for Target { | |
| 128 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 129 | + | write!(f, "{}/{}", self.platform.as_str(), self.arch.as_str()) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | impl FromStr for Target { | |
| 134 | + | type Err = String; | |
| 135 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 136 | + | let (p, a) = s.split_once('/').ok_or_else(|| format!("target `{s}` is not `platform/arch`"))?; | |
| 137 | + | Ok(Target::new(p.parse()?, a.parse()?)) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Round-trip through JSON / TOML as the `platform/arch` string. | |
| 142 | + | impl Serialize for Target { | |
| 143 | + | fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> { | |
| 144 | + | s.serialize_str(&self.to_string()) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | impl<'de> Deserialize<'de> for Target { | |
| 149 | + | fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> { | |
| 150 | + | let s = String::deserialize(d)?; | |
| 151 | + | s.parse().map_err(serde::de::Error::custom) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // --------------------------------------------------------------------- | |
| 156 | + | // Step | |
| 157 | + | // --------------------------------------------------------------------- | |
| 158 | + | ||
| 159 | + | /// The canonical release step sequence. A recipe marks transitions by calling | |
| 160 | + | /// the `step(name)` host function; not every platform uses every step (Linux | |
| 161 | + | /// skips sign/notarize/staple). The TUI renders these as matrix columns. | |
| 162 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 163 | + | #[serde(rename_all = "snake_case")] | |
| 164 | + | pub enum Step { | |
| 165 | + | Checkout, | |
| 166 | + | Prebuild, | |
| 167 | + | Build, | |
| 168 | + | Sign, | |
| 169 | + | Notarize, | |
| 170 | + | Staple, | |
| 171 | + | Verify, | |
| 172 | + | Package, | |
| 173 | + | Publish, | |
| 174 | + | Collect, | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | impl Step { | |
| 178 | + | /// All steps in canonical order — the matrix column set. | |
| 179 | + | pub const ALL: [Step; 10] = [ | |
| 180 | + | Step::Checkout, | |
| 181 | + | Step::Prebuild, | |
| 182 | + | Step::Build, | |
| 183 | + | Step::Sign, | |
| 184 | + | Step::Notarize, | |
| 185 | + | Step::Staple, | |
| 186 | + | Step::Verify, | |
| 187 | + | Step::Package, | |
| 188 | + | Step::Publish, | |
| 189 | + | Step::Collect, | |
| 190 | + | ]; | |
| 191 | + | ||
| 192 | + | pub fn as_str(self) -> &'static str { | |
| 193 | + | match self { | |
| 194 | + | Step::Checkout => "checkout", | |
| 195 | + | Step::Prebuild => "prebuild", | |
| 196 | + | Step::Build => "build", | |
| 197 | + | Step::Sign => "sign", | |
| 198 | + | Step::Notarize => "notarize", | |
| 199 | + | Step::Staple => "staple", | |
| 200 | + | Step::Verify => "verify", | |
| 201 | + | Step::Package => "package", | |
| 202 | + | Step::Publish => "publish", | |
| 203 | + | Step::Collect => "collect", | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | impl fmt::Display for Step { | |
| 209 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 210 | + | f.write_str(self.as_str()) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | impl FromStr for Step { | |
| 215 | + | type Err = String; | |
| 216 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 217 | + | Step::ALL | |
| 218 | + | .into_iter() | |
| 219 | + | .find(|st| st.as_str() == s) | |
| 220 | + | .ok_or_else(|| format!("unknown step `{s}`")) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // --------------------------------------------------------------------- | |
| 225 | + | // Version (semver) | |
| 226 | + | // --------------------------------------------------------------------- | |
| 227 | + | ||
| 228 | + | /// App semver (e.g. `0.4.1`), read from `tauri.conf.json` or supplied to | |
| 229 | + | /// `/build`. Stored as TEXT. | |
| 230 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 231 | + | pub struct Version(semver::Version); | |
| 232 | + | ||
| 233 | + | impl Version { | |
| 234 | + | pub fn parse(s: &str) -> Result<Self, String> { | |
| 235 | + | semver::Version::parse(s) | |
| 236 | + | .map(Self) | |
| 237 | + | .map_err(|e| format!("invalid semver `{s}`: {e}")) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | impl fmt::Display for Version { | |
| 242 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 243 | + | self.0.fmt(f) | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | impl FromStr for Version { | |
| 248 | + | type Err = String; | |
| 249 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 250 | + | Version::parse(s) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | impl Serialize for Version { | |
| 255 | + | fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> { | |
| 256 | + | s.serialize_str(&self.to_string()) | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | impl<'de> Deserialize<'de> for Version { | |
| 261 | + | fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> { | |
| 262 | + | let s = String::deserialize(d)?; | |
| 263 | + | Version::parse(&s).map_err(serde::de::Error::custom) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // --------------------------------------------------------------------- | |
| 268 | + | // StepRunId — primary key of `step_runs`, used to key live tails (Ord so the | |
| 269 | + | // TUI can iterate chronologically, like Sando's GateRunId). | |
| 270 | + | // --------------------------------------------------------------------- | |
| 271 | + | ||
| 272 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] | |
| 273 | + | #[serde(transparent)] | |
| 274 | + | pub struct StepRunId(pub i64); | |
| 275 | + | ||
| 276 | + | impl fmt::Display for StepRunId { | |
| 277 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 278 | + | self.0.fmt(f) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | /// Outcome of a step / target / build. | |
| 283 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 284 | + | #[serde(rename_all = "snake_case")] | |
| 285 | + | pub enum Status { | |
| 286 | + | Pending, | |
| 287 | + | Running, | |
| 288 | + | Ok, | |
| 289 | + | Failed, | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | impl Status { | |
| 293 | + | pub fn as_str(self) -> &'static str { | |
| 294 | + | match self { | |
| 295 | + | Status::Pending => "pending", | |
| 296 | + | Status::Running => "running", | |
| 297 | + | Status::Ok => "ok", | |
| 298 | + | Status::Failed => "failed", | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | #[cfg(test)] | |
| 304 | + | mod tests { | |
| 305 | + | use super::*; | |
| 306 | + | ||
| 307 | + | #[test] | |
| 308 | + | fn target_roundtrips() { | |
| 309 | + | let t: Target = "macos/aarch64".parse().unwrap(); | |
| 310 | + | assert_eq!(t.platform, Platform::Macos); | |
| 311 | + | assert_eq!(t.arch, Arch::Aarch64); | |
| 312 | + | assert_eq!(t.to_string(), "macos/aarch64"); | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | #[test] | |
| 316 | + | fn target_rejects_garbage() { | |
| 317 | + | assert!("macos".parse::<Target>().is_err()); | |
| 318 | + | assert!("mac/aarch64".parse::<Target>().is_err()); | |
| 319 | + | assert!("macos/sparc".parse::<Target>().is_err()); | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | #[test] | |
| 323 | + | fn target_json_is_the_string() { | |
| 324 | + | let t: Target = "linux/x86_64".parse().unwrap(); | |
| 325 | + | assert_eq!(serde_json::to_string(&t).unwrap(), "\"linux/x86_64\""); | |
| 326 | + | let back: Target = serde_json::from_str("\"linux/x86_64\"").unwrap(); | |
| 327 | + | assert_eq!(back, t); | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | #[test] | |
| 331 | + | fn step_roundtrips() { | |
| 332 | + | for s in Step::ALL { | |
| 333 | + | assert_eq!(s.as_str().parse::<Step>().unwrap(), s); | |
| 334 | + | } | |
| 335 | + | assert!("frobnicate".parse::<Step>().is_err()); | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | #[test] | |
| 339 | + | fn version_parses_semver() { | |
| 340 | + | assert_eq!(Version::parse("0.4.1").unwrap().to_string(), "0.4.1"); | |
| 341 | + | assert!(Version::parse("v0.4").is_err()); | |
| 342 | + | } | |
| 343 | + | } |
| @@ -1,0 +1,620 @@ | |||
| 1 | + | //! Rhai recipe engine + host-function API. | |
| 2 | + | //! | |
| 3 | + | //! A `(app, target)` resolves to a `.rhai` recipe composed from a shared step | |
| 4 | + | //! vocabulary. The daemon embeds Rhai and registers the host functions recipes | |
| 5 | + | //! call; the recipe is the orchestration, the host functions are the | |
| 6 | + | //! privileged primitives (run a command, read a secret, collect artifacts, | |
| 7 | + | //! publish). Recipes are otherwise sandboxed — no arbitrary FS/network except | |
| 8 | + | //! through these functions — matching the Balanced Breakfast plugin model. | |
| 9 | + | //! | |
| 10 | + | //! Rhai is synchronous; the engine runs each recipe on a blocking thread | |
| 11 | + | //! (`spawn_blocking`, see [`crate::runner`]) and host functions bridge to async | |
| 12 | + | //! work via `Handle::block_on`. That is sound only off a runtime worker thread, | |
| 13 | + | //! which `spawn_blocking` guarantees. | |
| 14 | + | ||
| 15 | + | use crate::config::Config; | |
| 16 | + | use crate::domain::{AppId, Status, Step, StepRunId, Target, Version}; | |
| 17 | + | use crate::events::{self, Event, EventTx}; | |
| 18 | + | use crate::ota::{OtaRegistry, Release}; | |
| 19 | + | use anyhow::{Context as _, Result}; | |
| 20 | + | use ops_core::live_log::LiveLog; | |
| 21 | + | use ops_core::remote::RemoteHost; | |
| 22 | + | use rhai::{Engine, EvalAltResult, Map}; | |
| 23 | + | use sqlx::SqlitePool; | |
| 24 | + | use std::collections::HashMap; | |
| 25 | + | use std::path::{Path, PathBuf}; | |
| 26 | + | use std::sync::{Arc, Mutex}; | |
| 27 | + | use tokio::runtime::Handle; | |
| 28 | + | use tokio::sync::Mutex as AsyncMutex; | |
| 29 | + | ||
| 30 | + | /// The currently-open step within a recipe run: its DB row id, which step it | |
| 31 | + | /// is, and the live-log sink that `sh`/`log` stream into. | |
| 32 | + | struct StepState { | |
| 33 | + | run_id: StepRunId, | |
| 34 | + | step: Step, | |
| 35 | + | log: Arc<AsyncMutex<LiveLog>>, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// Everything a recipe's host functions need, shared (Arc) into each closure. | |
| 39 | + | pub struct RecipeCtx { | |
| 40 | + | pub app: AppId, | |
| 41 | + | pub version: Version, | |
| 42 | + | pub target: Target, | |
| 43 | + | pub target_run_id: i64, | |
| 44 | + | pub hosts: HashMap<String, RemoteHost>, | |
| 45 | + | pub pool: SqlitePool, | |
| 46 | + | pub events: EventTx, | |
| 47 | + | pub cfg: Arc<Config>, | |
| 48 | + | pub ota: Arc<OtaRegistry>, | |
| 49 | + | pub rt: Handle, | |
| 50 | + | current: Mutex<Option<StepState>>, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | impl RecipeCtx { | |
| 54 | + | #[allow(clippy::too_many_arguments)] | |
| 55 | + | pub fn new( | |
| 56 | + | app: AppId, | |
| 57 | + | version: Version, | |
| 58 | + | target: Target, | |
| 59 | + | target_run_id: i64, | |
| 60 | + | hosts: HashMap<String, RemoteHost>, | |
| 61 | + | pool: SqlitePool, | |
| 62 | + | events: EventTx, | |
| 63 | + | cfg: Arc<Config>, | |
| 64 | + | ota: Arc<OtaRegistry>, | |
| 65 | + | rt: Handle, | |
| 66 | + | ) -> Self { | |
| 67 | + | Self { | |
| 68 | + | app, | |
| 69 | + | version, | |
| 70 | + | target, | |
| 71 | + | target_run_id, | |
| 72 | + | hosts, | |
| 73 | + | pool, | |
| 74 | + | events, | |
| 75 | + | cfg, | |
| 76 | + | ota, | |
| 77 | + | rt, | |
| 78 | + | current: Mutex::new(None), | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | fn now() -> String { | |
| 83 | + | chrono::Utc::now().to_rfc3339() | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/<step>.log`. | |
| 87 | + | fn log_path(&self, step: Step) -> PathBuf { | |
| 88 | + | let target_dir = self.target.to_string().replace('/', "-"); | |
| 89 | + | self.cfg | |
| 90 | + | .logs_root | |
| 91 | + | .join(self.app.as_str()) | |
| 92 | + | .join(self.version.to_string()) | |
| 93 | + | .join(target_dir) | |
| 94 | + | .join(format!("{}.log", step.as_str())) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// Close the previous step (as `Ok`), open a new one: insert its DB row, | |
| 98 | + | /// open a live log whose chunks broadcast `StepLogChunk`, emit `StepStart`. | |
| 99 | + | fn begin_step(self: &Arc<Self>, step: Step) -> Result<()> { | |
| 100 | + | self.finish_step(Status::Ok)?; | |
| 101 | + | let me = self.clone(); | |
| 102 | + | let run_id = self.rt.block_on(async move { | |
| 103 | + | let started = Self::now(); | |
| 104 | + | let log_ref = me.log_path(step).to_string_lossy().into_owned(); | |
| 105 | + | let id: i64 = sqlx::query_scalar( | |
| 106 | + | "INSERT INTO step_runs (target_run_id, step, status, log_ref, started_at) | |
| 107 | + | VALUES (?, ?, 'running', ?, ?) RETURNING id", | |
| 108 | + | ) | |
| 109 | + | .bind(me.target_run_id) | |
| 110 | + | .bind(step.as_str()) | |
| 111 | + | .bind(&log_ref) | |
| 112 | + | .bind(&started) | |
| 113 | + | .fetch_one(&me.pool) | |
| 114 | + | .await | |
| 115 | + | .context("insert step_run")?; | |
| 116 | + | sqlx::query("UPDATE target_runs SET current_step = ? WHERE id = ?") | |
| 117 | + | .bind(step.as_str()) | |
| 118 | + | .bind(me.target_run_id) | |
| 119 | + | .execute(&me.pool) | |
| 120 | + | .await | |
| 121 | + | .context("update current_step")?; | |
| 122 | + | anyhow::Ok(StepRunId(id)) | |
| 123 | + | })?; | |
| 124 | + | ||
| 125 | + | // Live log: each chunk fans out as a StepLogChunk event keyed by run_id. | |
| 126 | + | let events = self.events.clone(); | |
| 127 | + | let cb_run_id = run_id; | |
| 128 | + | let log = self.rt.block_on(LiveLog::open( | |
| 129 | + | self.log_path(step), | |
| 130 | + | Box::new(move |seq, text| { | |
| 131 | + | events::emit( | |
| 132 | + | &events, | |
| 133 | + | Event::StepLogChunk { run_id: cb_run_id, seq, text: text.to_string() }, | |
| 134 | + | ); | |
| 135 | + | }), | |
| 136 | + | )); | |
| 137 | + | ||
| 138 | + | events::emit( | |
| 139 | + | &self.events, | |
| 140 | + | Event::StepStart { | |
| 141 | + | run_id, | |
| 142 | + | app: self.app.clone(), | |
| 143 | + | version: self.version.clone(), | |
| 144 | + | target: self.target, | |
| 145 | + | step, | |
| 146 | + | }, | |
| 147 | + | ); | |
| 148 | + | ||
| 149 | + | *self.current.lock().unwrap() = Some(StepState { | |
| 150 | + | run_id, | |
| 151 | + | step, | |
| 152 | + | log: Arc::new(AsyncMutex::new(log)), | |
| 153 | + | }); | |
| 154 | + | Ok(()) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Finalize the open step (if any): close its log, stamp the DB row, emit | |
| 158 | + | /// `StepDone`. Idempotent when no step is open. | |
| 159 | + | pub fn finish_step(self: &Arc<Self>, status: Status) -> Result<()> { | |
| 160 | + | let st = self.current.lock().unwrap().take(); | |
| 161 | + | let Some(st) = st else { return Ok(()) }; | |
| 162 | + | let me = self.clone(); | |
| 163 | + | self.rt.block_on(async move { | |
| 164 | + | // Drop all log refs so the sink can be owned + flushed. | |
| 165 | + | if let Ok(m) = Arc::try_unwrap(st.log) { | |
| 166 | + | m.into_inner().close().await; | |
| 167 | + | } | |
| 168 | + | let _ = sqlx::query( | |
| 169 | + | "UPDATE step_runs SET status = ?, finished_at = ? WHERE id = ?", | |
| 170 | + | ) | |
| 171 | + | .bind(status.as_str()) | |
| 172 | + | .bind(Self::now()) | |
| 173 | + | .bind(st.run_id.0) | |
| 174 | + | .execute(&me.pool) | |
| 175 | + | .await; | |
| 176 | + | }); | |
| 177 | + | events::emit( | |
| 178 | + | &self.events, | |
| 179 | + | Event::StepDone { | |
| 180 | + | run_id: st.run_id, | |
| 181 | + | app: self.app.clone(), | |
| 182 | + | target: self.target, | |
| 183 | + | step: st.step, | |
| 184 | + | status, | |
| 185 | + | }, | |
| 186 | + | ); | |
| 187 | + | Ok(()) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// Ensure a step is open; default to `Build` if a recipe runs a command | |
| 191 | + | /// before declaring one. | |
| 192 | + | fn ensure_step(self: &Arc<Self>) -> Result<Arc<AsyncMutex<LiveLog>>> { | |
| 193 | + | if self.current.lock().unwrap().is_none() { | |
| 194 | + | self.begin_step(Step::Build)?; | |
| 195 | + | } | |
| 196 | + | Ok(self.current.lock().unwrap().as_ref().unwrap().log.clone()) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /// The step currently open, or `Build` as a default for failure | |
| 200 | + | /// attribution before any step was declared. | |
| 201 | + | pub fn current_step(&self) -> Step { | |
| 202 | + | self.current.lock().unwrap().as_ref().map(|s| s.step).unwrap_or(Step::Build) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | fn host(&self, name: &str) -> Result<RemoteHost> { | |
| 206 | + | self.hosts | |
| 207 | + | .get(name) | |
| 208 | + | .cloned() | |
| 209 | + | .ok_or_else(|| anyhow::anyhow!("unknown build host `{name}` (not in topology)")) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /// Run `cmd` on `host`, streaming into the current step's log. Returns | |
| 213 | + | /// exit code + a tail of stdout for the recipe to branch on. | |
| 214 | + | fn run(self: &Arc<Self>, host: &str, cmd: &str) -> Result<(i32, String)> { | |
| 215 | + | let sink = self.ensure_step()?; | |
| 216 | + | let host = self.host(host)?; | |
| 217 | + | let cmd = cmd.to_string(); | |
| 218 | + | let out = self.rt.block_on(async move { host.run_streaming(&cmd, sink).await })?; | |
| 219 | + | let code = out.status.code().unwrap_or(-1); | |
| 220 | + | let stdout = String::from_utf8_lossy(&out.stdout); | |
| 221 | + | let tail: String = stdout.chars().rev().take(2000).collect::<Vec<_>>().into_iter().rev().collect(); | |
| 222 | + | Ok((code, tail)) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // ----- error bridging: anyhow -> Rhai runtime error ----- | |
| 227 | + | ||
| 228 | + | fn rhai_err(e: impl std::fmt::Display) -> Box<EvalAltResult> { | |
| 229 | + | Box::new(EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE)) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | /// Read `tauri.conf.json`'s version for `app`, from its checkout on the daemon | |
| 233 | + | /// host. Used by `version_of()` and the runner's default-version path. | |
| 234 | + | pub fn version_from_tauri_conf(repo: &str) -> Result<Version> { | |
| 235 | + | let path = expand_tilde(repo).join("src-tauri").join("tauri.conf.json"); | |
| 236 | + | let raw = std::fs::read_to_string(&path) | |
| 237 | + | .with_context(|| format!("reading {}", path.display()))?; | |
| 238 | + | let v: serde_json::Value = serde_json::from_str(&raw).context("parsing tauri.conf.json")?; | |
| 239 | + | let ver = v.get("version").and_then(|x| x.as_str()).context("no `version` in tauri.conf.json")?; | |
| 240 | + | Version::parse(ver).map_err(|e| anyhow::anyhow!(e)) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`. | |
| 244 | + | pub fn expand_tilde(p: &str) -> PathBuf { | |
| 245 | + | if let Some(rest) = p.strip_prefix("~/") { | |
| 246 | + | if let Ok(home) = std::env::var("HOME") { | |
| 247 | + | return Path::new(&home).join(rest); | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | PathBuf::from(p) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// Build a Rhai engine with the host API bound to `ctx`. Sandboxed: recipes | |
| 254 | + | /// touch the outside world only through these functions. | |
| 255 | + | pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |
| 256 | + | let mut engine = Engine::new(); | |
| 257 | + | // Defensive caps — recipes are first-party but bound the blast radius. | |
| 258 | + | engine.set_max_operations(5_000_000); | |
| 259 | + | engine.set_max_call_levels(64); | |
| 260 | + | engine.set_max_string_size(0); | |
| 261 | + | ||
| 262 | + | // --- step(name) --- | |
| 263 | + | { | |
| 264 | + | let ctx = ctx.clone(); | |
| 265 | + | engine.register_fn("step", move |name: &str| -> Result<(), Box<EvalAltResult>> { | |
| 266 | + | let step: Step = name.parse().map_err(rhai_err)?; | |
| 267 | + | ctx.begin_step(step).map_err(rhai_err) | |
| 268 | + | }); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // --- sh(host, cmd) -> #{ code, stdout_tail } --- | |
| 272 | + | { | |
| 273 | + | let ctx = ctx.clone(); | |
| 274 | + | engine.register_fn("sh", move |host: &str, cmd: &str| -> Result<Map, Box<EvalAltResult>> { | |
| 275 | + | let (code, tail) = ctx.run(host, cmd).map_err(rhai_err)?; | |
| 276 | + | let mut m = Map::new(); | |
| 277 | + | m.insert("code".into(), (code as i64).into()); | |
| 278 | + | m.insert("stdout_tail".into(), tail.into()); | |
| 279 | + | Ok(m) | |
| 280 | + | }); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // --- sh_ok(host, cmd): run + assert exit 0 --- | |
| 284 | + | { | |
| 285 | + | let ctx = ctx.clone(); | |
| 286 | + | engine.register_fn("sh_ok", move |host: &str, cmd: &str| -> Result<(), Box<EvalAltResult>> { | |
| 287 | + | let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?; | |
| 288 | + | if code != 0 { | |
| 289 | + | return Err(rhai_err(format!("command on `{host}` exited {code}: {cmd}"))); | |
| 290 | + | } | |
| 291 | + | Ok(()) | |
| 292 | + | }); | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // --- log(msg): operator-visible line into the current step's tail --- | |
| 296 | + | { | |
| 297 | + | let ctx = ctx.clone(); | |
| 298 | + | engine.register_fn("log", move |msg: &str| -> Result<(), Box<EvalAltResult>> { | |
| 299 | + | let sink = ctx.ensure_step().map_err(rhai_err)?; | |
| 300 | + | let line = format!("[recipe] {msg}\n"); | |
| 301 | + | ctx.rt.block_on(async { | |
| 302 | + | use ops_core::remote::LogSink; | |
| 303 | + | sink.lock().await.write_chunk(line.as_bytes()).await; | |
| 304 | + | }); | |
| 305 | + | Ok(()) | |
| 306 | + | }); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // --- version_of(app) -> string --- | |
| 310 | + | { | |
| 311 | + | let ctx = ctx.clone(); | |
| 312 | + | engine.register_fn("version_of", move |app: &str| -> Result<String, Box<EvalAltResult>> { | |
| 313 | + | // Only the current app is in scope; cross-app reads aren't needed. | |
| 314 | + | if app != ctx.app.as_str() { | |
| 315 | + | return Err(rhai_err(format!("version_of: `{app}` is not the app being built"))); | |
| 316 | + | } | |
| 317 | + | Ok(ctx.version.to_string()) | |
| 318 | + | }); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // --- secret(key) -> string (file under secrets_root; never logged) --- | |
| 322 | + | { | |
| 323 | + | let ctx = ctx.clone(); | |
| 324 | + | engine.register_fn("secret", move |key: &str| -> Result<String, Box<EvalAltResult>> { | |
| 325 | + | // Guard against path traversal out of secrets_root. | |
| 326 | + | if key.contains("..") || key.starts_with('/') { | |
| 327 | + | return Err(rhai_err("secret key must be a relative path without `..`")); | |
| 328 | + | } | |
| 329 | + | let path = ctx.cfg.secrets_root.join(key); | |
| 330 | + | std::fs::read_to_string(&path) | |
| 331 | + | .map(|s| s.trim_end().to_string()) | |
| 332 | + | .map_err(|e| rhai_err(format!("secret `{key}`: {e}"))) | |
| 333 | + | }); | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // --- env(host, key) -> string --- | |
| 337 | + | { | |
| 338 | + | let ctx = ctx.clone(); | |
| 339 | + | engine.register_fn("env", move |host: &str, key: &str| -> Result<String, Box<EvalAltResult>> { | |
| 340 | + | // Read via the shell so it works on remote hosts too. | |
| 341 | + | let (code, tail) = ctx | |
| 342 | + | .run(host, &format!("printf '%s' \"${{{key}}}\"")) | |
| 343 | + | .map_err(rhai_err)?; | |
| 344 | + | if code != 0 { | |
| 345 | + | return Err(rhai_err(format!("env `{key}` on `{host}` failed"))); | |
| 346 | + | } | |
| 347 | + | Ok(tail.trim().to_string()) | |
| 348 | + | }); | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | // --- collect(host, glob, app, version): pull artifacts to dist_root --- | |
| 352 | + | { | |
| 353 | + | let ctx = ctx.clone(); | |
| 354 | + | engine.register_fn( | |
| 355 | + | "collect", | |
| 356 | + | move |host: &str, glob: &str, app: &str, version: &str| -> Result<(), Box<EvalAltResult>> { | |
| 357 | + | ctx.collect(host, glob, app, version).map_err(rhai_err) | |
| 358 | + | }, | |
| 359 | + | ); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // --- publish(channel, app, target, version, artifact, meta) --- | |
| 363 | + | { | |
| 364 | + | let ctx = ctx.clone(); | |
| 365 | + | engine.register_fn( | |
| 366 | + | "publish", | |
| 367 | + | move |channel: &str, app: &str, target: &str, version: &str, artifact: &str, meta: Map| -> Result<String, Box<EvalAltResult>> { | |
| 368 | + | ctx.publish(channel, app, target, version, artifact, meta).map_err(rhai_err) | |
| 369 | + | }, | |
| 370 | + | ); | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // --- macOS signing helpers (run on the named host; exercised once the Mac | |
| 374 | + | // keychain blocker clears — see _private/docs/bento/design.md §3/§7) --- | |
| 375 | + | register_macos_fns(&mut engine, &ctx); | |
| 376 | + | ||
| 377 | + | engine | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | impl RecipeCtx { | |
| 381 | + | fn collect(self: &Arc<Self>, host: &str, glob: &str, app: &str, version: &str) -> Result<()> { | |
| 382 | + | let dest = self.cfg.dist_root.join(app).join(version); | |
| 383 | + | let dest_s = dest.to_string_lossy().into_owned(); | |
| 384 | + | let h = self.host(host)?; | |
| 385 | + | // The daemon does the transfer locally: cp for a local host, scp for a | |
| 386 | + | // remote one. (Remote glob expansion happens on the remote shell.) | |
| 387 | + | let cmd = if h.is_local() { | |
| 388 | + | let g = ops_core::remote::sh_quote(glob); | |
| 389 | + | let d = ops_core::remote::sh_quote(&dest_s); | |
| 390 | + | format!("mkdir -p {d} && cp -vR {g} {d}/") | |
| 391 | + | } else { | |
| 392 | + | let d = ops_core::remote::sh_quote(&dest_s); | |
| 393 | + | format!( | |
| 394 | + | "mkdir -p {d} && scp -r {flags} {tgt}:{glob} {d}/", | |
| 395 | + | flags = ops_core::remote::SSH_FLAGS.join(" "), | |
| 396 | + | tgt = h.ssh_target(), | |
| 397 | + | ) | |
| 398 | + | }; | |
| 399 | + | // The daemon always runs the transfer itself (local cp or local scp), | |
| 400 | + | // regardless of which host built the artifact. | |
| 401 | + | let sink = self.ensure_step()?; | |
| 402 | + | let local = RemoteHost::new("local"); | |
| 403 | + | let out = self.rt.block_on(async move { local.run_streaming(&cmd, sink).await })?; | |
| 404 | + | if !out.success() { | |
| 405 | + | anyhow::bail!("collect failed (exit {:?})", out.status.code()); | |
| 406 | + | } | |
| 407 | + | // Best-effort size accounting for the event. | |
| 408 | + | events::emit( | |
| 409 | + | &self.events, | |
| 410 | + | Event::ArtifactCollected { | |
| 411 | + | app: self.app.clone(), | |
| 412 | + | target: self.target, | |
| 413 | + | path: dest_s, | |
| 414 | + | bytes: dir_size(&dest).unwrap_or(0), | |
| 415 | + | }, | |
| 416 | + | ); | |
| 417 | + | Ok(()) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | fn publish( | |
| 421 | + | self: &Arc<Self>, | |
| 422 | + | channel: &str, | |
| 423 | + | app: &str, | |
| 424 | + | target: &str, | |
| 425 | + | version: &str, | |
| 426 | + | artifact: &str, | |
| 427 | + | meta: Map, | |
| 428 | + | ) -> Result<String> { | |
| 429 | + | let backend = self | |
| 430 | + | .ota | |
| 431 | + | .get(channel) | |
| 432 | + | .ok_or_else(|| anyhow::anyhow!("unknown publish channel `{channel}`"))?; | |
| 433 | + | let target: Target = target.parse().map_err(|e: String| anyhow::anyhow!(e))?; | |
| 434 | + | let version = Version::parse(version).map_err(|e| anyhow::anyhow!(e))?; | |
| 435 | + | let app = AppId::new(app); | |
| 436 | + | let notes = meta.get("notes").and_then(|v| v.clone().into_string().ok()).unwrap_or_default(); | |
| 437 | + | // Resolve the artifact relative to the collected dist dir if not absolute. | |
| 438 | + | let artifact_path = { | |
| 439 | + | let p = PathBuf::from(artifact); | |
| 440 | + | if p.is_absolute() { | |
| 441 | + | p | |
| 442 | + | } else { | |
| 443 | + | self.cfg.dist_root.join(app.as_str()).join(version.to_string()).join(artifact) | |
| 444 | + | } | |
| 445 | + | }; | |
| 446 | + | let rel = Release { app: &app, target, version: &version, notes }; | |
| 447 | + | let receipt = backend | |
| 448 | + | .publish(&rel, &artifact_path) | |
| 449 | + | .with_context(|| format!("publish to `{channel}`"))?; | |
| 450 | + | // Record for idempotency / monotonicity. | |
| 451 | + | let me = self.clone(); | |
| 452 | + | let (app_s, target_s, ver_s, chan_s) = | |
| 453 | + | (app.to_string(), target.to_string(), version.to_string(), channel.to_string()); | |
| 454 | + | self.rt.block_on(async move { | |
| 455 | + | let _ = sqlx::query( | |
| 456 | + | "INSERT OR IGNORE INTO releases (app, target, version, channel, published_at) | |
| 457 | + | VALUES (?, ?, ?, ?, ?)", | |
| 458 | + | ) | |
| 459 | + | .bind(app_s) | |
| 460 | + | .bind(target_s) | |
| 461 | + | .bind(ver_s) | |
| 462 | + | .bind(chan_s) | |
| 463 | + | .bind(Self::now()) | |
| 464 | + | .execute(&me.pool) | |
| 465 | + | .await; | |
| 466 | + | }); | |
| 467 | + | events::emit( | |
| 468 | + | &self.events, | |
| 469 | + | Event::PublishOk { app: self.app.clone(), target: self.target, channel: channel.to_string() }, | |
| 470 | + | ); | |
| 471 | + | Ok(receipt) | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | fn dir_size(p: &Path) -> Option<i64> { | |
| 476 | + | let mut total = 0i64; | |
| 477 | + | for entry in std::fs::read_dir(p).ok()? { | |
| 478 | + | let entry = entry.ok()?; | |
| 479 | + | let md = entry.metadata().ok()?; | |
| 480 | + | if md.is_file() { | |
| 481 | + | total += md.len() as i64; | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | Some(total) | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | /// macOS signing/notarization host functions. Thin wrappers over the right | |
| 488 | + | /// shell incantations, run on the named host. Not exercised this session (the | |
| 489 | + | /// Mac keychain blocker, design §3, is uncleared) but staged so the macOS | |
| 490 | + | /// recipe runs unmodified once it is. | |
| 491 | + | fn register_macos_fns(engine: &mut Engine, ctx: &Arc<RecipeCtx>) { | |
| 492 | + | { | |
| 493 | + | let ctx = ctx.clone(); | |
| 494 | + | engine.register_fn( | |
| 495 | + | "verify_gatekeeper", | |
| 496 | + | move |host: &str, path: &str| -> Result<bool, Box<EvalAltResult>> { | |
| 497 | + | let (_, tail) = ctx | |
| 498 | + | .run(host, &format!("spctl --assess -vv --type install {} 2>&1 || true", ops_core::remote::sh_quote(path))) | |
| 499 | + | .map_err(rhai_err)?; | |
| 500 | + | Ok(tail.contains("source=Notarized Developer ID")) |
Lines truncated
| @@ -1,0 +1,27 @@ | |||
| 1 | + | use axum::http::StatusCode; | |
| 2 | + | use axum::response::{IntoResponse, Response}; | |
| 3 | + | ||
| 4 | + | #[derive(Debug, thiserror::Error)] | |
| 5 | + | pub enum Error { | |
| 6 | + | #[error("not found")] | |
| 7 | + | NotFound, | |
| 8 | + | #[error("bad request: {0}")] | |
| 9 | + | BadRequest(String), | |
| 10 | + | #[error(transparent)] | |
| 11 | + | Db(#[from] sqlx::Error), | |
| 12 | + | #[error(transparent)] | |
| 13 | + | Other(#[from] anyhow::Error), | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | impl IntoResponse for Error { | |
| 17 | + | fn into_response(self) -> Response { | |
| 18 | + | let status = match &self { | |
| 19 | + | Error::NotFound => StatusCode::NOT_FOUND, | |
| 20 | + | Error::BadRequest(_) => StatusCode::BAD_REQUEST, | |
| 21 | + | _ => StatusCode::INTERNAL_SERVER_ERROR, | |
| 22 | + | }; | |
| 23 | + | (status, self.to_string()).into_response() | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | pub type Result<T> = std::result::Result<T, Error>; |
| @@ -1,0 +1,43 @@ | |||
| 1 | + | //! Bento's concrete event payload, carried on the generic | |
| 2 | + | //! [`ops_core::eventbus`] bus. Flat `kind`-tagged so it serializes to the wire | |
| 3 | + | //! shape the TUI parses (`{"kind":"step_start", ...}`). | |
| 4 | + | ||
| 5 | + | use crate::domain::{AppId, Status, Step, StepRunId, Target, Version}; | |
| 6 | + | use ops_core::eventbus; | |
| 7 | + | use serde::{Deserialize, Serialize}; | |
| 8 | + | ||
| 9 | + | pub type EventEnvelope = eventbus::EventEnvelope<Event>; | |
| 10 | + | pub type EventTx = eventbus::EventTx<Event>; | |
| 11 | + | ||
| 12 | + | pub fn channel() -> EventTx { | |
| 13 | + | eventbus::channel() | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | pub fn emit(tx: &EventTx, event: Event) { | |
| 17 | + | eventbus::emit(tx, event) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] | |
| 21 | + | #[serde(tag = "kind", rename_all = "snake_case")] | |
| 22 | + | pub enum Event { | |
| 23 | + | /// A `/build` was accepted. | |
| 24 | + | BuildRequested { app: AppId, version: Version, targets: Vec<Target> }, | |
| 25 | + | /// A previous in-flight run for this `(app, target)` was aborted because a | |
| 26 | + | /// newer request arrived. | |
| 27 | + | TargetAborted { app: AppId, target: Target }, | |
| 28 | + | TargetStart { app: AppId, version: Version, target: Target }, | |
| 29 | + | StepStart { run_id: StepRunId, app: AppId, version: Version, target: Target, step: Step }, | |
| 30 | + | /// A chunk of merged stdout+stderr from the step currently running. | |
| 31 | + | /// `run_id` ties back to the `StepStart`; `seq` is a per-run monotonic | |
| 32 | + | /// counter; `text` is UTF-8-lossy and NOT line-aligned. The on-disk log is | |
| 33 | + | /// the byte-exact source. | |
| 34 | + | StepLogChunk { run_id: StepRunId, seq: u32, text: String }, | |
| 35 | + | StepDone { run_id: StepRunId, app: AppId, target: Target, step: Step, status: Status }, | |
| 36 | + | TargetOk { app: AppId, version: Version, target: Target, artifacts: Vec<String> }, | |
| 37 | + | TargetFailed { app: AppId, version: Version, target: Target, step: Step, error: String }, | |
| 38 | + | /// Notarization is the one flaky, network-bound step; retries are surfaced. | |
| 39 | + | NotarizeRetry { app: AppId, target: Target, attempt: u32, reason: String }, | |
| 40 | + | ArtifactCollected { app: AppId, target: Target, path: String, bytes: i64 }, | |
| 41 | + | PublishOk { app: AppId, target: Target, channel: String }, | |
| 42 | + | PublishFailed { app: AppId, target: Target, channel: String, error: String }, | |
| 43 | + | } |
| @@ -1,0 +1,19 @@ | |||
| 1 | + | //! bento-daemon as a library. | |
| 2 | + | //! | |
| 3 | + | //! Exposes every module so the `bentod` binary (`src/main.rs`) and the `bento` | |
| 4 | + | //! TUI (`../tui`) share wire-facing types — events, domain newtypes — by import | |
| 5 | + | //! rather than duplication. External consumers mainly need `domain` and | |
| 6 | + | //! `events`. | |
| 7 | + | ||
| 8 | + | pub mod config; | |
| 9 | + | pub mod db; | |
| 10 | + | pub mod domain; | |
| 11 | + | pub mod engine; | |
| 12 | + | pub mod error; | |
| 13 | + | pub mod events; | |
| 14 | + | pub mod metrics; | |
| 15 | + | pub mod ota; | |
| 16 | + | pub mod routes; | |
| 17 | + | pub mod runner; | |
| 18 | + | pub mod state; | |
| 19 | + | pub mod topology; |
| @@ -1,0 +1,46 @@ | |||
| 1 | + | use anyhow::Result; | |
| 2 | + | use bento_daemon::{config, db, events, metrics, ota, routes, state, topology}; | |
| 3 | + | use std::collections::HashMap; | |
| 4 | + | use std::net::SocketAddr; | |
| 5 | + | use std::sync::Arc; | |
| 6 | + | ||
| 7 | + | /// MNW base URL the `tauri-mnw` OTA backend publishes to. Overridable so a | |
| 8 | + | /// staging host can point elsewhere; defaults to production. | |
| 9 | + | fn mnw_base_url() -> String { | |
| 10 | + | std::env::var("BENTO_MNW_BASE_URL").unwrap_or_else(|_| "https://makenot.work".into()) | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | #[tokio::main] | |
| 14 | + | async fn main() -> Result<()> { | |
| 15 | + | tracing_subscriber::fmt() | |
| 16 | + | .with_writer(std::io::stderr) | |
| 17 | + | .with_env_filter( | |
| 18 | + | tracing_subscriber::EnvFilter::try_from_default_env() | |
| 19 | + | .unwrap_or_else(|_| "bento_daemon=info,bentod=info,tower_http=info".into()), | |
| 20 | + | ) | |
| 21 | + | .init(); | |
| 22 | + | ||
| 23 | + | let cfg = Arc::new(config::Config::load()?); | |
| 24 | + | let topo = Arc::new(topology::Topology::load(&cfg.topology_path)?); | |
| 25 | + | tokio::fs::create_dir_all(&cfg.dist_root).await?; | |
| 26 | + | tokio::fs::create_dir_all(&cfg.logs_root).await?; | |
| 27 | + | let pool = db::open(&cfg.db_path).await?; | |
| 28 | + | tracing::info!(hosts = topo.hosts.len(), apps = topo.app.len(), "topology loaded"); | |
| 29 | + | ||
| 30 | + | let prom = metrics::init(); | |
| 31 | + | let addr: SocketAddr = cfg.listen.parse()?; | |
| 32 | + | let app_state = state::AppState { | |
| 33 | + | pool, | |
| 34 | + | topo, | |
| 35 | + | cfg, | |
| 36 | + | prom, | |
| 37 | + | events: events::channel(), | |
| 38 | + | ota: Arc::new(ota::OtaRegistry::standard(mnw_base_url())), | |
| 39 | + | active: Arc::new(tokio::sync::Mutex::new(HashMap::new())), | |
| 40 | + | }; | |
| 41 | + | let app = routes::router(app_state); | |
| 42 | + | tracing::info!(%addr, "bento daemon listening"); | |
| 43 | + | let listener = tokio::net::TcpListener::bind(addr).await?; | |
| 44 | + | axum::serve(listener, app).await?; | |
| 45 | + | Ok(()) | |
| 46 | + | } |
| @@ -1,0 +1,19 @@ | |||
| 1 | + | use axum::{extract::State, response::IntoResponse}; | |
| 2 | + | use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle}; | |
| 3 | + | ||
| 4 | + | pub fn init() -> PrometheusHandle { | |
| 5 | + | PrometheusBuilder::new().install_recorder().expect("install prometheus recorder") | |
| 6 | + | } | |
| 7 | + | ||
| 8 | + | pub async fn render(State(handle): State<PrometheusHandle>) -> impl IntoResponse { | |
| 9 | + | handle.render() | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | /// A render handle that does NOT install the global recorder — the global is a | |
| 13 | + | /// process-wide singleton, so tests (many `AppState`s per process) must not | |
| 14 | + | /// call [`init`]. Metrics emitted via the macros are dropped under this handle, | |
| 15 | + | /// which is fine for tests that only exercise routes. | |
| 16 | + | #[cfg(test)] | |
| 17 | + | pub fn test_handle() -> PrometheusHandle { | |
| 18 | + | PrometheusBuilder::new().build_recorder().handle() | |
| 19 | + | } |
| @@ -1,0 +1,128 @@ | |||
| 1 | + | //! Release-channel abstraction. | |
| 2 | + | //! | |
| 3 | + | //! The publish step is not hardcoded to one updater or store. A recipe calls | |
| 4 | + | //! `publish("<channel>", ...)`; the daemon dispatches to the named | |
| 5 | + | //! [`OtaBackend`]. The flaky/lying-exit-code lessons (altool exits 0 on | |
| 6 | + | //! failure; notarytool needs retry) get encoded inside the backends so every | |
| 7 | + | //! recipe inherits them. | |
| 8 | + | //! | |
| 9 | + | //! P1 status: the trait, the registry, and a `tauri-mnw` backend skeleton are | |
| 10 | + | //! here so recipes can name a channel and the wiring is exercised end-to-end. | |
| 11 | + | //! The actual artifact upload to the MNW OTA API (`server/routes/ota.rs`) is | |
| 12 | + | //! P2 — [`TauriMnwBackend::publish`] currently performs the guardrail checks | |
| 13 | + | //! and returns a descriptive receipt without transferring bytes. | |
| 14 | + | ||
| 15 | + | use crate::domain::{AppId, Target, Version}; | |
| 16 | + | use anyhow::Result; | |
| 17 | + | use std::collections::HashMap; | |
| 18 | + | use std::path::Path; | |
| 19 | + | ||
| 20 | + | /// One signed artifact ready to publish for `(app, target, version)`. | |
| 21 | + | pub struct Release<'a> { | |
| 22 | + | pub app: &'a AppId, | |
| 23 | + | pub target: Target, | |
| 24 | + | pub version: &'a Version, | |
| 25 | + | pub notes: String, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// A delivery system. Adding one (`testflight`, `play`, `static-manifest`, | |
| 29 | + | /// `github-releases`, …) is a single `impl` plus registering its id; recipes | |
| 30 | + | /// don't change. | |
| 31 | + | pub trait OtaBackend: Send + Sync { | |
| 32 | + | fn id(&self) -> &str; | |
| 33 | + | fn supports(&self, target: Target) -> bool; | |
| 34 | + | /// Publish one artifact; return a channel-specific receipt string. Must be | |
| 35 | + | /// idempotent at the backend boundary (re-publishing the same | |
| 36 | + | /// version+artifact is a no-op, not a duplicate release). | |
| 37 | + | fn publish(&self, rel: &Release, artifact: &Path) -> Result<String>; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Named lookup of registered backends. | |
| 41 | + | #[derive(Default)] | |
| 42 | + | pub struct OtaRegistry { | |
| 43 | + | backends: HashMap<String, Box<dyn OtaBackend>>, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl OtaRegistry { | |
| 47 | + | pub fn new() -> Self { | |
| 48 | + | Self::default() | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | pub fn register(&mut self, backend: Box<dyn OtaBackend>) { | |
| 52 | + | self.backends.insert(backend.id().to_string(), backend); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | pub fn get(&self, channel: &str) -> Option<&dyn OtaBackend> { | |
| 56 | + | self.backends.get(channel).map(|b| b.as_ref()) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// The standard registry: `tauri-mnw` wired to the MNW OTA endpoint. | |
| 60 | + | pub fn standard(mnw_base_url: impl Into<String>) -> Self { | |
| 61 | + | let mut reg = Self::new(); | |
| 62 | + | reg.register(Box::new(TauriMnwBackend { base_url: mnw_base_url.into() })); | |
| 63 | + | reg | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Hook into Tauri's OTA system via the MNW server (the manifest host + | |
| 68 | + | /// release registry). The Tauri updater polls | |
| 69 | + | /// `GET /api/v1/sync/ota/{slug}/{target}/{arch}/{current}` and verifies a | |
| 70 | + | /// minisign signature; this backend registers the release + uploads the signed | |
| 71 | + | /// `*.app.tar.gz` (+ its `.sig`) so that endpoint serves the right manifest. | |
| 72 | + | pub struct TauriMnwBackend { | |
| 73 | + | pub base_url: String, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | impl OtaBackend for TauriMnwBackend { | |
| 77 | + | fn id(&self) -> &str { | |
| 78 | + | "tauri-mnw" | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | fn supports(&self, target: Target) -> bool { | |
| 82 | + | use crate::domain::Platform::*; | |
| 83 | + | // Tauri's updater covers the desktop trio; mobile rides testflight/play. | |
| 84 | + | matches!(target.platform, Macos | Linux | Windows) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | fn publish(&self, rel: &Release, artifact: &Path) -> Result<String> { | |
| 88 | + | // Guardrail: never publish an artifact that isn't there. | |
| 89 | + | anyhow::ensure!(artifact.exists(), "artifact {} does not exist", artifact.display()); | |
| 90 | + | // P2: POST the release + upload the artifact + its minisign .sig to the | |
| 91 | + | // MNW OTA API at `self.base_url`. Until then, report what would happen | |
| 92 | + | // so the recipe path is exercised without a half-published release. | |
| 93 | + | Ok(format!( | |
| 94 | + | "tauri-mnw: would publish {app} {ver} {target} ({artifact}) to {base} [P2: upload not yet wired]", | |
| 95 | + | app = rel.app, | |
| 96 | + | ver = rel.version, | |
| 97 | + | target = rel.target, | |
| 98 | + | artifact = artifact.display(), | |
| 99 | + | base = self.base_url, | |
| 100 | + | )) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | #[cfg(test)] | |
| 105 | + | mod tests { | |
| 106 | + | use super::*; | |
| 107 | + | ||
| 108 | + | #[test] | |
| 109 | + | fn standard_registry_has_tauri_mnw() { | |
| 110 | + | let reg = OtaRegistry::standard("https://makenot.work"); | |
| 111 | + | let b = reg.get("tauri-mnw").expect("registered"); | |
| 112 | + | assert_eq!(b.id(), "tauri-mnw"); | |
| 113 | + | assert!(b.supports("macos/aarch64".parse().unwrap())); | |
| 114 | + | assert!(b.supports("linux/x86_64".parse().unwrap())); | |
| 115 | + | assert!(!b.supports("ios/universal".parse().unwrap())); | |
| 116 | + | assert!(reg.get("nope").is_none()); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | #[test] | |
| 120 | + | fn publish_refuses_missing_artifact() { | |
| 121 | + | let reg = OtaRegistry::standard("https://makenot.work"); | |
| 122 | + | let b = reg.get("tauri-mnw").unwrap(); | |
| 123 | + | let app = AppId::new("goingson"); | |
| 124 | + | let ver = Version::parse("0.4.1").unwrap(); | |
| 125 | + | let rel = Release { app: &app, target: "macos/aarch64".parse().unwrap(), version: &ver, notes: String::new() }; | |
| 126 | + | assert!(b.publish(&rel, Path::new("/no/such/file")).is_err()); | |
| 127 | + | } | |
| 128 | + | } |
| @@ -1,0 +1,317 @@ | |||
| 1 | + | //! HTTP + WS contract (mirrors Sando's shape). | |
| 2 | + | //! | |
| 3 | + | //! - `GET /state` — latest build + its target x step matrix (loose JSON). | |
| 4 | + | //! - `POST /build` — `{app, version?, targets?[]}` kick a build. | |
| 5 | + | //! - `POST /retry` — `{app, target, version?}` re-run one target. | |
| 6 | + | //! - `GET /logs/{app}/{version}/{target}/{step}` — post-mortem step log. | |
| 7 | + | //! - `GET /events` — WS; broadcasts `EventEnvelope` JSON frames. | |
| 8 | + | //! - `GET /metrics` — Prometheus. | |
| 9 | + | ||
| 10 | + | use crate::domain::{AppId, Target}; | |
| 11 | + | use crate::error::{Error, Result}; | |
| 12 | + | use crate::runner; | |
| 13 | + | use crate::state::AppState; | |
| 14 | + | use axum::extract::{Path, State, WebSocketUpgrade}; | |
| 15 | + | use axum::response::IntoResponse; | |
| 16 | + | use axum::routing::{get, post}; | |
| 17 | + | use axum::{Json, Router}; | |
| 18 | + | use serde::{Deserialize, Serialize}; | |
| 19 | + | use sqlx::Row; | |
| 20 | + | ||
| 21 | + | pub fn router(state: AppState) -> Router { | |
| 22 | + | let prom = state.prom.clone(); | |
| 23 | + | Router::new() | |
| 24 | + | .route("/state", get(get_state)) | |
| 25 | + | .route("/build", post(build)) | |
| 26 | + | .route("/retry", post(retry)) | |
| 27 | + | .route("/logs/{app}/{version}/{target}/{step}", get(get_step_log)) | |
| 28 | + | .route("/events", get(events_ws)) | |
| 29 | + | .with_state(state) | |
| 30 | + | .route("/metrics", get(crate::metrics::render).with_state(prom)) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | #[derive(Serialize)] | |
| 34 | + | struct StateView { | |
| 35 | + | build: Option<BuildView>, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | #[derive(Serialize)] | |
| 39 | + | struct BuildView { | |
| 40 | + | id: i64, | |
| 41 | + | app: String, | |
| 42 | + | version: String, | |
| 43 | + | status: String, | |
| 44 | + | created_at: String, | |
| 45 | + | targets: Vec<TargetView>, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | #[derive(Serialize)] | |
| 49 | + | struct TargetView { | |
| 50 | + | target: String, | |
| 51 | + | status: String, | |
| 52 | + | current_step: Option<String>, | |
| 53 | + | error: Option<String>, | |
| 54 | + | steps: Vec<StepView>, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[derive(Serialize)] | |
| 58 | + | struct StepView { | |
| 59 | + | run_id: i64, | |
| 60 | + | step: String, | |
| 61 | + | status: String, | |
| 62 | + | log_ref: Option<String>, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | async fn get_state(State(s): State<AppState>) -> Result<Json<StateView>> { | |
| 66 | + | let latest = sqlx::query( | |
| 67 | + | "SELECT id, app, version, status, created_at FROM builds ORDER BY id DESC LIMIT 1", | |
| 68 | + | ) | |
| 69 | + | .fetch_optional(&s.pool) | |
| 70 | + | .await?; | |
| 71 | + | ||
| 72 | + | let Some(b) = latest else { | |
| 73 | + | return Ok(Json(StateView { build: None })); | |
| 74 | + | }; | |
| 75 | + | let build_id: i64 = b.get("id"); | |
| 76 | + | ||
| 77 | + | let target_rows = sqlx::query( | |
| 78 | + | "SELECT id, target, status, current_step, error FROM target_runs | |
| 79 | + | WHERE build_id = ? ORDER BY target", | |
| 80 | + | ) | |
| 81 | + | .bind(build_id) | |
| 82 | + | .fetch_all(&s.pool) | |
| 83 | + | .await?; | |
| 84 | + | ||
| 85 | + | let mut targets = Vec::with_capacity(target_rows.len()); | |
| 86 | + | for tr in target_rows { | |
| 87 | + | let target_run_id: i64 = tr.get("id"); | |
| 88 | + | // Latest row per step for this target run. | |
| 89 | + | let steps: Vec<StepView> = sqlx::query( | |
| 90 | + | "SELECT id, step, status, log_ref FROM step_runs sr | |
| 91 | + | WHERE target_run_id = ?1 | |
| 92 | + | AND id = (SELECT MAX(id) FROM step_runs | |
| 93 | + | WHERE target_run_id = ?1 AND step = sr.step) | |
| 94 | + | ORDER BY id", | |
| 95 | + | ) | |
| 96 | + | .bind(target_run_id) | |
| 97 | + | .fetch_all(&s.pool) | |
| 98 | + | .await? | |
| 99 | + | .into_iter() | |
| 100 | + | .map(|r| StepView { | |
| 101 | + | run_id: r.get("id"), | |
| 102 | + | step: r.get("step"), | |
| 103 | + | status: r.get("status"), | |
| 104 | + | log_ref: r.get("log_ref"), | |
| 105 | + | }) | |
| 106 | + | .collect(); | |
| 107 | + | ||
| 108 | + | targets.push(TargetView { | |
| 109 | + | target: tr.get("target"), | |
| 110 | + | status: tr.get("status"), | |
| 111 | + | current_step: tr.get("current_step"), | |
| 112 | + | error: tr.get("error"), | |
| 113 | + | steps, | |
| 114 | + | }); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | Ok(Json(StateView { | |
| 118 | + | build: Some(BuildView { | |
| 119 | + | id: build_id, | |
| 120 | + | app: b.get("app"), | |
| 121 | + | version: b.get("version"), | |
| 122 | + | status: b.get("status"), | |
| 123 | + | created_at: b.get("created_at"), | |
| 124 | + | targets, | |
| 125 | + | }), | |
| 126 | + | })) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | #[derive(Deserialize, Default)] | |
| 130 | + | struct BuildBody { | |
| 131 | + | app: String, | |
| 132 | + | #[serde(default)] | |
| 133 | + | version: Option<String>, | |
| 134 | + | #[serde(default)] | |
| 135 | + | targets: Vec<String>, | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | async fn build(State(s): State<AppState>, Json(body): Json<BuildBody>) -> Result<Json<serde_json::Value>> { | |
| 139 | + | let app = AppId::new(body.app); | |
| 140 | + | let targets = parse_targets(body.targets)?; | |
| 141 | + | let version = runner::resolve_version(&s, &app, body.version).map_err(Error::Other)?; | |
| 142 | + | let targets = runner::resolve_targets(&s, &app, targets).map_err(Error::Other)?; | |
| 143 | + | let build_id = runner::start_build(s, app, version.clone(), targets) | |
| 144 | + | .await | |
| 145 | + | .map_err(Error::Other)?; | |
| 146 | + | Ok(Json(serde_json::json!({ "accepted": true, "build_id": build_id, "version": version.to_string() }))) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[derive(Deserialize)] | |
| 150 | + | struct RetryBody { | |
| 151 | + | app: String, | |
| 152 | + | target: String, | |
| 153 | + | #[serde(default)] | |
| 154 | + | version: Option<String>, | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | async fn retry(State(s): State<AppState>, Json(body): Json<RetryBody>) -> Result<Json<serde_json::Value>> { | |
| 158 | + | let app = AppId::new(body.app); | |
| 159 | + | let target: Target = body.target.parse().map_err(Error::BadRequest)?; | |
| 160 | + | let version = runner::resolve_version(&s, &app, body.version).map_err(Error::Other)?; | |
| 161 | + | let targets = runner::resolve_targets(&s, &app, vec![target]).map_err(Error::Other)?; | |
| 162 | + | let build_id = runner::start_build(s, app, version.clone(), targets) | |
| 163 | + | .await | |
| 164 | + | .map_err(Error::Other)?; | |
| 165 | + | Ok(Json(serde_json::json!({ "accepted": true, "build_id": build_id, "target": target.to_string() }))) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | fn parse_targets(raw: Vec<String>) -> Result<Vec<Target>> { | |
| 169 | + | raw.into_iter().map(|t| t.parse::<Target>().map_err(Error::BadRequest)).collect() | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | async fn get_step_log( | |
| 173 | + | State(s): State<AppState>, | |
| 174 | + | Path((app, version, target, step)): Path<(String, String, String, String)>, | |
| 175 | + | ) -> Result<axum::response::Response> { | |
| 176 | + | fn safe(seg: &str) -> bool { | |
| 177 | + | !seg.is_empty() && !seg.contains('/') && !seg.contains('\\') && seg != "." && seg != ".." | |
| 178 | + | } | |
| 179 | + | if ![&app, &version, &target, &step].into_iter().all(|s| safe(s)) { | |
| 180 | + | return Err(Error::NotFound); | |
| 181 | + | } | |
| 182 | + | let path = s.cfg.logs_root.join(&app).join(&version).join(&target).join(format!("{step}.log")); | |
| 183 | + | match tokio::fs::read(&path).await { | |
| 184 | + | Ok(bytes) => Ok(( | |
| 185 | + | [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], | |
| 186 | + | bytes, | |
| 187 | + | ) | |
| 188 | + | .into_response()), | |
| 189 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Error::NotFound), | |
| 190 | + | Err(e) => Err(Error::Other(e.into())), | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | async fn events_ws(ws: WebSocketUpgrade, State(s): State<AppState>) -> impl IntoResponse { | |
| 195 | + | use axum::extract::ws::Message; | |
| 196 | + | use tokio::sync::broadcast::error::RecvError; | |
| 197 | + | ||
| 198 | + | ws.on_upgrade(move |mut socket| async move { | |
| 199 | + | let mut rx = s.events.subscribe(); | |
| 200 | + | loop { | |
| 201 | + | match rx.recv().await { | |
| 202 | + | Ok(env) => { | |
| 203 | + | let json = match serde_json::to_string(&env) { | |
| 204 | + | Ok(s) => s, | |
| 205 | + | Err(e) => { | |
| 206 | + | tracing::warn!(error = %e, "events ws: serialize failed"); | |
| 207 | + | continue; | |
| 208 | + | } | |
| 209 | + | }; | |
| 210 | + | if socket.send(Message::Text(json.into())).await.is_err() { | |
| 211 | + | break; | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | Err(RecvError::Lagged(n)) => { | |
| 215 | + | let _ = socket | |
| 216 | + | .send(Message::Text(format!(r#"{{"kind":"lagged","skipped":{n}}}"#).into())) | |
| 217 | + | .await; | |
| 218 | + | } | |
| 219 | + | Err(RecvError::Closed) => break, | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | }) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | #[cfg(test)] | |
| 226 | + | mod tests { | |
| 227 | + | use super::*; | |
| 228 | + | use crate::config::Config; | |
| 229 | + | use crate::ota::OtaRegistry; | |
| 230 | + | use crate::topology::Topology; | |
| 231 | + | use axum::body::Body; | |
| 232 | + | use axum::http::{Request, StatusCode}; | |
| 233 | + | use http_body_util::BodyExt; | |
| 234 | + | use std::collections::HashMap; | |
| 235 | + | use std::sync::Arc; | |
| 236 | + | use tokio::sync::Mutex; | |
| 237 | + | use tower::ServiceExt; | |
| 238 | + | ||
| 239 | + | async fn test_state(root: &std::path::Path) -> AppState { | |
| 240 | + | let cfg = Config::for_tests(root); | |
| 241 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 242 | + | let topo: Topology = toml::from_str( | |
| 243 | + | r#" | |
| 244 | + | [[host]] | |
| 245 | + | name = "fw13" | |
| 246 | + | ssh = "local" | |
| 247 | + | targets = ["linux/x86_64"] | |
| 248 | + | ||
| 249 | + | [app.goingson] | |
| 250 | + | repo = "/tmp/none" | |
| 251 | + | targets = ["linux/x86_64"] | |
| 252 | + | "#, | |
| 253 | + | ) | |
| 254 | + | .unwrap(); | |
| 255 | + | AppState { | |
| 256 | + | pool, | |
| 257 | + | topo: Arc::new(topo), | |
| 258 | + | cfg: Arc::new(cfg), | |
| 259 | + | prom: crate::metrics::test_handle(), | |
| 260 | + | events: crate::events::channel(), | |
| 261 | + | ota: Arc::new(OtaRegistry::standard("https://makenot.work")), | |
| 262 | + | active: Arc::new(Mutex::new(HashMap::new())), | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | async fn body_string(resp: axum::response::Response) -> String { | |
| 267 | + | let bytes = resp.into_body().collect().await.unwrap().to_bytes(); | |
| 268 | + | String::from_utf8(bytes.to_vec()).unwrap() | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | #[tokio::test] | |
| 272 | + | async fn state_is_empty_initially() { | |
| 273 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 274 | + | let app = router(test_state(tmp.path()).await); | |
| 275 | + | let resp = app | |
| 276 | + | .oneshot(Request::builder().uri("/state").body(Body::empty()).unwrap()) | |
| 277 | + | .await | |
| 278 | + | .unwrap(); | |
| 279 | + | assert_eq!(resp.status(), StatusCode::OK); | |
| 280 | + | assert_eq!(body_string(resp).await, r#"{"build":null}"#); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | #[tokio::test] | |
| 284 | + | async fn step_log_rejects_traversal() { | |
| 285 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 286 | + | let app = router(test_state(tmp.path()).await); | |
| 287 | + | let resp = app | |
| 288 | + | .oneshot( | |
| 289 | + | Request::builder() | |
| 290 | + | .uri("/logs/goingson/0.4.1/..%2f..%2fetc/passwd") | |
| 291 | + | .body(Body::empty()) | |
| 292 | + | .unwrap(), | |
| 293 | + | ) | |
| 294 | + | .await | |
| 295 | + | .unwrap(); | |
| 296 | + | assert_eq!(resp.status(), StatusCode::NOT_FOUND); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | #[tokio::test] | |
| 300 | + | async fn build_rejects_unknown_target() { | |
| 301 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 302 | + | let app = router(test_state(tmp.path()).await); | |
| 303 | + | let resp = app | |
| 304 | + | .oneshot( | |
| 305 | + | Request::builder() | |
| 306 | + | .method("POST") | |
| 307 | + | .uri("/build") | |
| 308 | + | .header("content-type", "application/json") | |
| 309 | + | .body(Body::from(r#"{"app":"goingson","targets":["windows/x86_64"]}"#)) | |
| 310 | + | .unwrap(), | |
| 311 | + | ) | |
| 312 | + | .await | |
| 313 | + | .unwrap(); | |
| 314 | + | // windows/x86_64 isn't shipped by the test app -> 500 (Other). | |
| 315 | + | assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); | |
| 316 | + | } | |
| 317 | + | } |
| @@ -1,0 +1,360 @@ | |||
| 1 | + | //! Build orchestration: fan a `(app, version)` out across its targets, each | |
| 2 | + | //! running its recipe concurrently on the host that can build it. | |
| 3 | + | //! | |
| 4 | + | //! A build inserts one `builds` row, then spawns one task per target. Each | |
| 5 | + | //! target task registers itself in the single-slot guard (a newer build for | |
| 6 | + | //! the same `(app, target)` aborts the in-flight one — latest wins, but other | |
| 7 | + | //! targets keep running, which is the fan-out), then runs the recipe. | |
| 8 | + | ||
| 9 | + | use crate::domain::{AppId, Status, Step, Target, Version}; | |
| 10 | + | use crate::engine::{self, RecipeCtx}; | |
| 11 | + | use crate::events::{self, Event}; | |
| 12 | + | use crate::state::AppState; | |
| 13 | + | use anyhow::{Context, Result}; | |
| 14 | + | use std::path::PathBuf; | |
| 15 | + | use std::sync::Arc; | |
| 16 | + | ||
| 17 | + | /// Resolve the version to build: explicit, or read from `tauri.conf.json`. | |
| 18 | + | pub fn resolve_version(state: &AppState, app: &AppId, explicit: Option<String>) -> Result<Version> { | |
| 19 | + | if let Some(v) = explicit { | |
| 20 | + | return Version::parse(&v).map_err(|e| anyhow::anyhow!(e)); | |
| 21 | + | } | |
| 22 | + | let cfg = state.topo.app(app).ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?; | |
| 23 | + | engine::version_from_tauri_conf(&cfg.repo) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// Validate + default the target list against what the app ships. | |
| 27 | + | pub fn resolve_targets(state: &AppState, app: &AppId, requested: Vec<Target>) -> Result<Vec<Target>> { | |
| 28 | + | let cfg = state.topo.app(app).ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?; | |
| 29 | + | if requested.is_empty() { | |
| 30 | + | return Ok(cfg.targets.clone()); | |
| 31 | + | } | |
| 32 | + | for t in &requested { | |
| 33 | + | anyhow::ensure!(cfg.targets.contains(t), "app `{app}` does not ship target {t}"); | |
| 34 | + | anyhow::ensure!(state.topo.host_for(*t).is_some(), "no host can build {t}"); | |
| 35 | + | } | |
| 36 | + | Ok(requested) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// Insert the build row and spawn per-target tasks. Returns the build id. | |
| 40 | + | pub async fn start_build( | |
| 41 | + | state: AppState, | |
| 42 | + | app: AppId, | |
| 43 | + | version: Version, | |
| 44 | + | targets: Vec<Target>, | |
| 45 | + | ) -> Result<i64> { | |
| 46 | + | let build_id: i64 = sqlx::query_scalar( | |
| 47 | + | "INSERT INTO builds (app, version, status, created_at) VALUES (?, ?, 'running', ?) RETURNING id", | |
| 48 | + | ) | |
| 49 | + | .bind(app.as_str()) | |
| 50 | + | .bind(version.to_string()) | |
| 51 | + | .bind(chrono::Utc::now().to_rfc3339()) | |
| 52 | + | .fetch_one(&state.pool) | |
| 53 | + | .await | |
| 54 | + | .context("insert build")?; | |
| 55 | + | ||
| 56 | + | events::emit( | |
| 57 | + | &state.events, | |
| 58 | + | Event::BuildRequested { app: app.clone(), version: version.clone(), targets: targets.clone() }, | |
| 59 | + | ); | |
| 60 | + | ||
| 61 | + | for target in targets { | |
| 62 | + | let state = state.clone(); | |
| 63 | + | let app = app.clone(); | |
| 64 | + | let version = version.clone(); | |
| 65 | + | let key = (app.clone(), target); | |
| 66 | + | ||
| 67 | + | // Latest-wins: abort any in-flight run for this (app, target). | |
| 68 | + | { | |
| 69 | + | let mut active = state.active.lock().await; | |
| 70 | + | if let Some(prev) = active.remove(&key) { | |
| 71 | + | if !prev.is_finished() { | |
| 72 | + | events::emit( | |
| 73 | + | &state.events, | |
| 74 | + | Event::TargetAborted { app: app.clone(), target }, | |
| 75 | + | ); | |
| 76 | + | prev.abort(); | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | let handle = tokio::spawn(run_target(state.clone(), build_id, app, version, target)); | |
| 82 | + | state.active.lock().await.insert(key, handle.abort_handle()); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Mark the build done once all target tasks settle. Spawned so /build | |
| 86 | + | // returns immediately. | |
| 87 | + | tokio::spawn(finalize_build(state, build_id)); | |
| 88 | + | Ok(build_id) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Run one target's recipe end to end, updating its `target_runs` row. | |
| 92 | + | async fn run_target(state: AppState, build_id: i64, app: AppId, version: Version, target: Target) { | |
| 93 | + | let target_run_id: i64 = match sqlx::query_scalar( | |
| 94 | + | "INSERT INTO target_runs (build_id, app, version, target, status, started_at) | |
| 95 | + | VALUES (?, ?, ?, ?, 'running', ?) RETURNING id", | |
| 96 | + | ) | |
| 97 | + | .bind(build_id) | |
| 98 | + | .bind(app.as_str()) | |
| 99 | + | .bind(version.to_string()) | |
| 100 | + | .bind(target.to_string()) | |
| 101 | + | .bind(chrono::Utc::now().to_rfc3339()) | |
| 102 | + | .fetch_one(&state.pool) | |
| 103 | + | .await | |
| 104 | + | { | |
| 105 | + | Ok(id) => id, | |
| 106 | + | Err(e) => { | |
| 107 | + | tracing::error!(%app, %target, error = %e, "could not create target_run"); | |
| 108 | + | return; | |
| 109 | + | } | |
| 110 | + | }; | |
| 111 | + | ||
| 112 | + | events::emit( | |
| 113 | + | &state.events, | |
| 114 | + | Event::TargetStart { app: app.clone(), version: version.clone(), target }, | |
| 115 | + | ); | |
| 116 | + | ||
| 117 | + | let recipe_src = match read_recipe(&state, &app, target) { | |
| 118 | + | Ok(s) => s, | |
| 119 | + | Err(e) => { | |
| 120 | + | fail_target(&state, target_run_id, &app, &version, target, Step::Checkout, &format!("{e:#}")).await; | |
| 121 | + | return; | |
| 122 | + | } | |
| 123 | + | }; | |
| 124 | + | ||
| 125 | + | let ctx = Arc::new(RecipeCtx::new( | |
| 126 | + | app.clone(), | |
| 127 | + | version.clone(), | |
| 128 | + | target, | |
| 129 | + | target_run_id, | |
| 130 | + | state.topo.remote_hosts(), | |
| 131 | + | state.pool.clone(), | |
| 132 | + | state.events.clone(), | |
| 133 | + | state.cfg.clone(), | |
| 134 | + | state.ota.clone(), | |
| 135 | + | tokio::runtime::Handle::current(), | |
| 136 | + | )); | |
| 137 | + | ||
| 138 | + | // Rhai is synchronous; run the recipe (and its final step finalization) on | |
| 139 | + | // a blocking thread so host functions can `block_on` without sitting on a | |
| 140 | + | // runtime worker. | |
| 141 | + | let ctx_run = ctx.clone(); | |
| 142 | + | let outcome = tokio::task::spawn_blocking(move || { | |
| 143 | + | let engine = engine::build_engine(ctx_run.clone()); | |
| 144 | + | let res = engine.run(&recipe_src); | |
| 145 | + | let last_step = ctx_run.current_step(); | |
| 146 | + | match &res { | |
| 147 | + | Ok(_) => { | |
| 148 | + | let _ = ctx_run.finish_step(Status::Ok); | |
| 149 | + | } | |
| 150 | + | Err(_) => { | |
| 151 | + | let _ = ctx_run.finish_step(Status::Failed); | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | res.map(|_| ()).map_err(|e| (last_step, e.to_string())) | |
| 155 | + | }) | |
| 156 | + | .await; | |
| 157 | + | ||
| 158 | + | match outcome { | |
| 159 | + | Ok(Ok(())) => { | |
| 160 | + | let artifacts = collected_artifacts(&state, &app, &version); | |
| 161 | + | let _ = sqlx::query( | |
| 162 | + | "UPDATE target_runs SET status = 'ok', current_step = NULL, finished_at = ? WHERE id = ?", | |
| 163 | + | ) | |
| 164 | + | .bind(chrono::Utc::now().to_rfc3339()) | |
| 165 | + | .bind(target_run_id) | |
| 166 | + | .execute(&state.pool) | |
| 167 | + | .await; | |
| 168 | + | events::emit(&state.events, Event::TargetOk { app, version, target, artifacts }); | |
| 169 | + | } | |
| 170 | + | Ok(Err((step, msg))) => { | |
| 171 | + | fail_target(&state, target_run_id, &app, &version, target, step, &msg).await; | |
| 172 | + | } | |
| 173 | + | Err(join_err) => { | |
| 174 | + | // Task was aborted (superseded) or panicked. | |
| 175 | + | let msg = if join_err.is_cancelled() { "aborted (superseded)".to_string() } else { format!("recipe task panicked: {join_err}") }; | |
| 176 | + | fail_target(&state, target_run_id, &app, &version, target, Step::Build, &msg).await; | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | async fn fail_target( | |
| 182 | + | state: &AppState, | |
| 183 | + | target_run_id: i64, | |
| 184 | + | app: &AppId, | |
| 185 | + | version: &Version, | |
| 186 | + | target: Target, | |
| 187 | + | step: Step, | |
| 188 | + | error: &str, | |
| 189 | + | ) { | |
| 190 | + | let _ = sqlx::query( | |
| 191 | + | "UPDATE target_runs SET status = 'failed', error = ?, finished_at = ? WHERE id = ?", | |
| 192 | + | ) | |
| 193 | + | .bind(error) | |
| 194 | + | .bind(chrono::Utc::now().to_rfc3339()) | |
| 195 | + | .bind(target_run_id) | |
| 196 | + | .execute(&state.pool) | |
| 197 | + | .await; | |
| 198 | + | events::emit( | |
| 199 | + | &state.events, | |
| 200 | + | Event::TargetFailed { app: app.clone(), version: version.clone(), target, step, error: error.to_string() }, | |
| 201 | + | ); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// Wait for all target runs of a build to leave `running`, then stamp the | |
| 205 | + | /// build's terminal status. | |
| 206 | + | async fn finalize_build(state: AppState, build_id: i64) { | |
| 207 | + | loop { | |
| 208 | + | let running: i64 = sqlx::query_scalar( | |
| 209 | + | "SELECT COUNT(*) FROM target_runs WHERE build_id = ? AND status = 'running'", | |
| 210 | + | ) | |
| 211 | + | .bind(build_id) | |
| 212 | + | .fetch_one(&state.pool) | |
| 213 | + | .await | |
| 214 | + | .unwrap_or(0); | |
| 215 | + | if running == 0 { | |
| 216 | + | break; | |
| 217 | + | } | |
| 218 | + | tokio::time::sleep(std::time::Duration::from_millis(500)).await; | |
| 219 | + | } | |
| 220 | + | let failed: i64 = sqlx::query_scalar( | |
| 221 | + | "SELECT COUNT(*) FROM target_runs WHERE build_id = ? AND status = 'failed'", | |
| 222 | + | ) | |
| 223 | + | .bind(build_id) | |
| 224 | + | .fetch_one(&state.pool) | |
| 225 | + | .await | |
| 226 | + | .unwrap_or(0); | |
| 227 | + | let status = if failed == 0 { "ok" } else { "failed" }; | |
| 228 | + | let _ = sqlx::query("UPDATE builds SET status = ?, finished_at = ? WHERE id = ?") | |
| 229 | + | .bind(status) | |
| 230 | + | .bind(chrono::Utc::now().to_rfc3339()) | |
| 231 | + | .bind(build_id) | |
| 232 | + | .execute(&state.pool) | |
| 233 | + | .await; | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | /// Read the recipe text for `(app, target)` from the app's checkout on the | |
| 237 | + | /// daemon host. `<repo>/<recipe_dir>/<platform>.rhai`. | |
| 238 | + | fn read_recipe(state: &AppState, app: &AppId, target: Target) -> Result<String> { | |
| 239 | + | let cfg = state.topo.app(app).ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?; | |
| 240 | + | let path: PathBuf = engine::expand_tilde(&cfg.repo) | |
| 241 | + | .join(&cfg.recipe_dir) | |
| 242 | + | .join(format!("{}.rhai", target.platform.as_str())); | |
| 243 | + | std::fs::read_to_string(&path).with_context(|| format!("reading recipe {}", path.display())) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | fn collected_artifacts(state: &AppState, app: &AppId, version: &Version) -> Vec<String> { | |
| 247 | + | let dir = state.cfg.dist_root.join(app.as_str()).join(version.to_string()); | |
| 248 | + | let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() }; | |
| 249 | + | rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect() | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | #[cfg(test)] | |
| 253 | + | mod tests { | |
| 254 | + | use super::*; | |
| 255 | + | use crate::config::Config; | |
| 256 | + | use crate::ota::OtaRegistry; | |
| 257 | + | use crate::topology::Topology; | |
| 258 | + | use std::collections::HashMap; | |
| 259 | + | use std::sync::Arc; | |
| 260 | + | use tokio::sync::Mutex; | |
| 261 | + | ||
| 262 | + | /// Stand up a tmp app repo + topology and run a real local recipe end to | |
| 263 | + | /// end: step transitions, streamed `sh_ok`, `version_of`, `log`, and a | |
| 264 | + | /// `collect` that pulls a built artifact into dist_root. | |
| 265 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | |
| 266 | + | async fn local_linux_recipe_runs_end_to_end() { | |
| 267 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 268 | + | let root = tmp.path(); | |
| 269 | + | ||
| 270 | + | // Fake app checkout: tauri.conf.json + a linux recipe. | |
| 271 | + | let repo = root.join("app"); | |
| 272 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 273 | + | std::fs::write( | |
| 274 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 275 | + | r#"{"version":"0.0.1"}"#, | |
| 276 | + | ) | |
| 277 | + | .unwrap(); | |
| 278 | + | std::fs::create_dir_all(repo.join("dist/recipes")).unwrap(); | |
| 279 | + | // Build writes an artifact into the repo; collect pulls it to dist_root. | |
| 280 | + | std::fs::write( | |
| 281 | + | repo.join("dist/recipes/linux.rhai"), | |
| 282 | + | r#" | |
| 283 | + | step("build"); | |
| 284 | + | let v = version_of("demo"); | |
| 285 | + | log("building demo " + v); | |
| 286 | + | sh_ok("fw13", "echo compiling; mkdir -p REPO/out && echo bin > REPO/out/demo.bin"); | |
| 287 | + | step("collect"); | |
| 288 | + | collect("fw13", "REPO/out/demo.bin", "demo", v); | |
| 289 | + | "# | |
| 290 | + | .replace("REPO", repo.to_str().unwrap()), | |
| 291 | + | ) | |
| 292 | + | .unwrap(); | |
| 293 | + | ||
| 294 | + | let cfg = Config::for_tests(root); | |
| 295 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 296 | + | let topo: Topology = toml::from_str(&format!( | |
| 297 | + | r#" | |
| 298 | + | [[host]] | |
| 299 | + | name = "fw13" | |
| 300 | + | ssh = "local" | |
| 301 | + | targets = ["linux/x86_64"] | |
| 302 | + | ||
| 303 | + | [app.demo] | |
| 304 | + | repo = "{}" | |
| 305 | + | targets = ["linux/x86_64"] | |
| 306 | + | "#, | |
| 307 | + | repo.display() | |
| 308 | + | )) | |
| 309 | + | .unwrap(); | |
| 310 | + | ||
| 311 | + | let state = AppState { | |
| 312 | + | pool: pool.clone(), | |
| 313 | + | topo: Arc::new(topo), | |
| 314 | + | cfg: Arc::new(cfg), | |
| 315 | + | prom: crate::metrics::test_handle(), | |
| 316 | + | events: crate::events::channel(), | |
| 317 | + | ota: Arc::new(OtaRegistry::standard("https://makenot.work")), | |
| 318 | + | active: Arc::new(Mutex::new(HashMap::new())), | |
| 319 | + | }; | |
| 320 | + | ||
| 321 | + | let app = AppId::new("demo"); | |
| 322 | + | let version = Version::parse("0.0.1").unwrap(); | |
| 323 | + | let build_id = start_build(state.clone(), app, version, vec!["linux/x86_64".parse().unwrap()]) | |
| 324 | + | .await | |
| 325 | + | .unwrap(); | |
| 326 | + | ||
| 327 | + | // Wait for the target run to settle. | |
| 328 | + | let mut status = String::new(); | |
| 329 | + | for _ in 0..100 { | |
| 330 | + | status = sqlx::query_scalar("SELECT status FROM target_runs WHERE build_id = ?") | |
| 331 | + | .bind(build_id) | |
| 332 | + | .fetch_one(&pool) | |
| 333 | + | .await | |
| 334 | + | .unwrap(); | |
| 335 | + | if status != "running" { | |
| 336 | + | break; | |
| 337 | + | } | |
| 338 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; | |
| 339 | + | } | |
| 340 | + | assert_eq!(status, "ok", "target run should succeed"); | |
| 341 | + | ||
| 342 | + | // Both steps recorded and finished ok. | |
| 343 | + | let steps: Vec<(String, String)> = | |
| 344 | + | sqlx::query_as("SELECT step, status FROM step_runs WHERE target_run_id IN (SELECT id FROM target_runs WHERE build_id = ?) ORDER BY id") | |
| 345 | + | .bind(build_id) | |
| 346 | + | .fetch_all(&pool) | |
| 347 | + | .await | |
| 348 | + | .unwrap(); | |
| 349 | + | let names: Vec<&str> = steps.iter().map(|(s, _)| s.as_str()).collect(); | |
| 350 | + | assert_eq!(names, vec!["build", "collect"]); | |
| 351 | + | assert!(steps.iter().all(|(_, st)| st == "ok")); | |
| 352 | + | ||
| 353 | + | // Artifact landed in dist_root and a step log was written. | |
| 354 | + | let artifact = state.cfg.dist_root.join("demo/0.0.1/demo.bin"); | |
| 355 | + | assert!(artifact.exists(), "collect should copy the artifact"); | |
| 356 | + | let log = state.cfg.logs_root.join("demo/0.0.1/linux-x86_64/build.log"); | |
| 357 | + | assert!(log.exists(), "build step log should exist"); | |
| 358 | + | assert!(std::fs::read_to_string(&log).unwrap().contains("compiling")); | |
| 359 | + | } | |
| 360 | + | } |