max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+477 insertions,
-31 deletions
| @@ -186,6 +186,7 @@ | |||
| 186 | 186 | "http-body-util", | |
| 187 | 187 | "metrics", | |
| 188 | 188 | "metrics-exporter-prometheus", | |
| 189 | + | "ops-artifact", | |
| 189 | 190 | "ops-core", | |
| 190 | 191 | "ops-exec", | |
| 191 | 192 | "ops-status", | |
| @@ -1404,6 +1405,16 @@ | |||
| 1404 | 1405 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1405 | 1406 | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" | |
| 1406 | 1407 | ||
| 1408 | + | [[package]] | |
| 1409 | + | name = "ops-artifact" | |
| 1410 | + | version = "0.1.0" | |
| 1411 | + | dependencies = [ | |
| 1412 | + | "chrono", | |
| 1413 | + | "serde", | |
| 1414 | + | "serde_json", | |
| 1415 | + | "sha2 0.10.9", | |
| 1416 | + | ] | |
| 1417 | + | ||
| 1407 | 1418 | [[package]] | |
| 1408 | 1419 | name = "ops-core" | |
| 1409 | 1420 | version = "0.1.0" | |
| @@ -3497,22 +3508,6 @@ | |||
| 3497 | 3508 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3498 | 3509 | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" | |
| 3499 | 3510 | ||
| 3500 | - | [[patch.unused]] | |
| 3501 | - | name = "docengine" | |
| 3502 | - | version = "0.4.0" | |
| 3503 | - | ||
| 3504 | - | [[patch.unused]] | |
| 3505 | - | name = "synckit-client" | |
| 3506 | - | version = "0.6.0" | |
| 3507 | - | ||
| 3508 | - | [[patch.unused]] | |
| 3509 | - | name = "synckit-config" | |
| 3510 | - | version = "0.1.2" | |
| 3511 | - | ||
| 3512 | - | [[patch.unused]] | |
| 3513 | - | name = "supernote-push" | |
| 3514 | - | version = "0.1.0" | |
| 3515 | - | ||
| 3516 | 3511 | [[patch.unused]] | |
| 3517 | 3512 | name = "kberg" | |
| 3518 | 3513 | version = "0.1.0" | |
| @@ -3524,3 +3519,15 @@ | |||
| 3524 | 3519 | [[patch.unused]] | |
| 3525 | 3520 | name = "tagtree" | |
| 3526 | 3521 | version = "0.4.0" | |
| 3522 | + | ||
| 3523 | + | [[patch.unused]] | |
| 3524 | + | name = "docengine" | |
| 3525 | + | version = "0.4.0" | |
| 3526 | + | ||
| 3527 | + | [[patch.unused]] | |
| 3528 | + | name = "synckit-client" | |
| 3529 | + | version = "0.7.0" | |
| 3530 | + | ||
| 3531 | + | [[patch.unused]] | |
| 3532 | + | name = "synckit-config" | |
| 3533 | + | version = "0.1.2" |
| @@ -9,6 +9,7 @@ | |||
| 9 | 9 | path = "src/main.rs" | |
| 10 | 10 | ||
| 11 | 11 | [dependencies] | |
| 12 | + | ops-artifact = { path = "../../shared/ops-artifact" } | |
| 12 | 13 | ops-core = { path = "../../shared/ops-core" } | |
| 13 | 14 | ops-status = { path = "../../shared/ops-status" } | |
| 14 | 15 | ops-exec = { path = "../../shared/ops-exec", features = ["rpc"] } |
| @@ -648,6 +648,16 @@ | |||
| 648 | 648 | Ok(tail.trim().to_string()) | |
| 649 | 649 | } | |
| 650 | 650 | ||
| 651 | + | /// Every artifact this run collected, `file name -> sha256`. | |
| 652 | + | /// | |
| 653 | + | /// Already computed at `collect`, which is the only moment the bytes are | |
| 654 | + | /// known to be the ones that landed. Re-reading the directory afterwards | |
| 655 | + | /// would pick up a sibling target's files too, since the whole build shares | |
| 656 | + | /// one `dist_root/<app>/<version>/`. | |
| 657 | + | pub fn artifact_hashes(&self) -> HashMap<String, String> { | |
| 658 | + | self.artifact_hashes.lock().unwrap().clone() | |
| 659 | + | } | |
| 660 | + | ||
| 651 | 661 | /// Resolve `glob` on `host` to the single artifact it names. The `for` loop | |
| 652 | 662 | /// lists each existing match on its own line (and prints nothing — rather | |
| 653 | 663 | /// than a literal unexpanded pattern — when the glob matches no file), so |
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | //! Design: `bento-overview` in the shared code wiki (`~/Code/_private/wiki/`). | |
| 9 | 9 | //! <!-- wiki: bento-overview --> | |
| 10 | 10 | ||
| 11 | + | pub mod artifact_record; | |
| 11 | 12 | pub mod config; | |
| 12 | 13 | pub mod db; | |
| 13 | 14 | pub mod domain; |
| @@ -37,16 +37,21 @@ | |||
| 37 | 37 | /// not the claim the barrier is making. | |
| 38 | 38 | /// | |
| 39 | 39 | /// Returns the branch each host was on before it was pinned, for | |
| 40 | - | /// [`restore_branches`] to put back once the build settles. A host that is | |
| 41 | - | /// ALREADY detached has no branch to return to, and is refused: that state means | |
| 42 | - | /// some earlier release never cleaned up, and any commits made in the meantime | |
| 43 | - | /// are sitting on no branch at all. | |
| 40 | + | /// [`restore_branches`] to put back once the build settles, and the commit they | |
| 41 | + | /// all agreed on. A host that is ALREADY detached has no branch to return to, | |
| 42 | + | /// and is refused: that state means some earlier release never cleaned up, and | |
| 43 | + | /// any commits made in the meantime are sitting on no branch at all. | |
| 44 | + | /// | |
| 45 | + | /// The agreed sha is the release's provenance, and this is the only place it is | |
| 46 | + | /// known to be one value rather than a per-host answer. Resolving it here and | |
| 47 | + | /// carrying it forward is what lets an artifact record name the source it was | |
| 48 | + | /// built from without asking a build host to be honest about it afterwards. | |
| 44 | 49 | async fn pin_release( | |
| 45 | 50 | state: &AppState, | |
| 46 | 51 | app: &AppId, | |
| 47 | 52 | version: &Version, | |
| 48 | 53 | targets: &[Target], | |
| 49 | - | ) -> Result<Vec<(String, String)>> { | |
| 54 | + | ) -> Result<Pinned> { | |
| 50 | 55 | let cfg = state | |
| 51 | 56 | .topo | |
| 52 | 57 | .app(app) | |
| @@ -221,7 +226,19 @@ | |||
| 221 | 226 | short(first_sha), | |
| 222 | 227 | mismatch.join(", "), | |
| 223 | 228 | ); | |
| 224 | - | Ok(branches) | |
| 229 | + | Ok(Pinned { | |
| 230 | + | branches, | |
| 231 | + | sha: first_sha.clone(), | |
| 232 | + | }) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// What the preflight established: where each host's checkout was, and the one | |
| 236 | + | /// commit every host is now on. | |
| 237 | + | struct Pinned { | |
| 238 | + | /// `(host, branch)` for [`restore_branches`]. | |
| 239 | + | branches: Vec<(String, String)>, | |
| 240 | + | /// The commit all hosts agreed on. Empty when pinning is off (tests). | |
| 241 | + | sha: String, | |
| 225 | 242 | } | |
| 226 | 243 | ||
| 227 | 244 | /// Put every host's checkout back on the branch [`pin_release`] found it on. | |
| @@ -340,12 +357,15 @@ | |||
| 340 | 357 | ||
| 341 | 358 | // Pin every build host to the release tag and verify they agree, before any | |
| 342 | 359 | // target task spawns. Off in tests (their repos aren't git checkouts). | |
| 343 | - | let pinned_branches = if state.cfg.pin_release_sha { | |
| 360 | + | let pinned = if state.cfg.pin_release_sha { | |
| 344 | 361 | pin_release(&state, &app, &version, &targets) | |
| 345 | 362 | .await | |
| 346 | 363 | .context("release preflight")? | |
| 347 | 364 | } else { | |
| 348 | - | Vec::new() | |
| 365 | + | Pinned { | |
| 366 | + | branches: Vec::new(), | |
| 367 | + | sha: String::new(), | |
| 368 | + | } | |
| 349 | 369 | }; | |
| 350 | 370 | ||
| 351 | 371 | let build_id: i64 = sqlx::query_scalar( | |
| @@ -405,6 +425,7 @@ | |||
| 405 | 425 | version, | |
| 406 | 426 | target, | |
| 407 | 427 | cancel.clone(), | |
| 428 | + | pinned.sha.clone(), | |
| 408 | 429 | )); | |
| 409 | 430 | active.insert( | |
| 410 | 431 | key, | |
| @@ -419,7 +440,7 @@ | |||
| 419 | 440 | ||
| 420 | 441 | // Mark the build done once all target tasks settle, and put the pinned | |
| 421 | 442 | // checkouts back on their branches. Spawned so /build returns immediately. | |
| 422 | - | tokio::spawn(finalize_build(state, build_id, set, app, pinned_branches)); | |
| 443 | + | tokio::spawn(finalize_build(state, build_id, set, app, pinned.branches)); | |
| 423 | 444 | Ok(build_id) | |
| 424 | 445 | } | |
| 425 | 446 | ||
| @@ -433,6 +454,7 @@ | |||
| 433 | 454 | version: Version, | |
| 434 | 455 | target: Target, | |
| 435 | 456 | cancel: Arc<std::sync::atomic::AtomicBool>, | |
| 457 | + | pinned_sha: String, | |
| 436 | 458 | ) { | |
| 437 | 459 | let started = std::time::Instant::now(); | |
| 438 | 460 | let target_run_id: i64 = match sqlx::query_scalar( | |
| @@ -626,6 +648,13 @@ | |||
| 626 | 648 | }) | |
| 627 | 649 | .await; | |
| 628 | 650 | ||
| 651 | + | // Write the artifact record before the run is stamped terminal. It describes | |
| 652 | + | // what was collected, so it runs whether the recipe succeeded or failed: a | |
| 653 | + | // build that signed and collected an artifact and then failed at `publish` | |
| 654 | + | // still produced bytes somebody may want the provenance of. Emit-only and | |
| 655 | + | // non-fatal; nothing reads it yet. | |
| 656 | + | crate::artifact_record::emit(&state, &ctx, &pinned_sha).await; | |
| 657 | + | ||
| 629 | 658 | match outcome { | |
| 630 | 659 | Ok(Ok(())) => { | |
| 631 | 660 | let artifacts = collected_artifacts(&state, &app, &version); | |
| @@ -2251,6 +2280,22 @@ | |||
| 2251 | 2280 | /// A committed + tagged git repo whose linux recipe pins the host to the tag | |
| 2252 | 2281 | /// via `checkout_sha`. `tag` is created only when `Some`. | |
| 2253 | 2282 | fn init_git_app(repo: &std::path::Path, tauri_version: &str, tag: Option<&str>) { | |
| 2283 | + | init_git_app_with_recipe( | |
| 2284 | + | repo, | |
| 2285 | + | tauri_version, | |
| 2286 | + | tag, | |
| 2287 | + | "step(\"checkout\");\nlet s = checkout_sha(build_host());\nlog(\"pinned \" + s);\n\ | |
| 2288 | + | step(\"build\");\nsh_ok(build_host(), \"true\");\n", | |
| 2289 | + | ); | |
| 2290 | + | } | |
| 2291 | + | ||
| 2292 | + | /// As [`init_git_app`], with the linux recipe spelled by the caller. | |
| 2293 | + | fn init_git_app_with_recipe( | |
| 2294 | + | repo: &std::path::Path, | |
| 2295 | + | tauri_version: &str, | |
| 2296 | + | tag: Option<&str>, | |
| 2297 | + | recipe: &str, | |
| 2298 | + | ) { | |
| 2254 | 2299 | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 2255 | 2300 | std::fs::write( | |
| 2256 | 2301 | repo.join("src-tauri/tauri.conf.json"), | |
| @@ -2259,12 +2304,7 @@ | |||
| 2259 | 2304 | .unwrap(); | |
| 2260 | 2305 | std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap(); | |
| 2261 | 2306 | std::fs::create_dir_all(repo.join("dist/recipes")).unwrap(); | |
| 2262 | - | std::fs::write( | |
| 2263 | - | repo.join("dist/recipes/linux.rhai"), | |
| 2264 | - | "step(\"checkout\");\nlet s = checkout_sha(build_host());\nlog(\"pinned \" + s);\n\ | |
| 2265 | - | step(\"build\");\nsh_ok(build_host(), \"true\");\n", | |
| 2266 | - | ) | |
| 2267 | - | .unwrap(); | |
| 2307 | + | std::fs::write(repo.join("dist/recipes/linux.rhai"), recipe).unwrap(); | |
| 2268 | 2308 | // Isolate from the dev's global git config (which forces signed tags). | |
| 2269 | 2309 | let run = |args: &[&str]| { | |
| 2270 | 2310 | let out = std::process::Command::new("git") | |
| @@ -2329,6 +2369,96 @@ | |||
| 2329 | 2369 | assert_eq!(status, "ok", "a pinned build should succeed ({error})"); | |
| 2330 | 2370 | } | |
| 2331 | 2371 | ||
| 2372 | + | /// A pinned build writes an artifact record beside what it collected: the | |
| 2373 | + | /// manifest of those bytes, the commit the preflight pinned, and the steps | |
| 2374 | + | /// as artifact-scoped gates. | |
| 2375 | + | /// | |
| 2376 | + | /// The three facts already existed and met nowhere — the per-file sha256 at | |
| 2377 | + | /// `collect`, the sha at the preflight, the step outcomes in `step_runs` — | |
| 2378 | + | /// which is how gates came to vouch for one thing while the deploy shipped | |
| 2379 | + | /// another. | |
| 2380 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | |
| 2381 | + | async fn a_pinned_build_writes_an_artifact_record_for_what_it_collected() { | |
| 2382 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 2383 | + | let repo = tmp.path().join("demo"); | |
| 2384 | + | std::fs::create_dir_all(&repo).unwrap(); | |
| 2385 | + | let recipe = r#" | |
| 2386 | + | step("checkout"); | |
| 2387 | + | let sha = checkout_sha(build_host()); | |
| 2388 | + | log("pinned " + sha); | |
| 2389 | + | step("build"); | |
| 2390 | + | sh_ok(build_host(), "mkdir -p REPO/out && echo bin > REPO/out/demo.bin"); | |
| 2391 | + | step("collect"); | |
| 2392 | + | collect(build_host(), "REPO/out/demo.bin", "demo", version()); | |
| 2393 | + | "# | |
| 2394 | + | .replace("REPO", repo.to_str().unwrap()); | |
| 2395 | + | init_git_app_with_recipe(&repo, "0.0.1", Some("v0.0.1"), &recipe); | |
| 2396 | + | ||
| 2397 | + | let mut cfg = Config::for_tests(tmp.path()); | |
| 2398 | + | cfg.pin_release_sha = true; | |
| 2399 | + | let dist_root = cfg.dist_root.clone(); | |
| 2400 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 2401 | + | let topo = Topology::from_str_for_tests(&format!( | |
| 2402 | + | "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\ | |
| 2403 | + | pull_root = \"{repo}\"\n[app.demo]\nrepo = \"{repo}\"\n", | |
| 2404 | + | repo = repo.display() | |
| 2405 | + | )) | |
| 2406 | + | .unwrap(); | |
| 2407 | + | let state = test_state(pool.clone(), topo, cfg); | |
| 2408 | + | let build_id = start_build( | |
| 2409 | + | state, | |
| 2410 | + | AppId::new("demo"), | |
| 2411 | + | Version::parse("0.0.1").unwrap(), | |
| 2412 | + | vec!["linux/x86_64".parse().unwrap()], | |
| 2413 | + | ) | |
| 2414 | + | .await | |
| 2415 | + | .unwrap(); | |
| 2416 | + | let (status, error) = await_target(&pool, build_id).await; | |
| 2417 | + | assert_eq!(status, "ok", "the build should succeed ({error})"); | |
| 2418 | + | ||
| 2419 | + | let path = crate::artifact_record::record_path( | |
| 2420 | + | &dist_root, | |
| 2421 | + | &AppId::new("demo"), | |
| 2422 | + | &Version::parse("0.0.1").unwrap(), | |
| 2423 | + | "linux/x86_64".parse().unwrap(), | |
| 2424 | + | ); | |
| 2425 | + | let json = std::fs::read_to_string(&path) | |
| 2426 | + | .unwrap_or_else(|e| panic!("record at {}: {e}", path.display())); | |
| 2427 | + | // Parsing revalidates, so this also asserts the digest matches the | |
| 2428 | + | // manifest and no environment-scoped gate slipped in. | |
| 2429 | + | let record = ops_artifact::ArtifactRecord::parse(&json).unwrap(); | |
| 2430 | + | ||
| 2431 | + | assert_eq!(record.producer, "bento"); | |
| 2432 | + | assert_eq!(record.manifest.entries().len(), 1); | |
| 2433 | + | assert_eq!(record.manifest.entries()[0].path, "demo.bin"); | |
| 2434 | + | assert_eq!(record.digest, record.manifest.digest()); | |
| 2435 | + | ||
| 2436 | + | // The provenance names the commit the preflight pinned, not a rebuild of | |
| 2437 | + | // whatever the branch is now. | |
| 2438 | + | let head = std::process::Command::new("git") | |
| 2439 | + | .args(["rev-parse", "v0.0.1^{commit}"]) | |
| 2440 | + | .current_dir(&repo) | |
| 2441 | + | .env("GIT_CONFIG_GLOBAL", "/dev/null") | |
| 2442 | + | .output() | |
| 2443 | + | .unwrap(); | |
| 2444 | + | let head = String::from_utf8_lossy(&head.stdout).trim().to_string(); | |
| 2445 | + | assert_eq!(record.provenance.git_sha, head); | |
| 2446 | + | assert_eq!(record.provenance.target, "linux/x86_64"); | |
| 2447 | + | assert_eq!(record.provenance.build_host, "fw13"); | |
| 2448 | + | assert!(!record.provenance.toolchain.is_empty()); | |
| 2449 | + | ||
| 2450 | + | let gates: Vec<&str> = record.gates.iter().map(|g| g.gate.as_str()).collect(); | |
| 2451 | + | assert_eq!(gates, ["checkout", "build", "collect"]); | |
| 2452 | + | assert!(record.all_gates_passed()); | |
| 2453 | + | assert!( | |
| 2454 | + | record | |
| 2455 | + | .gates | |
| 2456 | + | .iter() | |
| 2457 | + | .all(|g| g.scope == ops_artifact::Scope::Artifact), | |
| 2458 | + | "a build host cannot vouch for an environment" | |
| 2459 | + | ); | |
| 2460 | + | } | |
| 2461 | + | ||
| 2332 | 2462 | /// The barrier refuses a target no host can build, instead of leaving it out | |
| 2333 | 2463 | /// of the pin and letting the remaining hosts vouch for the release. | |
| 2334 | 2464 | /// |
| @@ -1,0 +1,297 @@ | |||
| 1 | + | //! Emitting the artifact record: what this build produced, from what source, | |
| 2 | + | //! and what it proved. | |
| 3 | + | //! | |
| 4 | + | //! Design + rationale: maintainer wiki. | |
| 5 | + | //! <!-- wiki: sando-bento-boundary --> | |
| 6 | + | //! | |
| 7 | + | //! Bento already knew all of this and kept none of it together. The per-file | |
| 8 | + | //! sha256 is computed at `collect`, the commit is resolved by the release | |
| 9 | + | //! preflight, the step outcomes are rows in `step_runs`, and the three met | |
| 10 | + | //! nowhere. Writing them as one document beside the artifacts is what makes the | |
| 11 | + | //! handover to Sando possible later; today nothing reads it. | |
| 12 | + | //! | |
| 13 | + | //! Emit-only, and deliberately non-fatal. A build that produced signed, | |
| 14 | + | //! notarized bytes has succeeded whether or not its paperwork could be written, | |
| 15 | + | //! so every failure in here is logged and swallowed. That stops being true once | |
| 16 | + | //! something consumes the record. | |
| 17 | + | ||
| 18 | + | use crate::domain::{AppId, Target, Version}; | |
| 19 | + | use crate::engine::RecipeCtx; | |
| 20 | + | use crate::state::AppState; | |
| 21 | + | use chrono::{DateTime, Utc}; | |
| 22 | + | use ops_artifact::{ArtifactRecord, GateRecord, Manifest, Provenance, Scope, Verdict}; | |
| 23 | + | use ops_exec::{Action, LogSink, Step as OpStep}; | |
| 24 | + | ||
| 25 | + | /// Where a target's record lands: beside the artifacts, named for the target. | |
| 26 | + | /// | |
| 27 | + | /// One directory holds a whole build (`dist_root/<app>/<version>/`), so the | |
| 28 | + | /// file name carries the target or the second host to finish would overwrite | |
| 29 | + | /// the first's paperwork. `/` is not a path character here, hence the slug. | |
| 30 | + | pub fn record_path( | |
| 31 | + | dist_root: &std::path::Path, | |
| 32 | + | app: &AppId, | |
| 33 | + | version: &Version, | |
| 34 | + | target: Target, | |
| 35 | + | ) -> std::path::PathBuf { | |
| 36 | + | dist_root | |
| 37 | + | .join(app.as_str()) | |
| 38 | + | .join(version.to_string()) | |
| 39 | + | .join(format!("{}.record.json", target_slug(target))) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | fn target_slug(target: Target) -> String { | |
| 43 | + | target.to_string().replace('/', "-") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Build the record for a finished target run and write it beside the | |
| 47 | + | /// artifacts. Never fails a build: logs and returns. | |
| 48 | + | pub async fn emit(state: &AppState, ctx: &RecipeCtx, pinned_sha: &str) { | |
| 49 | + | if pinned_sha.is_empty() { | |
| 50 | + | // Pinning is off (`pin_release_sha = false`, which is how the tests run | |
| 51 | + | // against repos that are not git checkouts). There is no commit to name, | |
| 52 | + | // and a record whose provenance is blank would describe bytes without | |
| 53 | + | // saying where they came from, which is the thing this exists to stop. | |
| 54 | + | tracing::debug!(app = %ctx.app, target = %ctx.target, "release pinning off, no record written"); | |
| 55 | + | return; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | let hashes = ctx.artifact_hashes(); | |
| 59 | + | if hashes.is_empty() { | |
| 60 | + | // Nothing was collected, so there is no bundle to describe. A build that | |
| 61 | + | // failed at prebuild is the ordinary case here, and inventing an empty | |
| 62 | + | // manifest for it would mint an identity for no bytes. | |
| 63 | + | tracing::debug!(app = %ctx.app, target = %ctx.target, "no artifacts collected, no record written"); | |
| 64 | + | return; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | let manifest = match Manifest::new(hashes) { | |
| 68 | + | Ok(m) => m, | |
| 69 | + | Err(e) => { | |
| 70 | + | tracing::error!(app = %ctx.app, target = %ctx.target, error = %e, "could not build artifact manifest"); | |
| 71 | + | return; | |
| 72 | + | } | |
| 73 | + | }; | |
| 74 | + | ||
| 75 | + | let provenance = Provenance { | |
| 76 | + | app: ctx.app.to_string(), | |
| 77 | + | version: ctx.version.to_string(), | |
| 78 | + | tag: ctx.tag.clone(), | |
| 79 | + | git_sha: pinned_sha.to_string(), | |
| 80 | + | target: ctx.target.to_string(), | |
| 81 | + | build_host: ctx.build_host.clone(), | |
| 82 | + | toolchain: toolchain_of(state, &ctx.build_host).await, | |
| 83 | + | built_at: Utc::now(), | |
| 84 | + | }; | |
| 85 | + | ||
| 86 | + | let gates = gates_for(state, ctx.target_run_id).await; | |
| 87 | + | ||
| 88 | + | let record = match ArtifactRecord::new("bento", manifest, provenance, gates) { | |
| 89 | + | Ok(r) => r, | |
| 90 | + | Err(e) => { | |
| 91 | + | // Reaching here means the daemon assembled a document it would | |
| 92 | + | // itself refuse. Loud, because it is a bug in this file, not a | |
| 93 | + | // build problem. | |
| 94 | + | tracing::error!(app = %ctx.app, target = %ctx.target, error = %e, "assembled an invalid artifact record"); | |
| 95 | + | return; | |
| 96 | + | } | |
| 97 | + | }; | |
| 98 | + | ||
| 99 | + | let path = record_path(&state.cfg.dist_root, &ctx.app, &ctx.version, ctx.target); | |
| 100 | + | if let Err(e) = tokio::fs::write(&path, record.to_json()).await { | |
| 101 | + | tracing::error!(path = %path.display(), error = %e, "could not write artifact record"); | |
| 102 | + | return; | |
| 103 | + | } | |
| 104 | + | tracing::info!( | |
| 105 | + | app = %ctx.app, target = %ctx.target, digest = %record.digest.short(), | |
| 106 | + | "wrote artifact record" | |
| 107 | + | ); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// `rustc --version` on the build host. | |
| 111 | + | /// | |
| 112 | + | /// Asked rather than assumed: the daemon's own toolchain is not the one that | |
| 113 | + | /// compiled a macOS or aarch64 artifact, and a record that reported fw13's | |
| 114 | + | /// rustc for every target would be confidently wrong three times out of four. | |
| 115 | + | /// Unreadable is recorded as `unknown` rather than left blank, since an empty | |
| 116 | + | /// provenance field is refused and a missing toolchain should not cost a build | |
| 117 | + | /// its paperwork. | |
| 118 | + | async fn toolchain_of(state: &AppState, host: &str) -> String { | |
| 119 | + | const UNKNOWN: &str = "unknown"; | |
| 120 | + | let Some(exec) = state.executors.get(host) else { | |
| 121 | + | return UNKNOWN.to_string(); | |
| 122 | + | }; | |
| 123 | + | // A login shell: `rustc` lives in ~/.cargo/bin, which a non-login ssh shell | |
| 124 | + | // does not have on PATH. | |
| 125 | + | let step = OpStep::shell( | |
| 126 | + | Action::Build, | |
| 127 | + | "bash -lc 'rustc --version' 2>/dev/null".to_string(), | |
| 128 | + | ); | |
| 129 | + | let mut sink = DiscardSink; | |
| 130 | + | match exec.run_streaming(&step, &mut sink).await { | |
| 131 | + | Ok(out) if out.status.success() => { | |
| 132 | + | let v = String::from_utf8_lossy(&out.stdout).trim().to_string(); | |
| 133 | + | if v.is_empty() { UNKNOWN.to_string() } else { v } | |
| 134 | + | } | |
| 135 | + | _ => UNKNOWN.to_string(), | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// This run's steps, as artifact-scoped gate records. | |
| 140 | + | /// | |
| 141 | + | /// A recipe step IS Bento's gate: `prebuild` is clippy plus the test suite, | |
| 142 | + | /// `verify` is the Gatekeeper check, `sign` either produced a valid signature | |
| 143 | + | /// or failed. Reporting the steps verbatim rather than inventing a separate | |
| 144 | + | /// gate vocabulary keeps the record honest about what was actually observed. | |
| 145 | + | async fn gates_for(state: &AppState, target_run_id: i64) -> Vec<GateRecord> { | |
| 146 | + | let rows: Vec<(String, String, Option<String>, String, Option<String>)> = sqlx::query_as( | |
| 147 | + | "SELECT step, status, log_ref, started_at, finished_at | |
| 148 | + | FROM step_runs WHERE target_run_id = ? ORDER BY id", | |
| 149 | + | ) | |
| 150 | + | .bind(target_run_id) | |
| 151 | + | .fetch_all(&state.pool) | |
| 152 | + | .await | |
| 153 | + | .unwrap_or_default(); | |
| 154 | + | ||
| 155 | + | rows.into_iter() | |
| 156 | + | .map(|(step, status, log_ref, started_at, finished_at)| { | |
| 157 | + | let ran_at = parse_ts(&started_at); | |
| 158 | + | let (verdict, summary) = | |
| 159 | + | verdict_of(&step, &status, &started_at, finished_at.as_deref()); | |
| 160 | + | let mut g = GateRecord::new(step, Scope::Artifact, verdict, summary, ran_at); | |
| 161 | + | if let Some(r) = log_ref { | |
| 162 | + | g = g.with_log_ref(r); | |
| 163 | + | } | |
| 164 | + | g | |
| 165 | + | }) | |
| 166 | + | .collect() | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | /// Map a `step_runs.status` to a verdict and a one-line summary. | |
| 170 | + | /// | |
| 171 | + | /// Anything that is neither `ok` nor `failed` is a step that never reached a | |
| 172 | + | /// verdict, which happens when a newer build supersedes this one mid-run. That | |
| 173 | + | /// is `Blocked`, not `Failed`: nothing was observed to be wrong with the | |
| 174 | + | /// artifact, the run just stopped being the current one. | |
| 175 | + | fn verdict_of( | |
| 176 | + | step: &str, | |
| 177 | + | status: &str, | |
| 178 | + | started_at: &str, | |
| 179 | + | finished_at: Option<&str>, | |
| 180 | + | ) -> (Verdict, String) { | |
| 181 | + | let secs = duration_secs(started_at, finished_at); | |
| 182 | + | match status { | |
| 183 | + | "ok" => ( | |
| 184 | + | Verdict::Passed, | |
| 185 | + | match secs { | |
| 186 | + | Some(s) => format!("{step} passed in {s}s"), | |
| 187 | + | None => format!("{step} passed"), | |
| 188 | + | }, | |
| 189 | + | ), | |
| 190 | + | "failed" => ( | |
| 191 | + | Verdict::Failed, | |
| 192 | + | match secs { | |
| 193 | + | Some(s) => format!("{step} failed after {s}s"), | |
| 194 | + | None => format!("{step} failed"), | |
| 195 | + | }, | |
| 196 | + | ), | |
| 197 | + | other => ( | |
| 198 | + | Verdict::Blocked, | |
| 199 | + | format!("{step} never finished (left `{other}`); the run was superseded or aborted"), | |
| 200 | + | ), | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | fn parse_ts(s: &str) -> DateTime<Utc> { | |
| 205 | + | DateTime::parse_from_rfc3339(s).map_or_else(|_| Utc::now(), |t| t.with_timezone(&Utc)) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | fn duration_secs(started_at: &str, finished_at: Option<&str>) -> Option<i64> { | |
| 209 | + | let start = DateTime::parse_from_rfc3339(started_at).ok()?; | |
| 210 | + | let end = DateTime::parse_from_rfc3339(finished_at?).ok()?; | |
| 211 | + | Some((end - start).num_seconds().max(0)) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | /// Sinks the toolchain probe's output. It is read from `RunOutput`, not streamed. | |
| 215 | + | struct DiscardSink; | |
| 216 | + | ||
| 217 | + | #[async_trait::async_trait] | |
| 218 | + | impl LogSink for DiscardSink { | |
| 219 | + | async fn write_chunk(&mut self, _bytes: &[u8]) {} | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | #[cfg(test)] | |
| 223 | + | mod tests { | |
| 224 | + | use super::*; | |
| 225 | + | ||
| 226 | + | #[test] | |
| 227 | + | fn the_record_is_named_for_its_target_so_siblings_do_not_overwrite() { | |
| 228 | + | // Every target of a build collects into one directory. A fixed file name | |
| 229 | + | // would leave the last host to finish as the only one with paperwork. | |
| 230 | + | let root = std::path::Path::new("/dist"); | |
| 231 | + | let linux = record_path( | |
| 232 | + | root, | |
| 233 | + | &AppId::new("goingson"), | |
| 234 | + | &"0.4.1".parse().unwrap(), | |
| 235 | + | "linux/x86_64".parse().unwrap(), | |
| 236 | + | ); | |
| 237 | + | let macos = record_path( | |
| 238 | + | root, | |
| 239 | + | &AppId::new("goingson"), | |
| 240 | + | &"0.4.1".parse().unwrap(), | |
| 241 | + | "macos/aarch64".parse().unwrap(), | |
| 242 | + | ); | |
| 243 | + | assert_ne!(linux, macos); | |
| 244 | + | assert_eq!( | |
| 245 | + | linux, | |
| 246 | + | std::path::Path::new("/dist/goingson/0.4.1/linux-x86_64.record.json") | |
| 247 | + | ); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | #[test] | |
| 251 | + | fn a_target_slug_carries_no_path_separator() { | |
| 252 | + | // It becomes one path component; a `/` would silently make it two. | |
| 253 | + | let slug = target_slug("macos/aarch64".parse().unwrap()); | |
| 254 | + | assert!(!slug.contains('/'), "{slug} must be a single component"); | |
| 255 | + | assert_eq!(slug, "macos-aarch64"); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn a_passing_step_reports_its_duration() { | |
| 260 | + | let (v, s) = verdict_of( | |
| 261 | + | "prebuild", | |
| 262 | + | "ok", | |
| 263 | + | "2026-08-06T12:00:00Z", | |
| 264 | + | Some("2026-08-06T12:02:30Z"), | |
| 265 | + | ); | |
| 266 | + | assert_eq!(v, Verdict::Passed); | |
| 267 | + | assert_eq!(s, "prebuild passed in 150s"); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn a_failed_step_is_a_failed_gate() { | |
| 272 | + | let (v, s) = verdict_of( | |
| 273 | + | "sign", | |
| 274 | + | "failed", | |
| 275 | + | "2026-08-06T12:00:00Z", | |
| 276 | + | Some("2026-08-06T12:00:04Z"), | |
| 277 | + | ); | |
| 278 | + | assert_eq!(v, Verdict::Failed); | |
| 279 | + | assert!(s.contains("failed after 4s"), "{s}"); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn a_superseded_step_is_blocked_rather_than_failed() { | |
| 284 | + | // Nothing was observed to be wrong with the artifact. Calling it a | |
| 285 | + | // failure would put a red mark on a build that was merely overtaken. | |
| 286 | + | let (v, s) = verdict_of("build", "running", "2026-08-06T12:00:00Z", None); | |
| 287 | + | assert_eq!(v, Verdict::Blocked); | |
| 288 | + | assert!(s.contains("never finished"), "{s}"); | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | #[test] | |
| 292 | + | fn an_unparseable_timestamp_costs_the_duration_and_nothing_else() { | |
| 293 | + | let (v, s) = verdict_of("build", "ok", "not-a-timestamp", Some("also-not")); | |
| 294 | + | assert_eq!(v, Verdict::Passed); | |
| 295 | + | assert_eq!(s, "build passed"); | |
| 296 | + | } | |
| 297 | + | } |