max / makenotwork
30 files changed,
+5855 insertions,
-0 deletions
| @@ -0,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
| @@ -0,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"] } |
| @@ -0,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 | + | ); |
| @@ -0,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 | + | } |
| @@ -0,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 | + | } |
| @@ -0,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 | + | } |
| @@ -0,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
| @@ -0,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>; |
| @@ -0,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 | + | } |
| @@ -0,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; |
| @@ -0,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 | + | } |
| @@ -0,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 | + | } |
| @@ -0,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 | + | } |
| @@ -0,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 | + | } |
| @@ -0,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 | + | } |
| @@ -0,0 +1,25 @@ | |||
| 1 | + | use crate::config::Config; | |
| 2 | + | use crate::domain::{AppId, Target}; | |
| 3 | + | use crate::events::EventTx; | |
| 4 | + | use crate::ota::OtaRegistry; | |
| 5 | + | use crate::topology::Topology; | |
| 6 | + | use metrics_exporter_prometheus::PrometheusHandle; | |
| 7 | + | use sqlx::SqlitePool; | |
| 8 | + | use std::collections::HashMap; | |
| 9 | + | use std::sync::Arc; | |
| 10 | + | use tokio::sync::Mutex; | |
| 11 | + | use tokio::task::AbortHandle; | |
| 12 | + | ||
| 13 | + | #[derive(Clone)] | |
| 14 | + | pub struct AppState { | |
| 15 | + | pub pool: SqlitePool, | |
| 16 | + | pub topo: Arc<Topology>, | |
| 17 | + | pub cfg: Arc<Config>, | |
| 18 | + | pub prom: PrometheusHandle, | |
| 19 | + | pub events: EventTx, | |
| 20 | + | pub ota: Arc<OtaRegistry>, | |
| 21 | + | /// Single-slot guard per `(app, target)`: a newer build for the same | |
| 22 | + | /// target aborts the in-flight one (latest request wins), mirroring | |
| 23 | + | /// Sando's `active_build`. Other targets keep running — that's the fan-out. | |
| 24 | + | pub active: Arc<Mutex<HashMap<(AppId, Target), AbortHandle>>>, | |
| 25 | + | } |
| @@ -0,0 +1,150 @@ | |||
| 1 | + | //! Build matrix + host topology (`bento.toml`). | |
| 2 | + | //! | |
| 3 | + | //! Three orthogonal axes, all declared here: hosts (what can build natively), | |
| 4 | + | //! apps (what ships which targets and where its recipes live), and the | |
| 5 | + | //! implicit target axis that ties them together. Adding a platform is config — | |
| 6 | + | //! a new recipe plus a host that declares the target — not code. | |
| 7 | + | ||
| 8 | + | use crate::domain::{AppId, Target}; | |
| 9 | + | use anyhow::{Context, Result}; | |
| 10 | + | use ops_core::remote::RemoteHost; | |
| 11 | + | use serde::Deserialize; | |
| 12 | + | use std::collections::HashMap; | |
| 13 | + | use std::path::Path; | |
| 14 | + | ||
| 15 | + | #[derive(Debug, Clone, Deserialize)] | |
| 16 | + | pub struct Topology { | |
| 17 | + | #[serde(default, rename = "host")] | |
| 18 | + | pub hosts: Vec<Host>, | |
| 19 | + | /// `[app.<name>]` table. | |
| 20 | + | #[serde(default)] | |
| 21 | + | pub app: HashMap<String, AppConfig>, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | #[derive(Debug, Clone, Deserialize)] | |
| 25 | + | pub struct Host { | |
| 26 | + | pub name: String, | |
| 27 | + | /// Tailnet alias or `user@host`; `local` runs commands directly. | |
| 28 | + | pub ssh: String, | |
| 29 | + | /// Targets this host can build natively. A target only dispatches to a host | |
| 30 | + | /// that lists it — this is how no-cross-compile is enforced structurally. | |
| 31 | + | #[serde(default)] | |
| 32 | + | pub targets: Vec<Target>, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | #[derive(Debug, Clone, Deserialize)] | |
| 36 | + | pub struct AppConfig { | |
| 37 | + | /// Checkout path on each build host (apps are cloned on every host). | |
| 38 | + | pub repo: String, | |
| 39 | + | #[serde(default = "default_branch")] | |
| 40 | + | pub branch: String, | |
| 41 | + | /// Recipe directory relative to the repo (`dist/recipes`). | |
| 42 | + | #[serde(default = "default_recipe_dir")] | |
| 43 | + | pub recipe_dir: String, | |
| 44 | + | /// Targets this app ships. | |
| 45 | + | pub targets: Vec<Target>, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | fn default_branch() -> String { | |
| 49 | + | "main".into() | |
| 50 | + | } | |
| 51 | + | fn default_recipe_dir() -> String { | |
| 52 | + | "dist/recipes".into() | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | impl Topology { | |
| 56 | + | pub fn load(path: &Path) -> Result<Self> { | |
| 57 | + | let raw = std::fs::read_to_string(path) | |
| 58 | + | .with_context(|| format!("reading topology at {}", path.display()))?; | |
| 59 | + | let topo: Topology = toml::from_str(&raw)?; | |
| 60 | + | topo.validate()?; | |
| 61 | + | Ok(topo) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fn validate(&self) -> Result<()> { | |
| 65 | + | anyhow::ensure!(!self.hosts.is_empty(), "topology must declare at least one host"); | |
| 66 | + | anyhow::ensure!(!self.app.is_empty(), "topology must declare at least one app"); | |
| 67 | + | // Every target an app ships must have a host that can build it. | |
| 68 | + | for (name, app) in &self.app { | |
| 69 | + | for t in &app.targets { | |
| 70 | + | if self.host_for(*t).is_none() { | |
| 71 | + | anyhow::bail!("app `{name}` ships target {t} but no host declares it"); | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | Ok(()) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// The first host that declares `target` as buildable. | |
| 79 | + | pub fn host_for(&self, target: Target) -> Option<&Host> { | |
| 80 | + | self.hosts.iter().find(|h| h.targets.contains(&target)) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Map of host name -> `RemoteHost`, for the recipe `sh(host, cmd)` API. | |
| 84 | + | pub fn remote_hosts(&self) -> HashMap<String, RemoteHost> { | |
| 85 | + | self.hosts.iter().map(|h| (h.name.clone(), RemoteHost::new(h.ssh.clone()))).collect() | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | pub fn app(&self, app: &AppId) -> Option<&AppConfig> { | |
| 89 | + | self.app.get(app.as_str()) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[cfg(test)] | |
| 94 | + | mod tests { | |
| 95 | + | use super::*; | |
| 96 | + | ||
| 97 | + | const SAMPLE: &str = r#" | |
| 98 | + | [[host]] | |
| 99 | + | name = "fw13" | |
| 100 | + | ssh = "local" | |
| 101 | + | targets = ["linux/x86_64"] | |
| 102 | + | ||
| 103 | + | [[host]] | |
| 104 | + | name = "mbp" | |
| 105 | + | ssh = "mbp" | |
| 106 | + | targets = ["macos/aarch64", "ios/universal"] | |
| 107 | + | ||
| 108 | + | [app.goingson] | |
| 109 | + | repo = "~/Code/Apps/goingson" | |
| 110 | + | targets = ["macos/aarch64", "linux/x86_64"] | |
| 111 | + | "#; | |
| 112 | + | ||
| 113 | + | fn load(s: &str) -> Result<Topology> { | |
| 114 | + | let t: Topology = toml::from_str(s)?; | |
| 115 | + | t.validate()?; | |
| 116 | + | Ok(t) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | #[test] | |
| 120 | + | fn parses_and_resolves_hosts() { | |
| 121 | + | let t = load(SAMPLE).unwrap(); | |
| 122 | + | assert_eq!(t.hosts.len(), 2); | |
| 123 | + | let target: Target = "macos/aarch64".parse().unwrap(); | |
| 124 | + | assert_eq!(t.host_for(target).unwrap().name, "mbp"); | |
| 125 | + | assert_eq!(t.app(&"goingson".into()).unwrap().branch, "main"); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | #[test] | |
| 129 | + | fn rejects_target_without_a_host() { | |
| 130 | + | let bad = r#" | |
| 131 | + | [[host]] | |
| 132 | + | name = "fw13" | |
| 133 | + | ssh = "local" | |
| 134 | + | targets = ["linux/x86_64"] | |
| 135 | + | ||
| 136 | + | [app.goingson] | |
| 137 | + | repo = "x" | |
| 138 | + | targets = ["windows/x86_64"] | |
| 139 | + | "#; | |
| 140 | + | assert!(load(bad).is_err()); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[test] | |
| 144 | + | fn remote_hosts_marks_local() { | |
| 145 | + | let t = load(SAMPLE).unwrap(); | |
| 146 | + | let hosts = t.remote_hosts(); | |
| 147 | + | assert!(hosts["fw13"].is_local()); | |
| 148 | + | assert!(!hosts["mbp"].is_local()); | |
| 149 | + | } | |
| 150 | + | } |
| @@ -0,0 +1,22 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "bento-tui" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | license = "MIT" | |
| 6 | + | ||
| 7 | + | [[bin]] | |
| 8 | + | name = "bento" | |
| 9 | + | path = "src/main.rs" | |
| 10 | + | ||
| 11 | + | [dependencies] | |
| 12 | + | bento-daemon = { path = "../daemon" } | |
| 13 | + | ratatui = "0.29" | |
| 14 | + | crossterm = "0.28" | |
| 15 | + | tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } | |
| 16 | + | tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } | |
| 17 | + | futures-util = { version = "0.3", default-features = false } | |
| 18 | + | reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } | |
| 19 | + | serde = { version = "1.0.228", features = ["derive"] } | |
| 20 | + | serde_json = "1" | |
| 21 | + | anyhow = "1.0.102" | |
| 22 | + | chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } |
| @@ -0,0 +1,701 @@ | |||
| 1 | + | //! bento-tui — operator front-end for bentod. | |
| 2 | + | //! | |
| 3 | + | //! Layout (top to bottom): | |
| 4 | + | //! | |
| 5 | + | //! ┌ daemon ─────────────────────────────────────────────────────┐ | |
| 6 | + | //! │ bento -> http://...:7800 (ws ok) build: goingson 0.4.1 │ | |
| 7 | + | //! ├ matrix ── target x step (↑/↓ select target) ────────────────│ | |
| 8 | + | //! │ target chk pre bld sgn ntz stp vfy pkg pub col │ | |
| 9 | + | //! │>macos/aarch64 O O > . . . . . . . │ | |
| 10 | + | //! │ linux/x86_64 O O O - - O - - O O │ | |
| 11 | + | //! ├ events (WS /events) ────────────────────────────────────────│ | |
| 12 | + | //! │ 09:21:03 target_start linux/x86_64 │ | |
| 13 | + | //! ├ tail [42] linux/x86_64 build — in-flight ───────────────────│ | |
| 14 | + | //! │ Compiling goingson v0.4.1 │ | |
| 15 | + | //! ├ status / keys ──────────────────────────────────────────────│ | |
| 16 | + | //! │ [b] build [R] retry target [↑↓] select [[/]] tail [q] quit│ | |
| 17 | + | //! └─────────────────────────────────────────────────────────────┘ | |
| 18 | + | ||
| 19 | + | use anyhow::{Context, Result}; | |
| 20 | + | use crossterm::event::{self, Event as XEvent, KeyCode, KeyModifiers}; | |
| 21 | + | use crossterm::terminal::{ | |
| 22 | + | EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, | |
| 23 | + | }; | |
| 24 | + | use futures_util::StreamExt; | |
| 25 | + | use ratatui::prelude::*; | |
| 26 | + | use ratatui::widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}; | |
| 27 | + | use bento_daemon::domain::{Step, StepRunId}; | |
| 28 | + | use bento_daemon::events::{Event, EventEnvelope}; | |
| 29 | + | use serde::Deserialize; | |
| 30 | + | use std::collections::{BTreeMap, VecDeque}; | |
| 31 | + | use std::io; | |
| 32 | + | use std::sync::{Arc, Mutex}; | |
| 33 | + | use std::time::Duration; | |
| 34 | + | use tokio::sync::mpsc; | |
| 35 | + | ||
| 36 | + | // ---------- daemon types (subset of GET /state) ---------- | |
| 37 | + | ||
| 38 | + | #[derive(Clone, Debug, Deserialize)] | |
| 39 | + | struct StateView { | |
| 40 | + | build: Option<BuildView>, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | #[derive(Clone, Debug, Deserialize)] | |
| 44 | + | struct BuildView { | |
| 45 | + | #[allow(dead_code)] | |
| 46 | + | id: i64, | |
| 47 | + | app: String, | |
| 48 | + | version: String, | |
| 49 | + | status: String, | |
| 50 | + | targets: Vec<TargetView>, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | #[derive(Clone, Debug, Deserialize)] | |
| 54 | + | struct TargetView { | |
| 55 | + | target: String, | |
| 56 | + | status: String, | |
| 57 | + | #[allow(dead_code)] | |
| 58 | + | current_step: Option<String>, | |
| 59 | + | error: Option<String>, | |
| 60 | + | steps: Vec<StepView>, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | #[derive(Clone, Debug, Deserialize)] | |
| 64 | + | struct StepView { | |
| 65 | + | #[allow(dead_code)] | |
| 66 | + | run_id: i64, | |
| 67 | + | step: String, | |
| 68 | + | status: String, | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Short column headers for the matrix, in canonical step order. | |
| 72 | + | const STEP_COLS: [(Step, &str); 10] = [ | |
| 73 | + | (Step::Checkout, "chk"), | |
| 74 | + | (Step::Prebuild, "pre"), | |
| 75 | + | (Step::Build, "bld"), | |
| 76 | + | (Step::Sign, "sgn"), | |
| 77 | + | (Step::Notarize, "ntz"), | |
| 78 | + | (Step::Staple, "stp"), | |
| 79 | + | (Step::Verify, "vfy"), | |
| 80 | + | (Step::Package, "pkg"), | |
| 81 | + | (Step::Publish, "pub"), | |
| 82 | + | (Step::Collect, "col"), | |
| 83 | + | ]; | |
| 84 | + | ||
| 85 | + | // ---------- shared app state ---------- | |
| 86 | + | ||
| 87 | + | #[derive(Default)] | |
| 88 | + | struct Shared { | |
| 89 | + | state: Option<StateView>, | |
| 90 | + | last_err: Option<String>, | |
| 91 | + | events: VecDeque<String>, | |
| 92 | + | ws_ok: bool, | |
| 93 | + | selected: usize, | |
| 94 | + | notice: Option<String>, | |
| 95 | + | tails: BTreeMap<StepRunId, StepTail>, | |
| 96 | + | focus_run: Option<StepRunId>, | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | const EVENTS_CAP: usize = 200; | |
| 100 | + | const TAILS_CAP: usize = 10; | |
| 101 | + | const TAIL_LINES_CAP: usize = 200; | |
| 102 | + | ||
| 103 | + | /// Per-step live-tail buffer. Receives `StepLogChunk` text and presents it as a | |
| 104 | + | /// line-aware ring; chunks are not line-aligned at the transport, so a trailing | |
| 105 | + | /// partial line is buffered across chunks. | |
| 106 | + | struct StepTail { | |
| 107 | + | target: String, | |
| 108 | + | step: String, | |
| 109 | + | lines: VecDeque<String>, | |
| 110 | + | partial: String, | |
| 111 | + | status: TailStatus, | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | #[derive(Clone, PartialEq, Eq)] | |
| 115 | + | enum TailStatus { | |
| 116 | + | InFlight, | |
| 117 | + | Finished(String), | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | impl StepTail { | |
| 121 | + | fn new(target: String, step: String) -> Self { | |
| 122 | + | Self { | |
| 123 | + | target, | |
| 124 | + | step, | |
| 125 | + | lines: VecDeque::new(), | |
| 126 | + | partial: String::new(), | |
| 127 | + | status: TailStatus::InFlight, | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | fn push_chunk(&mut self, text: &str) { | |
| 132 | + | let combined = std::mem::take(&mut self.partial) + text; | |
| 133 | + | let mut rest = combined.as_str(); | |
| 134 | + | while let Some(idx) = rest.find('\n') { | |
| 135 | + | let (line, after) = rest.split_at(idx); | |
| 136 | + | self.push_line(line.trim_end_matches('\r').to_string()); | |
| 137 | + | rest = &after[1..]; | |
| 138 | + | } | |
| 139 | + | self.partial = rest.to_string(); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | fn push_line(&mut self, line: String) { | |
| 143 | + | if self.lines.len() >= TAIL_LINES_CAP { | |
| 144 | + | self.lines.pop_front(); | |
| 145 | + | } | |
| 146 | + | self.lines.push_back(line); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | fn finalize(&mut self, status_word: String) { | |
| 150 | + | let trailing = std::mem::take(&mut self.partial); | |
| 151 | + | if !trailing.is_empty() { | |
| 152 | + | self.push_line(trailing); | |
| 153 | + | } | |
| 154 | + | self.status = TailStatus::Finished(status_word); | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | impl Shared { | |
| 159 | + | fn push_event(&mut self, line: String) { | |
| 160 | + | if self.events.len() >= EVENTS_CAP { | |
| 161 | + | self.events.pop_front(); | |
| 162 | + | } | |
| 163 | + | self.events.push_back(line); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | fn open_tail(&mut self, run_id: StepRunId, target: String, step: String) { | |
| 167 | + | if self.tails.len() >= TAILS_CAP { | |
| 168 | + | if let Some((&oldest, _)) = self.tails.iter().next() { | |
| 169 | + | self.tails.remove(&oldest); | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | self.tails.insert(run_id, StepTail::new(target, step)); | |
| 173 | + | self.focus_run = Some(run_id); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | fn push_tail_chunk(&mut self, run_id: StepRunId, text: &str) { | |
| 177 | + | if let Some(t) = self.tails.get_mut(&run_id) { | |
| 178 | + | t.push_chunk(text); | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | fn finalize_tail(&mut self, run_id: StepRunId, status_word: String) { | |
| 183 | + | if let Some(t) = self.tails.get_mut(&run_id) { | |
| 184 | + | t.finalize(status_word); | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// App to act on: the latest build's app, else the env default. | |
| 189 | + | fn app_name(&self, default_app: &str) -> String { | |
| 190 | + | self.state | |
| 191 | + | .as_ref() | |
| 192 | + | .and_then(|s| s.build.as_ref()) | |
| 193 | + | .map(|b| b.app.clone()) | |
| 194 | + | .unwrap_or_else(|| default_app.to_string()) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | fn selected_target(&self) -> Option<String> { | |
| 198 | + | self.state | |
| 199 | + | .as_ref() | |
| 200 | + | .and_then(|s| s.build.as_ref()) | |
| 201 | + | .and_then(|b| b.targets.get(self.selected)) | |
| 202 | + | .map(|t| t.target.clone()) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // ---------- main ---------- | |
| 207 | + | ||
| 208 | + | fn main() -> Result<()> { | |
| 209 | + | let daemon = std::env::var("BENTO_DAEMON").unwrap_or_else(|_| "http://127.0.0.1:7800".into()); | |
| 210 | + | let default_app = std::env::var("BENTO_APP").unwrap_or_else(|_| "goingson".into()); | |
| 211 | + | let shared = Arc::new(Mutex::new(Shared::default())); | |
| 212 | + | ||
| 213 | + | let rt = tokio::runtime::Builder::new_multi_thread() | |
| 214 | + | .enable_all() | |
| 215 | + | .worker_threads(2) | |
| 216 | + | .build()?; | |
| 217 | + | ||
| 218 | + | let _g = rt.enter(); | |
| 219 | + | rt.spawn(state_poller(daemon.clone(), shared.clone())); | |
| 220 | + | rt.spawn(events_subscriber(daemon.clone(), shared.clone())); | |
| 221 | + | ||
| 222 | + | enable_raw_mode()?; | |
| 223 | + | let mut stdout = io::stdout(); | |
| 224 | + | crossterm::execute!(stdout, EnterAlternateScreen)?; | |
| 225 | + | let backend = CrosstermBackend::new(stdout); | |
| 226 | + | let mut term = Terminal::new(backend)?; | |
| 227 | + | ||
| 228 | + | let res = ui_loop(&mut term, &daemon, &default_app, &shared, rt.handle()); | |
| 229 | + | ||
| 230 | + | disable_raw_mode()?; | |
| 231 | + | crossterm::execute!(term.backend_mut(), LeaveAlternateScreen)?; | |
| 232 | + | term.show_cursor()?; | |
| 233 | + | res | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // ---------- background tasks ---------- | |
| 237 | + | ||
| 238 | + | async fn state_poller(daemon: String, shared: Arc<Mutex<Shared>>) { | |
| 239 | + | let url = format!("{daemon}/state"); | |
| 240 | + | let client = reqwest::Client::new(); | |
| 241 | + | loop { | |
| 242 | + | match client.get(&url).timeout(Duration::from_secs(2)).send().await { | |
| 243 | + | Ok(resp) if resp.status().is_success() => match resp.json::<StateView>().await { | |
| 244 | + | Ok(s) => { | |
| 245 | + | let mut g = shared.lock().unwrap(); | |
| 246 | + | let n = s | |
| 247 | + | .build | |
| 248 | + | .as_ref() | |
| 249 | + | .map(|b| b.targets.len().saturating_sub(1)) | |
| 250 | + | .unwrap_or(0); | |
| 251 | + | if g.selected > n { | |
| 252 | + | g.selected = n; | |
| 253 | + | } | |
| 254 | + | g.state = Some(s); | |
| 255 | + | g.last_err = None; | |
| 256 | + | } | |
| 257 | + | Err(e) => shared.lock().unwrap().last_err = Some(format!("decode: {e}")), | |
| 258 | + | }, | |
| 259 | + | Ok(resp) => shared.lock().unwrap().last_err = Some(format!("status {}", resp.status())), | |
| 260 | + | Err(e) => shared.lock().unwrap().last_err = Some(e.to_string()), | |
| 261 | + | } | |
| 262 | + | tokio::time::sleep(Duration::from_secs(2)).await; | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | async fn events_subscriber(daemon: String, shared: Arc<Mutex<Shared>>) { | |
| 267 | + | let ws_url = ws_url_from(&daemon); | |
| 268 | + | loop { | |
| 269 | + | match tokio_tungstenite::connect_async(&ws_url).await { | |
| 270 | + | Ok((mut socket, _resp)) => { | |
| 271 | + | shared.lock().unwrap().ws_ok = true; | |
| 272 | + | while let Some(msg) = socket.next().await { | |
| 273 | + | match msg { | |
| 274 | + | Ok(tokio_tungstenite::tungstenite::Message::Text(t)) => { | |
| 275 | + | dispatch_ws_frame(&shared, &t) | |
| 276 | + | } | |
| 277 | + | Ok(tokio_tungstenite::tungstenite::Message::Close(_)) | Err(_) => break, | |
| 278 | + | _ => {} | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | shared.lock().unwrap().ws_ok = false; | |
| 282 | + | } | |
| 283 | + | Err(_) => shared.lock().unwrap().ws_ok = false, | |
| 284 | + | } | |
| 285 | + | tokio::time::sleep(Duration::from_secs(3)).await; | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | fn ws_url_from(daemon: &str) -> String { | |
| 290 | + | daemon.replacen("https://", "wss://", 1).replacen("http://", "ws://", 1) + "/events" | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /// Route a WS frame: log chunks update the per-step tail; everything else | |
| 294 | + | /// becomes one line in the events ring. (Chunks never hit the ring — a busy | |
| 295 | + | /// build emits hundreds per second and would evict every other event.) | |
| 296 | + | fn dispatch_ws_frame(shared: &Arc<Mutex<Shared>>, raw: &str) { | |
| 297 | + | if let Some(line) = format_lagged(raw) { | |
| 298 | + | shared.lock().unwrap().push_event(line); | |
| 299 | + | return; | |
| 300 | + | } | |
| 301 | + | let Ok(env) = serde_json::from_str::<EventEnvelope>(raw) else { | |
| 302 | + | shared.lock().unwrap().push_event(raw.to_string()); | |
| 303 | + | return; | |
| 304 | + | }; | |
| 305 | + | match &env.event { | |
| 306 | + | Event::StepStart { run_id, target, step, .. } => { | |
| 307 | + | let mut g = shared.lock().unwrap(); | |
| 308 | + | g.open_tail(*run_id, target.to_string(), step.to_string()); | |
| 309 | + | let line = format_event_line(&env); | |
| 310 | + | g.push_event(line); | |
| 311 | + | } | |
| 312 | + | Event::StepLogChunk { run_id, text, .. } => { | |
| 313 | + | shared.lock().unwrap().push_tail_chunk(*run_id, text); | |
| 314 | + | } | |
| 315 | + | Event::StepDone { run_id, status, .. } => { | |
| 316 | + | let word = status.as_str().to_string(); | |
| 317 | + | let mut g = shared.lock().unwrap(); | |
| 318 | + | g.finalize_tail(*run_id, word); | |
| 319 | + | let line = format_event_line(&env); | |
| 320 | + | g.push_event(line); | |
| 321 | + | } | |
| 322 | + | _ => { | |
| 323 | + | let line = format_event_line(&env); | |
| 324 | + | shared.lock().unwrap().push_event(line); | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | fn format_event_line(env: &EventEnvelope) -> String { | |
| 330 | + | let time = env.at.format("%H:%M:%S").to_string(); | |
| 331 | + | format!("{time} {}", format_event_body(&env.event)) | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | fn format_event_body(e: &Event) -> String { | |
| 335 | + | match e { | |
| 336 | + | Event::BuildRequested { app, version, targets } => { | |
| 337 | + | format!("build_requested {app} {version} [{}]", targets.iter().map(|t| t.to_string()).collect::<Vec<_>>().join(",")) | |
| 338 | + | } | |
| 339 | + | Event::TargetAborted { target, .. } => format!("target_aborted {target}"), | |
| 340 | + | Event::TargetStart { target, .. } => format!("target_start {target}"), | |
| 341 | + | Event::StepStart { target, step, .. } => format!("step_start {target} {step}"), | |
| 342 | + | Event::StepLogChunk { run_id, text, .. } => format!("log[{run_id}] {}", truncate(text.trim_end(), 80)), | |
| 343 | + | Event::StepDone { target, step, status, .. } => format!("step_done {target} {step} {}", status.as_str()), | |
| 344 | + | Event::TargetOk { target, artifacts, .. } => format!("target_ok {target} ({} artifacts)", artifacts.len()), | |
| 345 | + | Event::TargetFailed { target, step, error, .. } => format!("target_failed {target} {step}: {}", truncate(error, 80)), | |
| 346 | + | Event::NotarizeRetry { target, attempt, reason, .. } => format!("notarize_retry {target} #{attempt} {reason}"), | |
| 347 | + | Event::ArtifactCollected { target, path, bytes, .. } => format!("artifact {target} {path} {bytes}B"), | |
| 348 | + | Event::PublishOk { target, channel, .. } => format!("publish_ok {target} {channel}"), | |
| 349 | + | Event::PublishFailed { target, channel, error, .. } => format!("publish_failed {target} {channel}: {}", truncate(error, 80)), | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | fn format_lagged(raw: &str) -> Option<String> { | |
| 354 | + | let v: serde_json::Value = serde_json::from_str(raw).ok()?; | |
| 355 | + | if v.get("kind")?.as_str()? == "lagged" { | |
| 356 | + | Some(format!("(lagged, skipped {})", v.get("skipped").and_then(|n| n.as_i64()).unwrap_or(0))) | |
| 357 | + | } else { | |
| 358 | + | None | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // ---------- actions ---------- | |
| 363 | + | ||
| 364 | + | #[derive(Clone, Debug)] | |
| 365 | + | enum Action { | |
| 366 | + | Build { app: String }, | |
| 367 | + | Retry { app: String, target: String }, | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | impl std::fmt::Display for Action { | |
| 371 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 372 | + | match self { | |
| 373 | + | Action::Build { app } => write!(f, "build {app}"), | |
| 374 | + | Action::Retry { app, target } => write!(f, "retry {app}/{target}"), | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | async fn dispatch_action(daemon: &str, act: &Action) -> Result<String> { | |
| 380 | + | let client = reqwest::Client::new(); | |
| 381 | + | let (url, body) = match act { | |
| 382 | + | Action::Build { app } => (format!("{daemon}/build"), serde_json::json!({ "app": app })), | |
| 383 | + | Action::Retry { app, target } => { | |
| 384 | + | (format!("{daemon}/retry"), serde_json::json!({ "app": app, "target": target })) | |
| 385 | + | } | |
| 386 | + | }; | |
| 387 | + | let resp = client | |
| 388 | + | .post(&url) | |
| 389 | + | .timeout(Duration::from_secs(30)) | |
| 390 | + | .json(&body) | |
| 391 | + | .send() | |
| 392 | + | .await | |
| 393 | + | .context("send")?; | |
| 394 | + | let status = resp.status(); | |
| 395 | + | let text = resp.text().await.unwrap_or_default(); | |
| 396 | + | if status.is_success() { | |
| 397 | + | Ok(truncate(&text, 120)) | |
| 398 | + | } else { | |
| 399 | + | Err(anyhow::anyhow!("HTTP {status}: {}", truncate(&text, 200))) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | // ---------- ui loop ---------- | |
| 404 | + | ||
| 405 | + | fn ui_loop<B: Backend>( | |
| 406 | + | term: &mut Terminal<B>, | |
| 407 | + | daemon: &str, | |
| 408 | + | default_app: &str, | |
| 409 | + | shared: &Arc<Mutex<Shared>>, | |
| 410 | + | rt: &tokio::runtime::Handle, | |
| 411 | + | ) -> Result<()> { | |
| 412 | + | let (action_tx, mut action_rx) = mpsc::channel::<Action>(32); | |
| 413 | + | { | |
| 414 | + | let shared = shared.clone(); | |
| 415 | + | let daemon = daemon.to_string(); | |
| 416 | + | rt.spawn(async move { | |
| 417 | + | while let Some(act) = action_rx.recv().await { | |
| 418 | + | let res = dispatch_action(&daemon, &act).await; | |
| 419 | + | let line = match res { | |
| 420 | + | Ok(msg) => format!("[ok] {act}: {msg}"), | |
| 421 | + | Err(e) => format!("[err] {act}: {e}"), | |
| 422 | + | }; | |
| 423 | + | let mut g = shared.lock().unwrap(); | |
| 424 | + | g.notice = Some(line.clone()); | |
| 425 | + | g.push_event(format!(" action {line}")); | |
| 426 | + | } | |
| 427 | + | }); | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | loop { | |
| 431 | + | term.draw(|f| draw(f, daemon, shared))?; | |
| 432 | + | ||
| 433 | + | if event::poll(Duration::from_millis(120))? { | |
| 434 | + | if let XEvent::Key(k) = event::read()? { | |
| 435 | + | match k.code { | |
| 436 | + | KeyCode::Char('q') | KeyCode::Esc => return Ok(()), | |
| 437 | + | KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => { | |
| 438 | + | return Ok(()); | |
| 439 | + | } | |
| 440 | + | KeyCode::Up | KeyCode::Char('k') => { | |
| 441 | + | let mut g = shared.lock().unwrap(); | |
| 442 | + | g.selected = g.selected.saturating_sub(1); | |
| 443 | + | } | |
| 444 | + | KeyCode::Down | KeyCode::Char('j') => { | |
| 445 | + | let mut g = shared.lock().unwrap(); | |
| 446 | + | let max = g | |
| 447 | + | .state | |
| 448 | + | .as_ref() | |
| 449 | + | .and_then(|s| s.build.as_ref()) | |
| 450 | + | .map(|b| b.targets.len().saturating_sub(1)) | |
| 451 | + | .unwrap_or(0); | |
| 452 | + | if g.selected < max { | |
| 453 | + | g.selected += 1; | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | KeyCode::Char('b') => { | |
| 457 | + | let app = shared.lock().unwrap().app_name(default_app); | |
| 458 | + | let _ = action_tx.try_send(Action::Build { app }); | |
| 459 | + | } | |
| 460 | + | KeyCode::Char('R') => { | |
| 461 | + | let (app, target) = { | |
| 462 | + | let g = shared.lock().unwrap(); | |
| 463 | + | (g.app_name(default_app), g.selected_target()) | |
| 464 | + | }; | |
| 465 | + | if let Some(target) = target { | |
| 466 | + | let _ = action_tx.try_send(Action::Retry { app, target }); | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | KeyCode::Char('r') => { | |
| 470 | + | shared.lock().unwrap().notice = Some("refresh on next tick".into()); | |
| 471 | + | } | |
| 472 | + | KeyCode::Char('[') => { | |
| 473 | + | let mut g = shared.lock().unwrap(); | |
| 474 | + | g.focus_run = cycle_focus(&g.tails, g.focus_run, -1); | |
| 475 | + | } | |
| 476 | + | KeyCode::Char(']') => { | |
| 477 | + | let mut g = shared.lock().unwrap(); | |
| 478 | + | g.focus_run = cycle_focus(&g.tails, g.focus_run, 1); | |
| 479 | + | } | |
| 480 | + | _ => {} | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | } | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | // ---------- render ---------- | |
| 488 | + | ||
| 489 | + | fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) { | |
| 490 | + | let g = shared.lock().unwrap(); | |
| 491 | + | let chunks = Layout::default() | |
| 492 | + | .direction(Direction::Vertical) | |
| 493 | + | .constraints([ | |
| 494 | + | Constraint::Length(3), // header | |
| 495 | + | Constraint::Length(10), // matrix | |
| 496 | + | Constraint::Min(4), // events | |
| 497 | + | Constraint::Length(8), // tail | |
| 498 | + | Constraint::Length(2), // status | |
| 499 | + | ]) | |
| 500 | + | .split(f.area()); |
Lines truncated
| @@ -0,0 +1,13 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "egui-updater" | |
| 3 | + | version = "0.0.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | license = "MIT" | |
| 6 | + | description = "OTA self-update for egui/eframe desktop apps: poll a manifest, verify a minisign signature, download and apply. (Placeholder — implementation in progress.)" | |
| 7 | + | readme = "README.md" | |
| 8 | + | keywords = ["egui", "eframe", "updater", "self-update", "ota"] | |
| 9 | + | categories = ["gui", "development-tools"] | |
| 10 | + | # repository/homepage: set before the real release — see README. Omitted on the | |
| 11 | + | # 0.0.0 name-reservation placeholder. | |
| 12 | + | ||
| 13 | + | [dependencies] |