max / makenotwork
11 files changed,
+1089 insertions,
-160 deletions
| @@ -289,6 +289,12 @@ dependencies = [ | |||
| 289 | 289 | ] | |
| 290 | 290 | ||
| 291 | 291 | [[package]] | |
| 292 | + | name = "const-oid" | |
| 293 | + | version = "0.10.2" | |
| 294 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 295 | + | checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" | |
| 296 | + | ||
| 297 | + | [[package]] | |
| 292 | 298 | name = "core-foundation" | |
| 293 | 299 | version = "0.10.1" | |
| 294 | 300 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -403,6 +409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 403 | 409 | checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" | |
| 404 | 410 | dependencies = [ | |
| 405 | 411 | "block-buffer 0.12.1", | |
| 412 | + | "const-oid", | |
| 406 | 413 | "crypto-common 0.2.2", | |
| 407 | 414 | "ctutils", | |
| 408 | 415 | ] | |
| @@ -1618,6 +1625,7 @@ dependencies = [ | |||
| 1618 | 1625 | "semver", | |
| 1619 | 1626 | "serde", | |
| 1620 | 1627 | "serde_json", | |
| 1628 | + | "sha2 0.11.0", | |
| 1621 | 1629 | "sqlx", | |
| 1622 | 1630 | "tempfile", | |
| 1623 | 1631 | "thiserror", |
| @@ -25,6 +25,7 @@ anyhow = "1.0.102" | |||
| 25 | 25 | thiserror = "2.0.18" | |
| 26 | 26 | chrono = { version = "0.4", features = ["serde"] } | |
| 27 | 27 | semver = { version = "1.0", features = ["serde"] } | |
| 28 | + | sha2 = "0.11" | |
| 28 | 29 | ||
| 29 | 30 | [dev-dependencies] | |
| 30 | 31 | tempfile = "3.20" |
| @@ -0,0 +1,36 @@ | |||
| 1 | + | -- Release artifact identity: make build_runs.id the identity object and demote | |
| 2 | + | -- `version` to a label. Design: wiki [[release-artifact-identity]] (decided | |
| 3 | + | -- 2026-07-21). The defect it closes: `version` (a semver read from Cargo.toml) | |
| 4 | + | -- was simultaneously the artifact identity, the gate-evidence key, the | |
| 5 | + | -- deployed-state key, and the staging directory name. Versions bump at release, | |
| 6 | + | -- not per commit, so dev rebuilds overwrote releases/<version>/ in place while | |
| 7 | + | -- the gate rows and burn-in clock from an earlier build stayed green — promote | |
| 8 | + | -- shipped a binary that never passed the gates it was credited with. | |
| 9 | + | -- | |
| 10 | + | -- build_runs (migration 007) is already one row per /rebuild and carries `sha`. | |
| 11 | + | -- It is the right identity object. Everything below is ADDITIVE and nullable: | |
| 12 | + | -- legacy rows keep NULL and are treated as "pre-identity". One fresh | |
| 13 | + | -- build-and-promote cycle re-anchors each tier. An honest NULL beats a | |
| 14 | + | -- fabricated backfill for live prod state. | |
| 15 | + | ||
| 16 | + | -- Identity lives on the build row. | |
| 17 | + | -- bundle_digest: sha256 of the bundle MANIFEST, full 64 hex. Answers "what | |
| 18 | + | -- bytes" — identity. Distinct from `sha` (provenance: "what source"), which | |
| 19 | + | -- already exists on build_runs. Cargo builds are not bit-reproducible, so a | |
| 20 | + | -- digest match across hosts is a strictly stronger claim than a sha match. | |
| 21 | + | -- staged_path: releases/<digest16> — the content-addressed dir the bundle was | |
| 22 | + | -- renamed into. Replaces versions.artifact_path as the source of truth. | |
| 23 | + | ALTER TABLE build_runs ADD COLUMN bundle_digest TEXT; | |
| 24 | + | ALTER TABLE build_runs ADD COLUMN staged_path TEXT; | |
| 25 | + | ||
| 26 | + | -- The evidence binds to the build it vouched for, not to a version string. | |
| 27 | + | ALTER TABLE gate_runs ADD COLUMN build_id INTEGER REFERENCES build_runs(id); | |
| 28 | + | ALTER TABLE deploys ADD COLUMN build_id INTEGER REFERENCES build_runs(id); | |
| 29 | + | ||
| 30 | + | -- Deployed state points at build rows. previous_build_id keeps exactly one step | |
| 31 | + | -- of rollback history, mirroring the existing (current|previous)_version pair. | |
| 32 | + | ALTER TABLE tier_state ADD COLUMN current_build_id INTEGER REFERENCES build_runs(id); | |
| 33 | + | ALTER TABLE tier_state ADD COLUMN previous_build_id INTEGER REFERENCES build_runs(id); | |
| 34 | + | ||
| 35 | + | CREATE INDEX gate_runs_by_build ON gate_runs(build_id, gate_kind); | |
| 36 | + | CREATE INDEX deploys_by_build ON deploys(build_id); |
| @@ -324,17 +324,20 @@ pub async fn stage_and_gate( | |||
| 324 | 324 | .await | |
| 325 | 325 | .ok(); | |
| 326 | 326 | ||
| 327 | - | // Stage the binary in the host's release_root so future gates and the | |
| 328 | - | // host self-deploy point at a stable path, not the worktree's target/. | |
| 327 | + | // Stage the bundle into `staging/<build_id>/` — a private scratch dir, not | |
| 328 | + | // yet a release. It is published content-addressed below, once its digest is | |
| 329 | + | // known. This is what makes overwrite unexpressible (wiki | |
| 330 | + | // [[release-artifact-identity]]): a build never touches another build's dir. | |
| 329 | 331 | let host_release_root = &cfg.release_root; | |
| 330 | - | let staged = deploy::deploy_local(host_release_root, &art.version, &art.binary_paths).await?; | |
| 332 | + | let staging = | |
| 333 | + | deploy::stage_local_bundle(host_release_root, run_id.0, &art.binary_paths).await?; | |
| 331 | 334 | ||
| 332 | - | // Stage every entry from cfg.release_contents into the staged release dir. | |
| 333 | - | // This is how non-binary version-coupled content (static assets, docs, | |
| 334 | - | // error-pages, ...) makes it into the atomic deploy bundle. Projects opt | |
| 335 | - | // in via daemon config — the sando code carries no MNW-specific knowledge. | |
| 335 | + | // Stage every entry from cfg.release_contents into the staged bundle. This is | |
| 336 | + | // how non-binary version-coupled content (static assets, docs, error-pages, | |
| 337 | + | // ...) makes it into the atomic deploy bundle. Projects opt in via daemon | |
| 338 | + | // config — the sando code carries no MNW-specific knowledge. | |
| 336 | 339 | for entry in &cfg.release_contents { | |
| 337 | - | stage_entry(&art.worktree, &staged, entry).await?; | |
| 340 | + | stage_entry(&art.worktree, &staging, entry).await?; | |
| 338 | 341 | } | |
| 339 | 342 | ||
| 340 | 343 | // Stage companion binaries as `companions/<name>` (the file itself) so they | |
| @@ -343,7 +346,7 @@ pub async fn stage_and_gate( | |||
| 343 | 346 | // the topology. The nodes that opt in install them post-swap (see | |
| 344 | 347 | // deploy::deploy_remote). | |
| 345 | 348 | if !art.companion_paths.is_empty() { | |
| 346 | - | let dst_dir = staged.join("companions"); | |
| 349 | + | let dst_dir = staging.join("companions"); | |
| 347 | 350 | tokio::fs::create_dir_all(&dst_dir) | |
| 348 | 351 | .await | |
| 349 | 352 | .with_context(|| format!("create staged companions dir {}", dst_dir.display()))?; | |
| @@ -359,13 +362,39 @@ pub async fn stage_and_gate( | |||
| 359 | 362 | } | |
| 360 | 363 | } | |
| 361 | 364 | ||
| 362 | - | let staged_bin = staged.join(cfg.primary_bin()); | |
| 365 | + | // Content identity: hash the fully-staged bundle, write its MANIFEST into the | |
| 366 | + | // bundle (for node-side verification), then publish it at `releases/<digest16>`. | |
| 367 | + | // The digest is now load-bearing — a hashing failure fails the build rather | |
| 368 | + | // than shipping an unidentifiable artifact. | |
| 369 | + | let digest = crate::bundle::digest_dir(&staging) | |
| 370 | + | .await | |
| 371 | + | .context("hashing the staged bundle for content addressing")?; | |
| 372 | + | tokio::fs::write( | |
| 373 | + | staging.join(crate::bundle::MANIFEST_NAME), | |
| 374 | + | digest.manifest.as_bytes(), | |
| 375 | + | ) | |
| 376 | + | .await | |
| 377 | + | .context("writing bundle MANIFEST")?; | |
| 378 | + | let released = | |
| 379 | + | deploy::finalize_local_release(host_release_root, &staging, digest.short()).await?; | |
| 380 | + | ||
| 381 | + | let staged_bin = released.join(cfg.primary_bin()); | |
| 363 | 382 | sqlx::query("UPDATE versions SET artifact_path = ? WHERE version = ?") | |
| 364 | 383 | .bind(staged_bin.to_string_lossy().as_ref()) | |
| 365 | 384 | .bind(&art.version) | |
| 366 | 385 | .execute(&pool) | |
| 367 | 386 | .await?; | |
| 368 | 387 | ||
| 388 | + | // Record the identity on the build row: the full digest and the | |
| 389 | + | // content-addressed dir the bundle was published to. This is what promote | |
| 390 | + | // resolves the artifact through, and burn-in/retention key on. | |
| 391 | + | { | |
| 392 | + | let released_path = released.to_string_lossy(); | |
| 393 | + | crate::runs::set_identity(&pool, run_id, &digest.full, &released_path) | |
| 394 | + | .await | |
| 395 | + | .ok(); | |
| 396 | + | } | |
| 397 | + | ||
| 369 | 398 | let host = topo | |
| 370 | 399 | .tiers | |
| 371 | 400 | .iter() | |
| @@ -386,6 +415,9 @@ pub async fn stage_and_gate( | |||
| 386 | 415 | // boot_smoke) only — `node_health` never appears here, so there are no | |
| 387 | 416 | // nodes to probe. | |
| 388 | 417 | nodes: Vec::new(), | |
| 418 | + | // These gates vouch for this build; record its id so promote can resolve | |
| 419 | + | // the artifact through the evidence rather than a version string. | |
| 420 | + | build_id: Some(run_id.0), | |
| 389 | 421 | }; | |
| 390 | 422 | let failed = gates::run_all(&ctx, &host.gates).await?; | |
| 391 | 423 | ||
| @@ -396,7 +428,7 @@ pub async fn stage_and_gate( | |||
| 396 | 428 | // ultra-fuzz Run 2, S1). Held only for the atomic UPDATE, never the gates. | |
| 397 | 429 | { | |
| 398 | 430 | let _deploy_guard = deploy_lock.lock().await; | |
| 399 | - | crate::runs::advance_tier(&pool, "host", &art.version).await?; | |
| 431 | + | crate::runs::advance_tier(&pool, "host", &art.version, Some(run_id.0)).await?; | |
| 400 | 432 | } | |
| 401 | 433 | // Terminal verdict: unlike the phase pings above (best-effort), a dropped | |
| 402 | 434 | // pass/fail write leaves the run wedged at `building`. Log it loudly if it | |
| @@ -691,27 +723,57 @@ mod tests { | |||
| 691 | 723 | assert_eq!(result, "passed"); | |
| 692 | 724 | assert_eq!(summary, None); | |
| 693 | 725 | ||
| 694 | - | // versions.artifact_path now points at the staged primary binary, and | |
| 695 | - | // that file actually exists on disk under release_root. | |
| 726 | + | // Identity: the build row carries the bundle digest (64 hex) and the | |
| 727 | + | // content-addressed dir it was published to (releases/<digest16>). | |
| 728 | + | let (digest, staged_path): (Option<String>, Option<String>) = | |
| 729 | + | sqlx::query_as("SELECT bundle_digest, staged_path FROM build_runs WHERE id = ?") | |
| 730 | + | .bind(run_id.0) | |
| 731 | + | .fetch_one(&pool) | |
| 732 | + | .await | |
| 733 | + | .unwrap(); | |
| 734 | + | let digest = digest.expect("bundle_digest recorded"); | |
| 735 | + | let staged_path = staged_path.expect("staged_path recorded"); | |
| 736 | + | assert_eq!(digest.len(), 64); | |
| 737 | + | let releases = tmp.path().join("release-root").join("releases"); | |
| 738 | + | assert_eq!( | |
| 739 | + | std::path::Path::new(&staged_path), | |
| 740 | + | releases.join(&digest[..16]), | |
| 741 | + | "bundle is published content-addressed at releases/<digest16>" | |
| 742 | + | ); | |
| 743 | + | ||
| 744 | + | // versions.artifact_path points at the primary binary inside that dir, | |
| 745 | + | // and it exists on disk. | |
| 696 | 746 | let staged_bin: String = | |
| 697 | 747 | sqlx::query_scalar("SELECT artifact_path FROM versions WHERE version = ?") | |
| 698 | 748 | .bind(version.to_string()) | |
| 699 | 749 | .fetch_one(&pool) | |
| 700 | 750 | .await | |
| 701 | 751 | .unwrap(); | |
| 702 | - | let expected = tmp | |
| 703 | - | .path() | |
| 704 | - | .join("release-root") | |
| 705 | - | .join("releases") | |
| 706 | - | .join(version.to_string()) | |
| 707 | - | .join("makenotwork"); | |
| 708 | - | assert_eq!(staged_bin, expected.to_string_lossy()); | |
| 709 | - | assert!(expected.exists(), "staged binary missing at {expected:?}"); | |
| 710 | - | ||
| 711 | - | // The `current` symlink flipped to the new release. | |
| 752 | + | let expected_bin = releases.join(&digest[..16]).join("makenotwork"); | |
| 753 | + | assert_eq!(staged_bin, expected_bin.to_string_lossy()); | |
| 754 | + | assert!( | |
| 755 | + | expected_bin.exists(), | |
| 756 | + | "staged binary missing at {expected_bin:?}" | |
| 757 | + | ); | |
| 758 | + | ||
| 759 | + | // The bundle carries its MANIFEST (for node-side verification), and the | |
| 760 | + | // recorded digest recomputes over the published dir (MANIFEST excluded). | |
| 761 | + | assert!( | |
| 762 | + | releases.join(&digest[..16]).join("MANIFEST").exists(), | |
| 763 | + | "MANIFEST written into the bundle" | |
| 764 | + | ); | |
| 765 | + | let recomputed = crate::bundle::digest_dir(std::path::Path::new(&staged_path)) | |
| 766 | + | .await | |
| 767 | + | .unwrap(); | |
| 768 | + | assert_eq!( | |
| 769 | + | digest, recomputed.full, | |
| 770 | + | "recorded digest matches the bundle" | |
| 771 | + | ); | |
| 772 | + | ||
| 773 | + | // The `current` symlink flipped to the content-addressed release. | |
| 712 | 774 | let link = tmp.path().join("release-root").join("current"); | |
| 713 | 775 | let target = std::fs::read_link(&link).expect("current is a symlink"); | |
| 714 | - | assert_eq!(target, PathBuf::from(format!("releases/{version}"))); | |
| 776 | + | assert_eq!(target, PathBuf::from(format!("releases/{}", &digest[..16]))); | |
| 715 | 777 | } | |
| 716 | 778 | ||
| 717 | 779 | #[tokio::test] |
| @@ -0,0 +1,224 @@ | |||
| 1 | + | //! Release-bundle content addressing. Design: wiki [[release-artifact-identity]]. | |
| 2 | + | //! | |
| 3 | + | //! A release bundle is the whole staged release dir — the primary binary plus | |
| 4 | + | //! `companions/<name>` plus everything from `cfg.release_contents` (static | |
| 5 | + | //! assets, docs, error pages). Its identity is the **bundle digest**: the | |
| 6 | + | //! sha256 of a `MANIFEST` listing one `<sha256> <relpath>` line per file, | |
| 7 | + | //! sorted by relative path. | |
| 8 | + | //! | |
| 9 | + | //! Sorting by path makes the manifest order-independent and reproducible, and | |
| 10 | + | //! buys two properties for free: node-side verification is per-file, so a | |
| 11 | + | //! mismatch names the file that drifted rather than just reporting that | |
| 12 | + | //! something did; and the manifest is plain text readable on a node with no | |
| 13 | + | //! tooling. | |
| 14 | + | //! | |
| 15 | + | //! Hashing the *bundle* rather than the primary binary is deliberate: two | |
| 16 | + | //! bundles with identical binaries but differing assets must not collide — that | |
| 17 | + | //! is the exact drift that crash-looped prod on a missing `CDN_BASE_URL` | |
| 18 | + | //! (postmortem 2026-07-09 #2). | |
| 19 | + | ||
| 20 | + | use anyhow::{Context, Result}; | |
| 21 | + | use sha2::{Digest, Sha256}; | |
| 22 | + | use std::path::{Path, PathBuf}; | |
| 23 | + | ||
| 24 | + | /// The manifest file's name at the bundle root. Excluded from its own hash so | |
| 25 | + | /// a bundle that carries its MANIFEST (for node-side verification) still | |
| 26 | + | /// digests to the same value as the bundle before the file was written. | |
| 27 | + | pub const MANIFEST_NAME: &str = "MANIFEST"; | |
| 28 | + | ||
| 29 | + | /// The full bundle digest recorded on `build_runs.bundle_digest` (64 hex), and | |
| 30 | + | /// the 16-char prefix used as the content-addressed directory name. | |
| 31 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 32 | + | pub struct BundleDigest { | |
| 33 | + | /// sha256 of the manifest text, lowercase hex, 64 chars. | |
| 34 | + | pub full: String, | |
| 35 | + | /// The manifest text itself, ready to write to `<bundle>/MANIFEST`. | |
| 36 | + | pub manifest: String, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | impl BundleDigest { | |
| 40 | + | /// The 16-hex-char prefix used in `releases/<digest16>/`. Full digest is | |
| 41 | + | /// kept in the DB; the path only needs enough to be collision-safe. | |
| 42 | + | pub fn short(&self) -> &str { | |
| 43 | + | &self.full[..16] | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Compute the bundle digest for the staged release dir at `root`. | |
| 48 | + | /// | |
| 49 | + | /// Walks every regular file under `root` (recursively), hashes each, and builds | |
| 50 | + | /// the sorted `MANIFEST`. The manifest file itself ([`MANIFEST_NAME`] at the | |
| 51 | + | /// root) is skipped so the digest is stable whether or not it has been written | |
| 52 | + | /// into the bundle yet. | |
| 53 | + | /// | |
| 54 | + | /// The walk + per-file hashing runs on a blocking thread: a release bundle is | |
| 55 | + | /// hundreds of MB of binary, and hashing it must not stall the async runtime. | |
| 56 | + | pub async fn digest_dir(root: &Path) -> Result<BundleDigest> { | |
| 57 | + | let root = root.to_path_buf(); | |
| 58 | + | tokio::task::spawn_blocking(move || digest_dir_blocking(&root)) | |
| 59 | + | .await | |
| 60 | + | .context("bundle digest task panicked")? | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | fn digest_dir_blocking(root: &Path) -> Result<BundleDigest> { | |
| 64 | + | let mut files: Vec<PathBuf> = Vec::new(); | |
| 65 | + | collect_files(root, &mut files) | |
| 66 | + | .with_context(|| format!("walking bundle dir {}", root.display()))?; | |
| 67 | + | ||
| 68 | + | // Relative paths, sorted, so the manifest is order-independent across hosts | |
| 69 | + | // and filesystems (readdir order is not guaranteed). | |
| 70 | + | let mut rows: Vec<(String, PathBuf)> = Vec::with_capacity(files.len()); | |
| 71 | + | for abs in files { | |
| 72 | + | let rel = abs | |
| 73 | + | .strip_prefix(root) | |
| 74 | + | .with_context(|| format!("{} not under bundle root", abs.display()))?; | |
| 75 | + | // Skip the manifest file itself — it is not part of what it describes. | |
| 76 | + | if rel.as_os_str() == MANIFEST_NAME { | |
| 77 | + | continue; | |
| 78 | + | } | |
| 79 | + | let rel_str = rel_to_unix(rel); | |
| 80 | + | rows.push((rel_str, abs)); | |
| 81 | + | } | |
| 82 | + | rows.sort_by(|a, b| a.0.cmp(&b.0)); | |
| 83 | + | ||
| 84 | + | let mut manifest = String::new(); | |
| 85 | + | for (rel, abs) in &rows { | |
| 86 | + | let hash = hash_file(abs).with_context(|| format!("hashing {}", abs.display()))?; | |
| 87 | + | // Two spaces between hash and path, matching sha256sum's format so the | |
| 88 | + | // manifest is checkable with standard tools on a node. | |
| 89 | + | manifest.push_str(&hash); | |
| 90 | + | manifest.push_str(" "); | |
| 91 | + | manifest.push_str(rel); | |
| 92 | + | manifest.push('\n'); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | let full = hex(&Sha256::digest(manifest.as_bytes())); | |
| 96 | + | Ok(BundleDigest { full, manifest }) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// Recursively collect regular-file paths under `dir`. Symlinks are not | |
| 100 | + | /// followed: a staged bundle is a plain tree of copied files, and following | |
| 101 | + | /// links would let content outside the bundle leak into its identity. | |
| 102 | + | fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> { | |
| 103 | + | for entry in std::fs::read_dir(dir)? { | |
| 104 | + | let entry = entry?; | |
| 105 | + | let ft = entry.file_type()?; | |
| 106 | + | if ft.is_dir() { | |
| 107 | + | collect_files(&entry.path(), out)?; | |
| 108 | + | } else if ft.is_file() { | |
| 109 | + | out.push(entry.path()); | |
| 110 | + | } | |
| 111 | + | // symlinks / other special files are intentionally ignored. | |
| 112 | + | } | |
| 113 | + | Ok(()) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | fn hash_file(path: &Path) -> std::io::Result<String> { | |
| 117 | + | use std::io::Read; | |
| 118 | + | let mut file = std::fs::File::open(path)?; | |
| 119 | + | let mut hasher = Sha256::new(); | |
| 120 | + | let mut buf = [0u8; 64 * 1024]; | |
| 121 | + | loop { | |
| 122 | + | let n = file.read(&mut buf)?; | |
| 123 | + | if n == 0 { | |
| 124 | + | break; | |
| 125 | + | } | |
| 126 | + | hasher.update(&buf[..n]); | |
| 127 | + | } | |
| 128 | + | Ok(hex(&hasher.finalize())) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// Relative path as a forward-slash string, so a manifest built on one OS reads | |
| 132 | + | /// the same on another. Sando builds on Linux, but keep the identity portable. | |
| 133 | + | fn rel_to_unix(rel: &Path) -> String { | |
| 134 | + | rel.components() | |
| 135 | + | .map(|c| c.as_os_str().to_string_lossy()) | |
| 136 | + | .collect::<Vec<_>>() | |
| 137 | + | .join("/") | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | fn hex(bytes: &[u8]) -> String { | |
| 141 | + | use std::fmt::Write; | |
| 142 | + | let mut s = String::with_capacity(bytes.len() * 2); | |
| 143 | + | for b in bytes { | |
| 144 | + | let _ = write!(s, "{b:02x}"); | |
| 145 | + | } | |
| 146 | + | s | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[cfg(test)] | |
| 150 | + | mod tests { | |
| 151 | + | use super::*; | |
| 152 | + | ||
| 153 | + | async fn write(root: &Path, rel: &str, bytes: &[u8]) { | |
| 154 | + | let p = root.join(rel); | |
| 155 | + | tokio::fs::create_dir_all(p.parent().unwrap()) | |
| 156 | + | .await | |
| 157 | + | .unwrap(); | |
| 158 | + | tokio::fs::write(&p, bytes).await.unwrap(); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | #[tokio::test] | |
| 162 | + | async fn digest_is_stable_and_lists_every_file_sorted() { | |
| 163 | + | let dir = tempfile::tempdir().unwrap(); | |
| 164 | + | let root = dir.path(); | |
| 165 | + | write(root, "makenotwork", b"binary bytes").await; | |
| 166 | + | write(root, "static/app.css", b"body{}").await; | |
| 167 | + | write(root, "companions/mnw-cli", b"cli bytes").await; | |
| 168 | + | ||
| 169 | + | let d = super::digest_dir(root).await.unwrap(); | |
| 170 | + | assert_eq!(d.full.len(), 64); | |
| 171 | + | assert_eq!(d.short().len(), 16); | |
| 172 | + | // Manifest lines are sorted by relative path. | |
| 173 | + | let paths: Vec<&str> = d | |
| 174 | + | .manifest | |
| 175 | + | .lines() | |
| 176 | + | .map(|l| l.split_once(" ").unwrap().1) | |
| 177 | + | .collect(); | |
| 178 | + | assert_eq!( | |
| 179 | + | paths, | |
| 180 | + | ["companions/mnw-cli", "makenotwork", "static/app.css"] | |
| 181 | + | ); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | #[tokio::test] | |
| 185 | + | async fn digest_is_independent_of_creation_order() { | |
| 186 | + | let a = tempfile::tempdir().unwrap(); | |
| 187 | + | write(a.path(), "z.txt", b"1").await; | |
| 188 | + | write(a.path(), "a.txt", b"2").await; | |
| 189 | + | let b = tempfile::tempdir().unwrap(); | |
| 190 | + | write(b.path(), "a.txt", b"2").await; | |
| 191 | + | write(b.path(), "z.txt", b"1").await; | |
| 192 | + | assert_eq!( | |
| 193 | + | super::digest_dir(a.path()).await.unwrap().full, | |
| 194 | + | super::digest_dir(b.path()).await.unwrap().full, | |
| 195 | + | ); | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | #[tokio::test] | |
| 199 | + | async fn a_changed_asset_changes_the_digest_even_with_identical_binary() { | |
| 200 | + | let a = tempfile::tempdir().unwrap(); | |
| 201 | + | write(a.path(), "makenotwork", b"same binary").await; | |
| 202 | + | write(a.path(), "static/app.css", b"v1").await; | |
| 203 | + | let b = tempfile::tempdir().unwrap(); | |
| 204 | + | write(b.path(), "makenotwork", b"same binary").await; | |
| 205 | + | write(b.path(), "static/app.css", b"v2").await; | |
| 206 | + | assert_ne!( | |
| 207 | + | super::digest_dir(a.path()).await.unwrap().full, | |
| 208 | + | super::digest_dir(b.path()).await.unwrap().full, | |
| 209 | + | "asset drift with an identical binary must not collide (2026-07-09 #2)" | |
| 210 | + | ); | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | #[tokio::test] | |
| 214 | + | async fn manifest_file_is_excluded_from_its_own_digest() { | |
| 215 | + | let dir = tempfile::tempdir().unwrap(); | |
| 216 | + | write(dir.path(), "makenotwork", b"bytes").await; | |
| 217 | + | let before = super::digest_dir(dir.path()).await.unwrap(); | |
| 218 | + | // Write the manifest into the bundle, as Stage B will for node-side | |
| 219 | + | // verification; the digest must not change. | |
| 220 | + | write(dir.path(), MANIFEST_NAME, before.manifest.as_bytes()).await; | |
| 221 | + | let after = super::digest_dir(dir.path()).await.unwrap(); | |
| 222 | + | assert_eq!(before.full, after.full); | |
| 223 | + | } | |
| 224 | + | } |
| @@ -1,14 +1,18 @@ | |||
| 1 | 1 | //! Atomic symlink-swap deploys. | |
| 2 | 2 | //! | |
| 3 | - | //! Layout on every target (local host, A nodes, B nodes, ...): | |
| 3 | + | //! Layout on every target (local host, A nodes, B nodes, ...). Release dirs are | |
| 4 | + | //! named for their content digest, not the version (wiki note | |
| 5 | + | //! `release-artifact-identity`), so a rebuild can never overwrite an earlier | |
| 6 | + | //! build's dir in place: | |
| 4 | 7 | //! | |
| 5 | 8 | //! <release_root>/ | |
| 6 | 9 | //! releases/ | |
| 7 | - | //! 0.8.1/ | |
| 10 | + | //! a1b2c3d4e5f60718/ <- <digest16> | |
| 8 | 11 | //! <bin_name> | |
| 9 | - | //! 0.8.2/ | |
| 12 | + | //! MANIFEST <- <sha256> <relpath> per file, node-verified | |
| 13 | + | //! f0e1d2c3b4a59687/ | |
| 10 | 14 | //! <bin_name> | |
| 11 | - | //! current -> releases/0.8.2 | |
| 15 | + | //! current -> releases/f0e1d2c3b4a59687 | |
| 12 | 16 | //! | |
| 13 | 17 | //! `ln -sfn` swaps the symlink. systemd units point at | |
| 14 | 18 | //! `<release_root>/current/<bin_name>` so reload-or-restart picks up the new | |
| @@ -67,23 +71,67 @@ async fn run_checked(executor: &dyn Executor, script: &str, what: &str) -> Resul | |||
| 67 | 71 | Ok(out) | |
| 68 | 72 | } | |
| 69 | 73 | ||
| 70 | - | pub async fn deploy_local( | |
| 74 | + | /// Stage built binaries into `staging/<build_id>/` on the Sando host — a | |
| 75 | + | /// private, mutable scratch dir that is not yet a release (no symlink, no gc). | |
| 76 | + | /// The caller adds `release_contents` + companions, hashes the result, writes | |
| 77 | + | /// the `MANIFEST`, then publishes it content-addressed via | |
| 78 | + | /// [`finalize_local_release`]. Splitting staging from publish is what lets the | |
| 79 | + | /// bundle be hashed before it is named (wiki [[release-artifact-identity]]). | |
| 80 | + | /// | |
| 81 | + | /// A stale `staging/<build_id>` from a killed prior run at the same id is | |
| 82 | + | /// removed first, so a retry stages clean. | |
| 83 | + | pub async fn stage_local_bundle( | |
| 71 | 84 | release_root: &Path, | |
| 72 | - | version: &crate::domain::Version, | |
| 85 | + | build_id: i64, | |
| 73 | 86 | binaries: &[PathBuf], | |
| 74 | 87 | ) -> Result<PathBuf> { | |
| 75 | - | let release_dir = release_root.join("releases").join(version.to_string()); | |
| 76 | - | tokio::fs::create_dir_all(&release_dir).await?; | |
| 88 | + | let staging = release_root.join("staging").join(build_id.to_string()); | |
| 89 | + | if tokio::fs::try_exists(&staging).await.unwrap_or(false) { | |
| 90 | + | tokio::fs::remove_dir_all(&staging) | |
| 91 | + | .await | |
| 92 | + | .with_context(|| format!("clearing stale staging dir {}", staging.display()))?; | |
| 93 | + | } | |
| 94 | + | tokio::fs::create_dir_all(&staging).await?; | |
| 77 | 95 | for binary in binaries { | |
| 78 | 96 | let name = binary.file_name().context("binary path has no file name")?; | |
| 79 | - | let dest = release_dir.join(name); | |
| 97 | + | let dest = staging.join(name); | |
| 80 | 98 | tokio::fs::copy(binary, &dest) | |
| 81 | 99 | .await | |
| 82 | 100 | .with_context(|| format!("copy {} -> {}", binary.display(), dest.display()))?; | |
| 83 | 101 | } | |
| 102 | + | Ok(staging) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Publish a fully-staged bundle content-addressed: rename | |
| 106 | + | /// `staging/<build_id>` to `releases/<digest16>` (atomic same-filesystem | |
| 107 | + | /// rename), flip `current` to it, gc old releases. Returns the released dir. | |
| 108 | + | /// | |
| 109 | + | /// The rename is the load-bearing step: **a directory whose name derives from | |
| 110 | + | /// its contents cannot be rewritten, because rewriting it changes its name.** | |
| 111 | + | /// The overwrite class that let a dev rebuild inherit an earlier build's gate | |
| 112 | + | /// rows and burn-in clock stops being something to guard against and becomes | |
| 113 | + | /// something that cannot be expressed. If a release with this digest already | |
| 114 | + | /// exists (identical bytes rebuilt), the staging copy is redundant and dropped. | |
| 115 | + | pub async fn finalize_local_release( | |
| 116 | + | release_root: &Path, | |
| 117 | + | staging: &Path, | |
| 118 | + | digest16: &str, | |
| 119 | + | ) -> Result<PathBuf> { | |
| 120 | + | let releases = release_root.join("releases"); | |
| 121 | + | tokio::fs::create_dir_all(&releases).await?; | |
| 122 | + | let released = releases.join(digest16); | |
| 123 | + | ||
| 124 | + | if tokio::fs::try_exists(&released).await.unwrap_or(false) { | |
| 125 | + | // Same digest already published — reuse it, discard the redundant stage. | |
| 126 | + | tokio::fs::remove_dir_all(staging).await.ok(); | |
| 127 | + | } else { | |
| 128 | + | tokio::fs::rename(staging, &released) | |
| 129 | + | .await | |
| 130 | + | .with_context(|| format!("publish {} -> {}", staging.display(), released.display()))?; | |
| 131 | + | } | |
| 84 | 132 | ||
| 85 | 133 | let current = release_root.join("current"); | |
| 86 | - | let target = format!("releases/{version}"); | |
| 134 | + | let target = format!("releases/{digest16}"); | |
| 87 | 135 | let out = Command::new("ln") | |
| 88 | 136 | .args(["-sfn", &target]) | |
| 89 | 137 | .arg(¤t) | |
| @@ -98,7 +146,7 @@ pub async fn deploy_local( | |||
| 98 | 146 | if let Err(e) = gc_local_releases(release_root).await { | |
| 99 | 147 | tracing::warn!(error = %e, "local release GC failed (non-fatal)"); | |
| 100 | 148 | } | |
| 101 | - | Ok(release_dir) | |
| 149 | + | Ok(released) | |
| 102 | 150 | } | |
| 103 | 151 | ||
| 104 | 152 | /// Deploy `staged_release_dir` (a directory built on the Sando host by | |
| @@ -115,21 +163,43 @@ pub async fn deploy_node( | |||
| 115 | 163 | staged_release_dir: &Path, | |
| 116 | 164 | primary_bin: &str, | |
| 117 | 165 | ) -> Result<PathBuf> { | |
| 166 | + | // The release dir is named for its content digest (`releases/<digest16>`), | |
| 167 | + | // not the version. The node mirrors that name so host and node agree on the | |
| 168 | + | // artifact's identity; the version is only a log label here. Legacy staged | |
| 169 | + | // dirs (pre-identity, still `releases/<version>`) work unchanged — the name | |
| 170 | + | // is whatever the host staged under. | |
| 171 | + | let release_id = staged_release_dir | |
| 172 | + | .file_name() | |
| 173 | + | .and_then(|n| n.to_str()) | |
| 174 | + | .with_context(|| { | |
| 175 | + | format!( | |
| 176 | + | "staged release dir {} has no usable name", | |
| 177 | + | staged_release_dir.display() | |
| 178 | + | ) | |
| 179 | + | })?; | |
| 118 | 180 | if node.ssh_target == "local" || node.ssh_target.is_empty() { | |
| 119 | 181 | // Local deploy already happened when we staged on the Sando host. | |
| 120 | 182 | // Just re-point `current` at the staged dir. | |
| 121 | - | return reset_local_current(executor, Path::new(&node.release_root), version).await; | |
| 183 | + | return reset_local_current(executor, Path::new(&node.release_root), release_id).await; | |
| 122 | 184 | } | |
| 123 | - | deploy_remote(executor, node, version, staged_release_dir, primary_bin).await | |
| 185 | + | deploy_remote( | |
| 186 | + | executor, | |
| 187 | + | node, | |
| 188 | + | version, | |
| 189 | + | release_id, | |
| 190 | + | staged_release_dir, | |
| 191 | + | primary_bin, | |
| 192 | + | ) | |
| 193 | + | .await | |
| 124 | 194 | } | |
| 125 | 195 | ||
| 126 | 196 | async fn reset_local_current( | |
| 127 | 197 | executor: &dyn Executor, | |
| 128 | 198 | release_root: &Path, | |
| 129 | - | version: &str, | |
| 199 | + | release_id: &str, | |
| 130 | 200 | ) -> Result<PathBuf> { | |
| 131 | 201 | let current = release_root.join("current"); | |
| 132 | - | let target = format!("releases/{version}"); | |
| 202 | + | let target = format!("releases/{release_id}"); | |
| 133 | 203 | run_checked( | |
| 134 | 204 | executor, | |
| 135 | 205 | &format!( | |
| @@ -140,21 +210,22 @@ async fn reset_local_current( | |||
| 140 | 210 | "local symlink swap", | |
| 141 | 211 | ) | |
| 142 | 212 | .await?; | |
| 143 | - | Ok(release_root.join("releases").join(version)) | |
| 213 | + | Ok(release_root.join("releases").join(release_id)) | |
| 144 | 214 | } | |
| 145 | 215 | ||
| 146 | 216 | async fn deploy_remote( | |
| 147 | 217 | executor: &dyn Executor, | |
| 148 | 218 | node: &Node, | |
| 149 | 219 | version: &str, | |
| 220 | + | release_id: &str, | |
| 150 | 221 | staged_release_dir: &Path, | |
| 151 | 222 | primary_bin: &str, | |
| 152 | 223 | ) -> Result<PathBuf> { | |
| 153 | 224 | let release_root = &node.release_root; | |
| 154 | 225 | let service = &node.service_name; | |
| 155 | - | let release_dir = format!("{release_root}/releases/{version}"); | |
| 226 | + | let release_dir = format!("{release_root}/releases/{release_id}"); | |
| 156 | 227 | ||
| 157 | - | tracing::info!(node = %node.name, version, "deploy: mkdir release dir"); | |
| 228 | + | tracing::info!(node = %node.name, version, release_id, "deploy: mkdir release dir"); | |
| 158 | 229 | run_checked( | |
| 159 | 230 | executor, | |
| 160 | 231 | &format!("set -e; mkdir -p {q}", q = sh_quote(&release_dir)), | |
| @@ -179,6 +250,22 @@ async fn deploy_remote( | |||
| 179 | 250 | .await | |
| 180 | 251 | .context("rsync failed (current symlink left intact)")?; | |
| 181 | 252 | ||
| 253 | + | // Verify the bundle on the node against its own MANIFEST before the swap | |
| 254 | + | // (invariant 3, wiki [[release-artifact-identity]]). The MANIFEST shipped in | |
| 255 | + | // the bundle is exactly `sha256sum` check format (`<hash> <relpath>`), so | |
| 256 | + | // this re-hashes every file on the node and names any that drifted in | |
| 257 | + | // transit — the hash is load-bearing, not merely recorded. Bundles staged | |
| 258 | + | // by a pre-identity build carry no MANIFEST; those skip verification (logged) | |
| 259 | + | // rather than fail, so a mid-migration deploy of a legacy artifact still | |
| 260 | + | // ships. A mismatch fails the promote with the running service intact. | |
| 261 | + | run_checked( | |
| 262 | + | executor, | |
| 263 | + | &manifest_verify_script(&release_dir), | |
| 264 | + | "verifying bundle digest on node", | |
| 265 | + | ) | |
| 266 | + | .await | |
| 267 | + | .context("node-side bundle verification failed (current symlink left intact)")?; | |
| 268 | + | ||
| 182 | 269 | // Fail closed on a wrong-architecture binary before the symlink swap. The | |
| 183 | 270 | // "never cross-compile" rule is enforced at build time (build_host check), | |
| 184 | 271 | // but nothing verified the artifact's arch matched the *target* node — so | |
| @@ -214,7 +301,7 @@ async fn deploy_remote( | |||
| 214 | 301 | "sudo /bin/systemctl reload-or-restart {}", | |
| 215 | 302 | sh_quote(service) | |
| 216 | 303 | ); | |
| 217 | - | let swap_and_restart = swap_and_restart_script(release_root, version, &restart_cmd); | |
| 304 | + | let swap_and_restart = swap_and_restart_script(release_root, release_id, &restart_cmd); | |
| 218 | 305 | run_checked( | |
| 219 | 306 | executor, | |
| 220 | 307 | &swap_and_restart, | |
| @@ -229,7 +316,7 @@ async fn deploy_remote( | |||
| 229 | 316 | // promote: a companion is part of the deploy, not a best-effort side effect. | |
| 230 | 317 | for c in &node.companions { | |
| 231 | 318 | let src = format!( | |
| 232 | - | "{release_root}/releases/{version}/companions/{name}", | |
| 319 | + | "{release_root}/releases/{release_id}/companions/{name}", | |
| 233 | 320 | name = c.name, | |
| 234 | 321 | ); | |
| 235 | 322 | tracing::info!(node = %node.name, companion = %c.name, "deploy: install companion + restart"); | |
| @@ -248,7 +335,9 @@ async fn deploy_remote( | |||
| 248 | 335 | tracing::warn!(error = %e, "remote release GC failed (non-fatal)"); | |
| 249 | 336 | } | |
| 250 | 337 | ||
| 251 | - | Ok(PathBuf::from(release_root).join("releases").join(version)) | |
| 338 | + | Ok(PathBuf::from(release_root) | |
| 339 | + | .join("releases") | |
| 340 | + | .join(release_id)) | |
| 252 | 341 | } | |
| 253 | 342 | ||
| 254 | 343 | /// Absolute path of the node-side companion installer (shipped once per node; | |
| @@ -339,12 +428,12 @@ fn config_check_script(env_file: &str, bin: &str) -> String { | |||
| 339 | 428 | /// | |
| 340 | 429 | /// `restart_cmd` is injected (rather than hardcoded) so tests can drive the | |
| 341 | 430 | /// failure and success paths with a `false`/`true` stand-in. | |
| 342 | - | fn swap_and_restart_script(release_root: &str, version: &str, restart_cmd: &str) -> String { | |
| 431 | + | fn swap_and_restart_script(release_root: &str, release_id: &str, restart_cmd: &str) -> String { | |
| 343 | 432 | format!( | |
| 344 | 433 | "set -e\n\ | |
| 345 | 434 | cd {root}\n\ | |
| 346 | 435 | prev=$(readlink current 2>/dev/null || true)\n\ | |
| 347 | - | ln -sfn releases/{ver} current.new\n\ | |
| 436 | + | ln -sfn releases/{rel} current.new\n\ | |
| 348 | 437 | mv -Tf current.new current\n\ | |
| 349 | 438 | if ! {restart}; then\n\ | |
| 350 | 439 | if [ -n \"$prev\" ]; then\n\ | |
| @@ -356,11 +445,34 @@ fn swap_and_restart_script(release_root: &str, version: &str, restart_cmd: &str) | |||
| 356 | 445 | exit 1\n\ | |
| 357 | 446 | fi\n", | |
| 358 | 447 | root = sh_quote(release_root), | |
| 359 | - | ver = sh_quote(version), | |
| 448 | + | rel = sh_quote(release_id), | |
| 360 | 449 | restart = restart_cmd, | |
| 361 | 450 | ) | |
| 362 | 451 | } | |
| 363 | 452 | ||
| 453 | + | /// Shell that re-hashes the rsynced bundle on the node against its shipped | |
| 454 | + | /// `MANIFEST` and aborts (exit 1) if any file drifted (invariant 3, wiki note | |
| 455 | + | /// `release-artifact-identity`). The `MANIFEST` is `sha256sum` check format, | |
| 456 | + | /// so `sha256sum -c` verifies every listed file with node-native tooling and | |
| 457 | + | /// names the one that failed. `--strict` fails on a malformed manifest line; | |
| 458 | + | /// `--quiet` drops the per-file OK spam and keeps only failures. | |
| 459 | + | /// | |
| 460 | + | /// A bundle staged by a pre-identity build carries no `MANIFEST`; that is not an | |
| 461 | + | /// error — it logs a skip and exits 0, so a mid-migration deploy of a legacy | |
| 462 | + | /// artifact still ships. Once every tier has cycled once, every bundle has one. | |
| 463 | + | fn manifest_verify_script(release_dir: &str) -> String { | |
| 464 | + | format!( | |
| 465 | + | "set -e\n\ | |
| 466 | + | cd {dir}\n\ | |
| 467 | + | if [ ! -f MANIFEST ]; then\n\ | |
| 468 | + | echo \"deploy: no MANIFEST in bundle; skipping digest verification (legacy artifact)\" >&2\n\ | |
| 469 | + | exit 0\n\ | |
| 470 | + | fi\n\ | |
| 471 | + | sha256sum --quiet --strict -c MANIFEST\n", | |
| 472 | + | dir = sh_quote(release_dir), | |
| 473 | + | ) | |
| 474 | + | } | |
| 475 | + | ||
| 364 | 476 | /// Shell that aborts (exit 1) if `bin`'s ELF architecture doesn't match the | |
| 365 | 477 | /// node it's running on. Reads the ELF `e_machine` field (2 bytes LE at offset | |
| 366 | 478 | /// 18) and compares it to the value implied by `uname -m`. An arch we don't have | |
| @@ -456,33 +568,46 @@ mod tests { | |||
| 456 | 568 | let release_root = root.join("releases-root"); | |
| 457 | 569 | tokio::fs::create_dir_all(&release_root).await.unwrap(); | |
| 458 | 570 | ||
| 459 | - | let staged = deploy_local( | |
| 460 | - | &release_root, | |
| 461 | - | &"0.8.12".parse().unwrap(), | |
| 462 | - | &[primary.clone(), admin.clone()], | |
| 463 | - | ) | |
| 464 | - | .await | |
| 465 | - | .expect("deploy_local should succeed"); | |
| 571 | + | // Stage into staging/<build_id> (no publish yet). | |
| 572 | + | let staging = stage_local_bundle(&release_root, 42, &[primary.clone(), admin.clone()]) | |
| 573 | + | .await | |
| 574 | + | .expect("stage_local_bundle should succeed"); | |
| 575 | + | assert_eq!(staging, release_root.join("staging").join("42")); | |
| 576 | + | assert!( | |
| 577 | + | !release_root.join("current").exists(), | |
| 578 | + | "staging must not publish or flip current" | |
| 579 | + | ); | |
| 466 | 580 | ||
| 467 | - | assert_eq!(staged, release_root.join("releases").join("0.8.12")); | |
| 581 | + | // Publish content-addressed at releases/<digest16>. | |
| 582 | + | let released = finalize_local_release(&release_root, &staging, "deadbeefcafe0000") | |
| 583 | + | .await | |
| 584 | + | .expect("finalize_local_release should succeed"); | |
| 585 | + | assert_eq!( | |
| 586 | + | released, | |
| 587 | + | release_root.join("releases").join("deadbeefcafe0000") | |
| 588 | + | ); | |
| 589 | + | assert!( | |
| 590 | + | !staging.exists(), | |
| 591 | + | "staging dir is consumed by the publish rename" | |
| 592 | + | ); | |
| 468 | 593 | assert_eq!( | |
| 469 | - | tokio::fs::read(staged.join("makenotwork")).await.unwrap(), | |
| 594 | + | tokio::fs::read(released.join("makenotwork")).await.unwrap(), | |
| 470 | 595 | b"PRIMARY" | |
| 471 | 596 | ); | |
| 472 | 597 | assert_eq!( | |
| 473 | - | tokio::fs::read(staged.join("mnw-admin")).await.unwrap(), | |
| 598 | + | tokio::fs::read(released.join("mnw-admin")).await.unwrap(), | |
| 474 | 599 | b"ADMIN" | |
| 475 | 600 | ); | |
| 476 | 601 | ||
| 477 | 602 | let current = release_root.join("current"); | |
| 478 | 603 | let target = tokio::fs::read_link(¤t).await.unwrap(); | |
| 479 | - | assert_eq!(target.to_string_lossy(), "releases/0.8.12"); | |
| 604 | + | assert_eq!(target.to_string_lossy(), "releases/deadbeefcafe0000"); | |
| 480 | 605 | let via_current = tokio::fs::read(current.join("makenotwork")).await.unwrap(); | |
| 481 | 606 | assert_eq!(via_current, b"PRIMARY"); | |
| 482 | 607 | } | |
| 483 | 608 | ||
| 484 | 609 | #[tokio::test] | |
| 485 | - | async fn deploy_local_second_release_swaps_symlink_and_keeps_old_dir() { | |
| 610 | + | async fn finalize_second_release_swaps_symlink_and_keeps_old_dir() { | |
| 486 | 611 | let tmp = tempfile::tempdir().unwrap(); | |
| 487 | 612 | let root = tmp.path(); | |
| 488 | 613 | let src_dir = root.join("src"); | |
| @@ -493,28 +618,35 @@ mod tests { | |||
| 493 | 618 | let release_root = root.join("rr"); | |
| 494 | 619 | tokio::fs::create_dir_all(&release_root).await.unwrap(); | |
| 495 | 620 | ||
| 496 | - | deploy_local( | |
| 497 | - | &release_root, | |
| 498 | - | &"0.1.0".parse().unwrap(), | |
| 499 | - | std::slice::from_ref(&bin), | |
| 500 | - | ) | |
| 501 | - | .await | |
| 502 | - | .unwrap(); | |
| 621 | + | // Two builds, distinct digests (distinct content) -> two release dirs. | |
| 622 | + | let s1 = stage_local_bundle(&release_root, 1, std::slice::from_ref(&bin)) | |
| 623 | + | .await | |
| 624 | + | .unwrap(); | |
| 625 | + | finalize_local_release(&release_root, &s1, "1111111111111111") | |
| 626 | + | .await | |
| 627 | + | .unwrap(); | |
| 503 | 628 | tokio::fs::write(&bin, b"V2").await.unwrap(); | |
| 504 | - | deploy_local( | |
| 505 | - | &release_root, | |
| 506 | - | &"0.2.0".parse().unwrap(), | |
| 507 | - | std::slice::from_ref(&bin), | |
| 508 | - | ) | |
| 509 | - | .await | |
| 510 | - | .unwrap(); | |
| 629 | + | let s2 = stage_local_bundle(&release_root, 2, std::slice::from_ref(&bin)) | |
| 630 | + | .await | |
| 631 | + | .unwrap(); | |
| 632 | + | finalize_local_release(&release_root, &s2, "2222222222222222") | |
| 633 | + | .await | |
| 634 | + | .unwrap(); | |
| 511 | 635 | ||
| 512 | - | assert!(release_root.join("releases/0.1.0/server").exists()); | |
| 513 | - | assert!(release_root.join("releases/0.2.0/server").exists()); | |
| 636 | + | assert!( | |
| 637 | + | release_root | |
| 638 | + | .join("releases/1111111111111111/server") | |
| 639 | + | .exists() | |
| 640 | + | ); | |
| 641 | + | assert!( | |
| 642 | + | release_root | |
| 643 | + | .join("releases/2222222222222222/server") | |
| 644 | + | .exists() | |
| 645 | + | ); | |
| 514 | 646 | let target = tokio::fs::read_link(release_root.join("current")) | |
| 515 | 647 | .await | |
| 516 | 648 | .unwrap(); | |
| 517 | - | assert_eq!(target.to_string_lossy(), "releases/0.2.0"); | |
| 649 | + | assert_eq!(target.to_string_lossy(), "releases/2222222222222222"); | |
| 518 | 650 | let via_current = tokio::fs::read(release_root.join("current/server")) | |
| 519 | 651 | .await | |
| 520 | 652 | .unwrap(); | |
| @@ -522,6 +654,91 @@ mod tests { | |||
| 522 | 654 | } | |
| 523 | 655 | ||
| 524 | 656 | #[tokio::test] | |
| 657 | + | async fn finalize_reuses_an_existing_release_of_the_same_digest() { | |
| 658 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 659 | + | let root = tmp.path(); | |
| 660 | + | let bin = root.join("server"); | |
| 661 | + | tokio::fs::write(&bin, b"BYTES").await.unwrap(); | |
| 662 | + | let release_root = root.join("rr"); | |
| 663 | + | tokio::fs::create_dir_all(&release_root).await.unwrap(); | |
| 664 | + | ||
| 665 | + | let s1 = stage_local_bundle(&release_root, 1, std::slice::from_ref(&bin)) | |
| 666 | + | .await | |
| 667 | + | .unwrap(); | |
| 668 | + | finalize_local_release(&release_root, &s1, "abc123abc123abc1") | |
| 669 | + | .await | |
| 670 | + | .unwrap(); | |
| 671 | + | // Same digest rebuilt (e.g. a re-run at the same content): finalize must | |
| 672 | + | // reuse the existing release and drop the redundant staging dir, not error. | |
| 673 | + | let s2 = stage_local_bundle(&release_root, 2, std::slice::from_ref(&bin)) | |
| 674 | + | .await | |
| 675 | + | .unwrap(); | |
| 676 | + | let released = finalize_local_release(&release_root, &s2, "abc123abc123abc1") | |
| 677 | + | .await | |
| 678 | + | .expect("finalize is idempotent on a repeated digest"); | |
| 679 | + | assert_eq!(released, release_root.join("releases/abc123abc123abc1")); | |
| 680 | + | assert!(!s2.exists(), "redundant staging dropped"); | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | #[tokio::test] | |
| 684 | + | async fn manifest_verify_script_passes_on_match_fails_on_drift_and_skips_when_absent() { | |
| 685 | + | // The node-side verification is a shell running `sha256sum -c MANIFEST`; | |
| 686 | + | // drive the real script through bash to prove it accepts a good bundle, | |
| 687 | + | // rejects a tampered one, and no-ops on a legacy (MANIFEST-less) bundle. | |
| 688 | + | let dir = tempfile::tempdir().unwrap(); | |
| 689 | + | tokio::fs::write(dir.path().join("server"), b"BINARY") | |
| 690 | + | .await | |
| 691 | + | .unwrap(); | |
| 692 | + | tokio::fs::create_dir(dir.path().join("static")) | |
| 693 | + | .await | |
| 694 | + | .unwrap(); | |
| 695 | + | tokio::fs::write(dir.path().join("static/app.css"), b"body{}") | |
| 696 | + | .await | |
| 697 | + | .unwrap(); | |
| 698 | + | let digest = crate::bundle::digest_dir(dir.path()).await.unwrap(); | |
| 699 | + | tokio::fs::write(dir.path().join("MANIFEST"), digest.manifest.as_bytes()) | |
| 700 | + | .await | |
| 701 | + | .unwrap(); | |
| 702 | + | ||
| 703 | + | let run = |d: &std::path::Path| { | |
| 704 | + | let script = manifest_verify_script(d.to_str().unwrap()); | |
| 705 | + | async move { | |
| 706 | + | Command::new("bash") | |
| 707 | + | .arg("-c") | |
| 708 | + | .arg(&script) | |
| 709 | + | .output() | |
| 710 | + | .await | |
| 711 | + | .unwrap() | |
| 712 | + | } | |
| 713 | + | }; | |
| 714 | + | ||
| 715 | + | let ok = run(dir.path()).await; | |
| 716 | + | assert!( | |
| 717 | + | ok.status.success(), | |
| 718 | + | "matching bundle verifies: {}", | |
| 719 | + | String::from_utf8_lossy(&ok.stderr) | |
| 720 | + | ); | |
| 721 | + | ||
| 722 | + | // Drift one file: sha256sum -c must fail (current symlink left intact). | |
| 723 | + | tokio::fs::write(dir.path().join("static/app.css"), b"TAMPERED") | |
| 724 | + | .await | |
| 725 | + | .unwrap(); | |
| 726 | + | let bad = run(dir.path()).await; | |
| 727 | + | assert!(!bad.status.success(), "a drifted file fails verification"); | |
| 728 | + | ||
| 729 | + | // Legacy bundle with no MANIFEST: skip, not fail. | |
| 730 | + | let legacy = tempfile::tempdir().unwrap(); | |
| 731 | + | tokio::fs::write(legacy.path().join("server"), b"x") | |
| 732 | + | .await | |
| 733 | + | .unwrap(); | |
| 734 | + | let skip = run(legacy.path()).await; | |
| 735 | + | assert!( | |
| 736 | + | skip.status.success(), | |
| 737 | + | "a bundle without a MANIFEST skips verification rather than failing" | |
| 738 | + | ); | |
| 739 | + | } | |
| 740 | + | ||
| 741 | + | #[tokio::test] | |
| 525 | 742 | async fn gc_local_releases_keeps_last_n_by_mtime() { | |
| 526 | 743 | let tmp = tempfile::tempdir().unwrap(); | |
| 527 | 744 | let root = tmp.path(); |
| @@ -48,6 +48,12 @@ pub struct GateCtx { | |||
| 48 | 48 | /// gate runs on the host (where `node_health` never appears); filled at | |
| 49 | 49 | /// promote time with each freshly-deployed node and its executor. | |
| 50 | 50 | pub nodes: Vec<NodeProbe>, | |
| 51 | + | /// The `build_runs.id` this gate run vouches for — the artifact identity | |
| 52 | + | /// (wiki [[release-artifact-identity]]). Recorded on every `gate_runs` row so | |
| 53 | + | /// promote can resolve the artifact through the evidence for a specific build, | |
| 54 | + | /// not through a version string that a later rebuild can silently reuse. | |
| 55 | + | /// `None` for legacy/pre-identity runs and gate unit tests. | |
| 56 | + | pub build_id: Option<i64>, | |
| 51 | 57 | } | |
| 52 | 58 | ||
| 53 | 59 | /// One node the `node_health` gate verifies: its id, the systemd unit to | |
| @@ -67,13 +73,14 @@ pub async fn run(ctx: &GateCtx, gate: &Gate) -> Result<GateOutcome> { | |||
| 67 | 73 | let started_at = Utc::now().to_rfc3339(); | |
| 68 | 74 | ||
| 69 | 75 | let id: i64 = sqlx::query_scalar( | |
| 70 | - | "INSERT INTO gate_runs (version, tier, gate_kind, started_at) VALUES (?, ?, ?, ?) | |
| 76 | + | "INSERT INTO gate_runs (version, tier, gate_kind, started_at, build_id) VALUES (?, ?, ?, ?, ?) | |
| 71 | 77 | RETURNING id", | |
| 72 | 78 | ) | |
| 73 | 79 | .bind(&ctx.version) | |
| 74 | 80 | .bind(&ctx.tier) | |
| 75 | 81 | .bind(kind) | |
| 76 | 82 | .bind(&started_at) | |
| 83 | + | .bind(ctx.build_id) | |
| 77 | 84 | .fetch_one(&ctx.pool) | |
| 78 | 85 | .await?; | |
| 79 | 86 | let run_id = GateRunId(id); | |
| @@ -2132,6 +2139,7 @@ mod tests { | |||
| 2132 | 2139 | worktree: tmp.path().to_path_buf(), | |
| 2133 | 2140 | events: events::channel(), | |
| 2134 | 2141 | nodes: Vec::new(), | |
| 2142 | + | build_id: None, | |
| 2135 | 2143 | }; | |
| 2136 | 2144 | let out = cargo_test(&ctx, GateRunId(1)).await.unwrap(); | |
| 2137 | 2145 | assert_eq!( | |
| @@ -2166,6 +2174,7 @@ mod tests { | |||
| 2166 | 2174 | worktree: std::path::PathBuf::from("/tmp/wt"), | |
| 2167 | 2175 | events: events::channel(), | |
| 2168 | 2176 | nodes: Vec::new(), | |
| 2177 | + | build_id: None, | |
| 2169 | 2178 | }; | |
| 2170 | 2179 | let dir = std::path::Path::new("/tmp/wt/x"); | |
| 2171 | 2180 | ||
| @@ -2198,6 +2207,7 @@ mod tests { | |||
| 2198 | 2207 | worktree: std::path::PathBuf::from("/tmp/wt"), | |
| 2199 | 2208 | events: events::channel(), | |
| 2200 | 2209 | nodes: Vec::new(), | |
| 2210 | + | build_id: None, | |
| 2201 | 2211 | }; | |
| 2202 | 2212 | let mut t = target("shared/ops-exec"); | |
| 2203 | 2213 | t.all_features = true; | |
| @@ -2285,6 +2295,7 @@ mod tests { | |||
| 2285 | 2295 | worktree: tmp.path().to_path_buf(), | |
| 2286 | 2296 | events: events::channel(), | |
| 2287 | 2297 | nodes: Vec::new(), | |
| 2298 | + | build_id: None, | |
| 2288 | 2299 | }; | |
| 2289 | 2300 | let out = supply_chain(&ctx, GateRunId(1), GateKind::CargoAudit) | |
| 2290 | 2301 | .await | |
| @@ -2359,6 +2370,7 @@ mod tests { | |||
| 2359 | 2370 | worktree: std::path::PathBuf::from("/tmp/wt"), | |
| 2360 | 2371 | events: events::channel(), | |
| 2361 | 2372 | nodes: Vec::new(), | |
| 2373 | + | build_id: None, | |
| 2362 | 2374 | }; | |
| 2363 | 2375 | let plain = crate::config::TestTarget { | |
| 2364 | 2376 | dir: std::path::PathBuf::from("server"), | |
| @@ -2490,6 +2502,7 @@ mod tests { | |||
| 2490 | 2502 | worktree: std::path::PathBuf::from("/tmp/unused"), | |
| 2491 | 2503 | events: events::channel(), | |
| 2492 | 2504 | nodes: Vec::new(), | |
| 2505 | + | build_id: None, | |
| 2493 | 2506 | }; | |
| 2494 | 2507 | let out = run(&ctx, &Gate::BurnIn { hours: 24 }).await.unwrap(); | |
| 2495 | 2508 | assert_eq!(out.status_str(), "blocked"); | |
| @@ -2543,6 +2556,7 @@ mod tests { | |||
| 2543 | 2556 | worktree: std::path::PathBuf::new(), | |
| 2544 | 2557 | events: events::channel(), | |
| 2545 | 2558 | nodes: Vec::new(), // no nodes -> fail closed | |
| 2559 | + | build_id: None, | |
| 2546 | 2560 | }; | |
| 2547 | 2561 | let out = run(&ctx, &Gate::NodeHealth).await.unwrap(); | |
| 2548 | 2562 | assert_eq!(out.status_str(), "blocked"); | |
| @@ -2863,6 +2877,7 @@ mod tests { | |||
| 2863 | 2877 | worktree: std::path::PathBuf::from("/tmp/unused"), | |
| 2864 | 2878 | events: events::channel(), | |
| 2865 | 2879 | nodes: Vec::new(), | |
| 2880 | + | build_id: None, | |
| 2866 | 2881 | }; | |
| 2867 | 2882 | let out = run(&ctx, &Gate::CodeSmoke).await.unwrap(); | |
| 2868 | 2883 | assert_eq!(out.status_str(), "blocked"); |
| @@ -15,6 +15,7 @@ | |||
| 15 | 15 | ||
| 16 | 16 | pub mod backup; | |
| 17 | 17 | pub mod build; | |
| 18 | + | pub mod bundle; | |
| 18 | 19 | pub mod classify; | |
| 19 | 20 | pub mod config; | |
| 20 | 21 | pub mod db; |
| @@ -990,7 +990,7 @@ mod tests { | |||
| 990 | 990 | // A tier that configures no gates has nothing to satisfy. | |
| 991 | 991 | let pool = fresh_pool().await; | |
| 992 | 992 | seed(&pool, "host", "0.8.12").await; | |
| 993 | - | let pending = unsatisfied_gates(&pool, &tid("host"), &[], "0.8.12", false) | |
| 993 | + | let pending = unsatisfied_gates(&pool, &tid("host"), &[], "0.8.12", None, false) | |
| 994 | 994 | .await | |
| 995 | 995 | .unwrap(); | |
| 996 | 996 | assert_eq!(pending, Vec::<String>::new()); | |
| @@ -1004,9 +1004,10 @@ mod tests { | |||
| 1004 | 1004 | // straight through to prod. | |
| 1005 | 1005 | let pool = fresh_pool().await; | |
| 1006 | 1006 | seed(&pool, "a", "0.8.12").await; | |
| 1007 | - | let pending = unsatisfied_gates(&pool, &tid("a"), &[Gate::BootSmoke], "0.8.12", false) | |
| 1008 | - | .await | |
| 1009 | - | .unwrap(); | |
| 1007 | + | let pending = | |
| 1008 | + | unsatisfied_gates(&pool, &tid("a"), &[Gate::BootSmoke], "0.8.12", None, false) | |
| 1009 | + | .await | |
| 1010 | + | .unwrap(); | |
| 1010 | 1011 | assert_eq!(pending, vec!["boot_smoke".to_string()]); | |
| 1011 | 1012 | } | |
| 1012 | 1013 | ||
| @@ -1021,6 +1022,7 @@ mod tests { | |||
| 1021 | 1022 | &tid("host"), | |
| 1022 | 1023 | &[Gate::CargoTest, Gate::BootSmoke], | |
| 1023 | 1024 | "0.8.12", | |
| 1025 | + | None, | |
| 1024 | 1026 | false, | |
| 1025 | 1027 | ) | |
| 1026 | 1028 | .await | |
| @@ -1036,9 +1038,16 @@ mod tests { | |||
| 1036 | 1038 | seed(&pool, "host", "0.8.12").await; | |
| 1037 | 1039 | insert_gate(&pool, "host", "0.8.12", "cargo_test", 0).await; | |
| 1038 | 1040 | insert_gate(&pool, "host", "0.8.12", "cargo_test", 1).await; | |
| 1039 | - | let pending = unsatisfied_gates(&pool, &tid("host"), &[Gate::CargoTest], "0.8.12", false) | |
| 1040 | - | .await | |
| 1041 | - | .unwrap(); | |
| 1041 | + | let pending = unsatisfied_gates( | |
| 1042 | + | &pool, | |
| 1043 | + | &tid("host"), | |
| 1044 | + | &[Gate::CargoTest], | |
| 1045 | + | "0.8.12", | |
| 1046 | + | None, | |
| 1047 | + | false, | |
| 1048 | + | ) | |
| 1049 | + | .await | |
| 1050 | + | .unwrap(); | |
| 1042 | 1051 | assert!(pending.is_empty()); | |
| 1043 | 1052 | } | |
| 1044 | 1053 | ||
| @@ -1077,9 +1086,16 @@ mod tests { | |||
| 1077 | 1086 | ||
| 1078 | 1087 | // Stale confirmation (recorded before this landing) -> unsatisfied. | |
| 1079 | 1088 | insert_confirm(&pool, "a", "0.8.12", landed - chrono::Duration::hours(1)).await; | |
| 1080 | - | let pending = unsatisfied_gates(&pool, &tid("a"), &[Gate::ManualConfirm], "0.8.12", false) | |
| 1081 | - | .await | |
| 1082 | - | .unwrap(); | |
| 1089 | + | let pending = unsatisfied_gates( | |
| 1090 | + | &pool, | |
| 1091 | + | &tid("a"), | |
| 1092 | + | &[Gate::ManualConfirm], | |
| 1093 | + | "0.8.12", | |
| 1094 | + | None, | |
| 1095 | + | false, | |
| 1096 | + | ) | |
| 1097 | + | .await | |
| 1098 | + | .unwrap(); | |
| 1083 | 1099 | assert_eq!( | |
| 1084 | 1100 | pending, | |
| 1085 | 1101 | vec!["manual_confirm".to_string()], | |
| @@ -1088,9 +1104,16 @@ mod tests { | |||
| 1088 | 1104 | ||
| 1089 | 1105 | // Fresh confirmation (after this landing) -> satisfied. | |
| 1090 | 1106 | insert_confirm(&pool, "a", "0.8.12", landed + chrono::Duration::minutes(5)).await; | |
| 1091 | - | let pending = unsatisfied_gates(&pool, &tid("a"), &[Gate::ManualConfirm], "0.8.12", false) | |
| 1092 | - | .await | |
| 1093 | - | .unwrap(); | |
| 1107 | + | let pending = unsatisfied_gates( | |
| 1108 | + | &pool, | |
| 1109 | + | &tid("a"), | |
| 1110 | + | &[Gate::ManualConfirm], | |
| 1111 | + | "0.8.12", | |
| 1112 | + | None, | |
| 1113 | + | false, | |
| 1114 | + | ) | |
| 1115 | + | .await | |
| 1116 | + | .unwrap(); | |
| 1094 | 1117 | assert!(pending.is_empty(), "fresh confirm satisfies"); | |
| 1095 | 1118 | } | |
| 1096 | 1119 | ||
| @@ -1101,9 +1124,16 @@ mod tests { | |||
| 1101 | 1124 | let pool = fresh_pool().await; | |
| 1102 | 1125 | seed(&pool, "a", "0.8.12").await; // leaves burn_in_started_at NULL | |
| 1103 | 1126 | insert_confirm(&pool, "a", "0.8.12", chrono::Utc::now()).await; | |
| 1104 | - | let pending = unsatisfied_gates(&pool, &tid("a"), &[Gate::ManualConfirm], "0.8.12", false) | |
| 1105 | - | .await | |
| 1106 | - | .unwrap(); | |
| 1127 | + | let pending = unsatisfied_gates( | |
| 1128 | + | &pool, | |
| 1129 | + | &tid("a"), | |
| 1130 | + | &[Gate::ManualConfirm], | |
| 1131 | + | "0.8.12", | |
| 1132 | + | None, | |
| 1133 | + | false, | |
| 1134 | + | ) | |
| 1135 | + | .await | |
| 1136 | + | .unwrap(); | |
| 1107 | 1137 | assert_eq!( | |
| 1108 | 1138 | pending, | |
| 1109 | 1139 | vec!["manual_confirm".to_string()], | |
| @@ -1122,7 +1152,7 @@ mod tests { | |||
| 1122 | 1152 | insert_gate(&pool, "a", "0.8.12", "cargo_test", 0).await; | |
| 1123 | 1153 | let gates = [Gate::BurnIn { hours: 48 }, Gate::CargoTest]; | |
| 1124 | 1154 | ||
| 1125 | - | let normal = unsatisfied_gates(&pool, &tid("a"), &gates, "0.8.12", false) | |
| 1155 | + | let normal = unsatisfied_gates(&pool, &tid("a"), &gates, "0.8.12", None, false) | |
| 1126 | 1156 | .await | |
| 1127 | 1157 | .unwrap(); | |
| 1128 | 1158 | assert_eq!( | |
| @@ -1130,7 +1160,7 @@ mod tests { | |||
| 1130 | 1160 | vec!["burn_in".to_string(), "cargo_test".to_string()] | |
| 1131 | 1161 | ); | |
| 1132 | 1162 | ||
| 1133 | - | let with_hotfix = unsatisfied_gates(&pool, &tid("a"), &gates, "0.8.12", true) | |
| 1163 | + | let with_hotfix = unsatisfied_gates(&pool, &tid("a"), &gates, "0.8.12", None, true) | |
| 1134 | 1164 | .await | |
| 1135 | 1165 | .unwrap(); | |
| 1136 | 1166 | assert_eq!(with_hotfix, vec!["cargo_test".to_string()]); | |
| @@ -1152,6 +1182,7 @@ mod tests { | |||
| 1152 | 1182 | &tid("a"), | |
| 1153 | 1183 | &[Gate::BurnIn { hours: 48 }], | |
| 1154 | 1184 | "0.8.12", | |
| 1185 | + | None, | |
| 1155 | 1186 | false, | |
| 1156 | 1187 | ) | |
| 1157 | 1188 | .await | |
| @@ -1171,9 +1202,16 @@ mod tests { | |||
| 1171 | 1202 | insert_gate(&pool, "a", "0.8.12", "cargo_test", 0).await; | |
| 1172 | 1203 | insert_gate(&pool, "host", "0.8.11", "cargo_test", 0).await; | |
| 1173 | 1204 | ||
| 1174 | - | let pending = unsatisfied_gates(&pool, &tid("host"), &[Gate::CargoTest], "0.8.12", false) | |
| 1175 | - | .await | |
| 1176 | - | .unwrap(); | |
| 1205 | + | let pending = unsatisfied_gates( | |
| 1206 | + | &pool, | |
| 1207 | + | &tid("host"), | |
| 1208 | + | &[Gate::CargoTest], | |
| 1209 | + | "0.8.12", | |
| 1210 | + | None, | |
| 1211 | + | false, | |
| 1212 | + | ) | |
| 1213 | + | .await | |
| 1214 | + | .unwrap(); | |
| 1177 | 1215 | assert_eq!(pending, vec!["cargo_test".to_string()]); | |
| 1178 | 1216 | } | |
| 1179 | 1217 | ||
| @@ -1192,9 +1230,16 @@ mod tests { | |||
| 1192 | 1230 | .await | |
| 1193 | 1231 | .unwrap(); | |
| 1194 | 1232 | ||
| 1195 | - | let pending = unsatisfied_gates(&pool, &tid("host"), &[Gate::CargoTest], "0.8.12", false) | |
| 1196 | - | .await | |
| 1197 | - | .unwrap(); | |
| 1233 | + | let pending = unsatisfied_gates( | |
| 1234 | + | &pool, | |
| 1235 | + | &tid("host"), | |
| 1236 | + | &[Gate::CargoTest], | |
| 1237 | + | "0.8.12", | |
| 1238 | + | None, | |
| 1239 | + | false, | |
| 1240 | + | ) | |
| 1241 | + | .await | |
| 1242 | + | .unwrap(); | |
| 1198 | 1243 | assert_eq!(pending, vec!["cargo_test".to_string()]); | |
| 1199 | 1244 | } | |
| 1200 | 1245 | ||
| @@ -1551,7 +1596,9 @@ mod tests { | |||
| 1551 | 1596 | // Exercise the sealed forward-advance primitive itself — the same op | |
| 1552 | 1597 | // /promote and the host build path both call (S1), not a copy of its SQL. | |
| 1553 | 1598 | let v = crate::domain::Version::parse("2.0.0").unwrap(); | |
| 1554 | - | crate::runs::advance_tier(&pool, "a", &v).await.unwrap(); | |
| 1599 | + | crate::runs::advance_tier(&pool, "a", &v, None) | |
| 1600 | + | .await | |
| 1601 | + | .unwrap(); | |
| 1555 | 1602 | ||
| 1556 | 1603 | let (cur, prev): (Option<String>, Option<String>) = sqlx::query_as( | |
| 1557 | 1604 | "SELECT current_version, previous_version FROM tier_state WHERE tier = 'a'", | |
| @@ -1599,9 +1646,9 @@ mod tests { | |||
| 1599 | 1646 | } | |
| 1600 | 1647 | ||
| 1601 | 1648 | let mut state = test_state().await; | |
| 1602 | - | // The rollback target needs a versions row (artifact parent = the staged | |
| 1603 | - | // dir, unused for local nodes but resolved by the helper). | |
| 1604 | - | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('1.0.0','sha',datetime('now'),'/tmp/staged/makenotwork')") | |
| 1649 | + | // The rollback target needs a versions row. The release dir name comes | |
| 1650 | + | // from the artifact_path's parent (legacy layout: releases/<version>). | |
| 1651 | + | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('1.0.0','sha',datetime('now'),'/tmp/staged/releases/1.0.0/makenotwork')") | |
| 1605 | 1652 | .execute(&state.pool).await.unwrap(); | |
| 1606 | 1653 | let execs: crate::state::ExecutorMap = nodes | |
| 1607 | 1654 | .iter() | |
| @@ -1809,8 +1856,11 @@ mod tests { | |||
| 1809 | 1856 | .unwrap(); | |
| 1810 | 1857 | } | |
| 1811 | 1858 | for v in ["1.0.0", "2.0.0", "3.0.0"] { | |
| 1812 | - | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES (?, 'sha', datetime('now'), '/tmp/staged/makenotwork')") | |
| 1813 | - | .bind(v).execute(&state.pool).await.unwrap(); | |
| 1859 | + | // Legacy (pre-identity) artifact_path is `releases/<version>/<bin>`, | |
| 1860 | + | // so the release dir the node mirrors is named for the version. The | |
| 1861 | + | // fixture reflects that real layout (parent basename == version). | |
| 1862 | + | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES (?, 'sha', datetime('now'), ?)") | |
| 1863 | + | .bind(v).bind(format!("/tmp/staged/releases/{v}/makenotwork")).execute(&state.pool).await.unwrap(); | |
| 1814 | 1864 | } | |
| 1815 | 1865 | sqlx::query("UPDATE tier_state SET current_version = '3.0.0' WHERE tier = 'host'") | |
| 1816 | 1866 | .execute(&state.pool) | |
| @@ -1979,8 +2029,8 @@ mod tests { | |||
| 1979 | 2029 | .bind(rr.to_string_lossy().into_owned()) | |
| 1980 | 2030 | .execute(&state.pool).await.unwrap(); | |
| 1981 | 2031 | for v in [prev, current] { | |
| 1982 | - | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES (?, 'sha', datetime('now'), '/tmp/staged/makenotwork')") | |
| 1983 | - | .bind(v).execute(&state.pool).await.unwrap(); | |
| 2032 | + | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES (?, 'sha', datetime('now'), ?)") | |
| 2033 | + | .bind(v).bind(format!("/tmp/staged/releases/{v}/makenotwork")).execute(&state.pool).await.unwrap(); | |
| 1984 | 2034 | } | |
| 1985 | 2035 | sqlx::query( | |
| 1986 | 2036 | "UPDATE tier_state SET current_version = ?, previous_version = ? WHERE tier = 'a'", | |
| @@ -2113,7 +2163,7 @@ mod tests { | |||
| 2113 | 2163 | ) | |
| 2114 | 2164 | .await | |
| 2115 | 2165 | .unwrap(); | |
| 2116 | - | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('3.0.0','sha',datetime('now'),'/tmp/staged/makenotwork')") | |
| 2166 | + | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('3.0.0','sha',datetime('now'),'/tmp/staged/releases/3.0.0/makenotwork')") | |
| 2117 | 2167 | .execute(&pool).await.unwrap(); | |
| 2118 | 2168 | sqlx::query("UPDATE tier_state SET current_version = '3.0.0' WHERE tier = 'host'") | |
| 2119 | 2169 | .execute(&pool) | |
| @@ -2188,7 +2238,7 @@ mod tests { | |||
| 2188 | 2238 | state.executors = Arc::new(crate::state::ExecutorMap::new()); | |
| 2189 | 2239 | let pool = state.pool.clone(); | |
| 2190 | 2240 | // Promote 3.0.0 up from host, which configures no gates of its own. | |
| 2191 | - | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('3.0.0','sha',datetime('now'),'/tmp/staged/makenotwork')") | |
| 2241 | + | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('3.0.0','sha',datetime('now'),'/tmp/staged/releases/3.0.0/makenotwork')") | |
| 2192 | 2242 | .execute(&pool).await.unwrap(); | |
| 2193 | 2243 | sqlx::query("UPDATE tier_state SET current_version = '3.0.0' WHERE tier = 'host'") | |
| 2194 | 2244 | .execute(&pool) | |
| @@ -2562,4 +2612,200 @@ mod tests { | |||
| 2562 | 2612 | .unwrap(); | |
| 2563 | 2613 | assert_eq!(resp.status(), StatusCode::NOT_FOUND); | |
| 2564 | 2614 | } | |
| 2615 | + | ||
| 2616 | + | // ---- build-identity path (wiki release-artifact-identity) ---- | |
| 2617 | + | ||
| 2618 | + | /// Insert a settled (passed) build_runs row with content identity and return | |
| 2619 | + | /// its id. `staged_path` is the content-addressed release dir the bundle was | |
| 2620 | + | /// published to (`releases/<digest16>`). | |
| 2621 | + | async fn seed_build(pool: &SqlitePool, sha: &str, version: &str, staged_path: &str) -> i64 { | |
| 2622 | + | sqlx::query_scalar( | |
| 2623 | + | "INSERT INTO build_runs (sha, version, phase, result, started_at, bundle_digest, staged_path) | |
| 2624 | + | VALUES (?, ?, 'done', 'passed', datetime('now'), ?, ?) RETURNING id", | |
| 2625 | + | ) | |
| 2626 | + | .bind(sha) | |
| 2627 | + | .bind(version) | |
| 2628 | + | .bind(format!("{sha}-digest")) | |
| 2629 | + | .bind(staged_path) | |
| 2630 | + | .fetch_one(pool) | |
| 2631 | + | .await | |
| 2632 | + | .unwrap() | |
| 2633 | + | } | |
| 2634 | + | ||
| 2635 | + | async fn insert_gate_build( | |
| 2636 | + | pool: &SqlitePool, | |
| 2637 | + | tier: &str, | |
| 2638 | + | version: &str, | |
| 2639 | + | kind: &str, | |
| 2640 | + | passed: i64, | |
| 2641 | + | build_id: i64, | |
| 2642 | + | ) { | |
| 2643 | + | let status = if passed == 1 { "passed" } else { "failed" }; | |
| 2644 | + | sqlx::query( | |
| 2645 | + | "INSERT INTO gate_runs (version, tier, gate_kind, started_at, finished_at, status, build_id) \ | |
| 2646 | + | VALUES (?, ?, ?, datetime('now'), datetime('now'), ?, ?)", | |
| 2647 | + | ) | |
| 2648 | + | .bind(version) | |
| 2649 | + | .bind(tier) | |
| 2650 | + | .bind(kind) | |
| 2651 | + | .bind(status) | |
| 2652 | + | .bind(build_id) | |
| 2653 | + | .execute(pool) | |
| 2654 | + | .await | |
| 2655 | + | .unwrap(); | |
| 2656 | + | } | |
| 2657 | + | ||
| 2658 | + | #[tokio::test] | |
| 2659 | + | async fn unsatisfied_gates_keys_build_evidence_on_build_id() { | |
| 2660 | + | let pool = fresh_pool().await; | |
| 2661 | + | seed(&pool, "a", "3.0.0").await; | |
| 2662 | + | // Two builds of the SAME version string. Only b1's gate ran. | |
| 2663 | + | let b1 = seed_build(&pool, "sha1", "3.0.0", "/rel/1111111111111111").await; | |
| 2664 | + | let b2 = seed_build(&pool, "sha2", "3.0.0", "/rel/2222222222222222").await; | |
| 2665 | + | insert_gate_build(&pool, "a", "3.0.0", "cargo_test", 1, b1).await; | |
| 2666 | + | ||
| 2667 | + | // b1's own evidence satisfies. | |
| 2668 | + | let ok = unsatisfied_gates( | |
| 2669 | + | &pool, | |
| 2670 | + | &tid("a"), | |
| 2671 | + | &[Gate::CargoTest], | |
| 2672 | + | "3.0.0", | |
| 2673 | + | Some(b1), | |
| 2674 | + | false, | |
| 2675 | + | ) | |
| 2676 | + | .await | |
| 2677 | + | .unwrap(); | |
| 2678 | + | assert!(ok.is_empty(), "the build that passed its gate is satisfied"); | |
| 2679 | + | ||
| 2680 | + | // b2 shares the version but has no gate row of its own: fail closed. This | |
| 2681 | + | // is the hole — a rebuild reusing the version must not ride b1's evidence. | |
| 2682 | + | let bad = unsatisfied_gates( | |
| 2683 | + | &pool, | |
| 2684 | + | &tid("a"), | |
| 2685 | + | &[Gate::CargoTest], | |
| 2686 | + | "3.0.0", | |
| 2687 | + | Some(b2), | |
| 2688 | + | false, | |
| 2689 | + | ) | |
| 2690 | + | .await | |
| 2691 | + | .unwrap(); | |
| 2692 | + | assert_eq!( | |
| 2693 | + | bad, | |
| 2694 | + | vec!["cargo_test".to_string()], | |
| 2695 | + | "a different build of the same version does not inherit the evidence" | |
| 2696 | + | ); | |
| 2697 | + | ||
| 2698 | + | // Legacy (pre-identity) callers still resolve by version string. | |
| 2699 | + | let legacy = unsatisfied_gates(&pool, &tid("a"), &[Gate::CargoTest], "3.0.0", None, false) | |
| 2700 | + | .await | |
| 2701 | + | .unwrap(); | |
| 2702 | + | assert!(legacy.is_empty(), "version-keyed legacy path unchanged"); | |
| 2703 | + | } | |
| 2704 | + | ||
| 2705 | + | #[tokio::test] | |
| 2706 | + | async fn promote_rejects_an_explicit_version_that_is_not_the_source_build() { | |
| 2707 | + | // The burn-in hole: `promote --version Y` used to check the SOURCE tier's | |
| 2708 | + | // clock/evidence (which belong to whatever is current there), letting Y | |
| 2709 | + | // inherit another build's 48h. Now promote resolves the source's current | |
| 2710 | + | // build and refuses an explicit version that isn't it. | |
| 2711 | + | let state = test_state().await; | |
| 2712 | + | seed_version(&state.pool, "3.0.0").await; | |
| 2713 | + | let b = seed_build(&state.pool, "shaB", "3.0.0", "/rel/deadbeefdeadbeef").await; | |
| 2714 | + | sqlx::query( | |
| 2715 | + | "UPDATE tier_state SET current_version='3.0.0', current_build_id=? WHERE tier='host'", | |
| 2716 | + | ) | |
| 2717 | + | .bind(b) | |
| 2718 | + | .execute(&state.pool) | |
| 2719 | + | .await | |
| 2720 | + | .unwrap(); | |
| 2721 | + | ||
| 2722 | + | let err = promote_inner( | |
| 2723 | + | state, | |
| 2724 | + | "a".into(), | |
| 2725 | + | PromoteBody { | |
| 2726 | + | version: Some("2.0.0".into()), | |
| 2727 | + | ..Default::default() | |
| 2728 | + | }, | |
| 2729 | + | ) | |
| 2730 | + | .await | |
| 2731 | + | .expect_err("promoting a version other than the source build must be refused"); | |
| 2732 | + | assert!( | |
| 2733 | + | matches!(&err, crate::error::Error::GateBlocked(m) if m.contains("vouched for")), | |
| 2734 | + | "the refusal must name the mismatch, got: {err:?}", | |
| 2735 | + | ); | |
| 2736 | + | } | |
| 2737 | + | ||
| 2738 | + | #[tokio::test] | |
| 2739 | + | async fn promote_identity_path_deploys_the_source_build_and_advances_build_id() { | |
| 2740 | + | // Full promote through the identity path: source tier points at a build, | |
| 2741 | + | // promote resolves the artifact through it, deploys build_runs.staged_path | |
| 2742 | + | // (content-addressed), and advances the target's current_build_id. | |
| 2743 | + | let mut state = test_state().await; | |
| 2744 | + | let node_root = tempfile::tempdir().unwrap(); | |
| 2745 | + | // Point the a-local node at a real tempdir so the symlink swap is checkable. | |
| 2746 | + | let mut topo = (*state.topo).clone(); | |
| 2747 | + | topo.tiers[1].nodes[0].release_root = node_root.path().to_string_lossy().into_owned(); | |
| 2748 | + | topo.tiers[1].gates = vec![]; // isolate the deploy fan-out | |
| 2749 | + | state.topo = Arc::new(topo); | |
| 2750 | + | state.executors = Arc::new(crate::state::build_executors(&state.topo)); | |
| 2751 | + | ||
| 2752 | + | // deploys.node FKs into `nodes`, so the promote path needs the row. | |
| 2753 | + | sqlx::query("INSERT INTO nodes (name, tier, ssh_target, release_root) VALUES ('a-local', 'a', 'local', ?)") | |
| 2754 | + | .bind(node_root.path().to_string_lossy().into_owned()) | |
| 2755 | + | .execute(&state.pool) | |
| 2756 | + | .await | |
| 2757 | + | .unwrap(); | |
| 2758 | + | ||
| 2759 | + | seed_version(&state.pool, "3.0.0").await; | |
| 2760 | + | let staged = format!( | |
| 2761 | + | "{}/releases/abc123abc123abc1", | |
| 2762 | + | node_root.path().to_string_lossy() | |
| 2763 | + | ); | |
| 2764 | + | let b = seed_build(&state.pool, "shaB", "3.0.0", &staged).await; | |
| 2765 | + | sqlx::query( | |
| 2766 | + | "UPDATE tier_state SET current_version='3.0.0', current_build_id=? WHERE tier='host'", | |
| 2767 | + | ) | |
| 2768 | + | .bind(b) | |
| 2769 | + | .execute(&state.pool) | |
| 2770 | + | .await | |
| 2771 | + | .unwrap(); | |
| 2772 | + | ||
| 2773 | + | let pool = state.pool.clone(); | |
| 2774 | + | let Json(body) = promote_inner(state, "a".into(), PromoteBody::default()) | |
| 2775 | + | .await | |
| 2776 | + | .expect("identity-path promote succeeds"); | |
| 2777 | + | assert_eq!(body["version"], "3.0.0"); | |
| 2778 | + | ||
| 2779 | + | // Target tier advanced with the build identity, not just the version. | |
| 2780 | + | let (cur_v, cur_b): (Option<String>, Option<i64>) = sqlx::query_as( | |
| 2781 | + | "SELECT current_version, current_build_id FROM tier_state WHERE tier = 'a'", | |
| 2782 | + | ) | |
| 2783 | + | .fetch_one(&pool) | |
| 2784 | + | .await | |
| 2785 | + | .unwrap(); | |
| 2786 | + | assert_eq!(cur_v.as_deref(), Some("3.0.0")); | |
| 2787 | + | assert_eq!(cur_b, Some(b), "target records the promoted build id"); | |
| 2788 | + | ||
| 2789 | + | // The deploy row is attributed to the build. | |
| 2790 | + | let deploy_b: Option<i64> = sqlx::query_scalar( | |
| 2791 | + | "SELECT build_id FROM deploys WHERE tier = 'a' ORDER BY id DESC LIMIT 1", | |
| 2792 | + | ) | |
| 2793 | + | .fetch_one(&pool) | |
| 2794 | + | .await | |
| 2795 | + | .unwrap(); | |
| 2796 | + | assert_eq!(deploy_b, Some(b)); | |
| 2797 | + | ||
| 2798 | + | // The node's `current` points at the content-addressed release dir, whose | |
| 2799 | + | // name is the staged_path's basename — not the version. | |
| 2800 | + | let link = tokio::fs::read_link(node_root.path().join("current")) | |
| 2801 | + | .await | |
| 2802 | + | .unwrap(); | |
| 2803 | + | assert_eq!(link.to_string_lossy(), "releases/abc123abc123abc1"); | |
| 2804 | + | } | |
| 2805 | + | ||
| 2806 | + | /// Insert a bare `versions` row (FK target for tier_state.current_version). | |
| 2807 | + | async fn seed_version(pool: &SqlitePool, version: &str) { | |
| 2808 | + | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES (?, 'sha', datetime('now'), '/tmp/x') ON CONFLICT DO NOTHING") | |
| 2809 | + | .bind(version).execute(pool).await.unwrap(); | |
| 2810 | + | } | |
| 2565 | 2811 | } |
| @@ -39,29 +39,97 @@ pub(super) async fn promote_inner( | |||
| 39 | 39 | ))); | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | - | // Resolve version: explicit if given, else the source tier's current. | |
| 43 | - | let version_str = match body.version { | |
| 44 | - | Some(v) => v, | |
| 45 | - | None => sqlx::query_scalar::<_, Option<String>>( | |
| 46 | - | "SELECT current_version FROM tier_state WHERE tier = ?", | |
| 47 | - | ) | |
| 48 | - | .bind(&source.name) | |
| 49 | - | .fetch_optional(&s.pool) | |
| 50 | - | .await | |
| 51 | - | .map_err(crate::error::Error::Db)? | |
| 52 | - | .flatten() | |
| 53 | - | .ok_or_else(|| { | |
| 54 | - | crate::error::Error::GateBlocked(format!( | |
| 55 | - | "no version specified and tier {} has no current_version", | |
| 56 | - | source.name | |
| 57 | - | )) | |
| 58 | - | })?, | |
| 42 | + | // Resolve the artifact through the SOURCE tier's evidence, not a version | |
| 43 | + | // string (invariant 2, wiki [[release-artifact-identity]]). The build that is | |
| 44 | + | // *current on the source tier* is the one that burned in and passed its gates | |
| 45 | + | // there; promoting anything else lets a version string ride on another build's | |
| 46 | + | // evidence and clock — the "promote --version Y inherits X's 48h" hole. A | |
| 47 | + | // given `body.version` must name that same build. | |
| 48 | + | let source_build: Option<(Option<i64>, Option<String>, Option<String>)> = sqlx::query_as( | |
| 49 | + | "SELECT ts.current_build_id, br.version, br.staged_path | |
| 50 | + | FROM tier_state ts | |
| 51 | + | LEFT JOIN build_runs br ON br.id = ts.current_build_id | |
| 52 | + | WHERE ts.tier = ?", | |
| 53 | + | ) | |
| 54 | + | .bind(&source.name) | |
| 55 | + | .fetch_optional(&s.pool) | |
| 56 | + | .await | |
| 57 | + | .map_err(crate::error::Error::Db)?; | |
| 58 | + | ||
| 59 | + | let (build_id, version_str, staged_dir) = match source_build { | |
| 60 | + | // Identity path: the source tier points at a recorded build. | |
| 61 | + | Some((Some(bid), ver, staged_path)) => { | |
| 62 | + | let version_str = ver.ok_or_else(|| { | |
| 63 | + | crate::error::Error::Other(anyhow::anyhow!( | |
| 64 | + | "source build {bid} on tier {} has no recorded version", | |
| 65 | + | source.name | |
| 66 | + | )) | |
| 67 | + | })?; | |
| 68 | + | let staged_path = staged_path.ok_or_else(|| { | |
| 69 | + | crate::error::Error::Other(anyhow::anyhow!( | |
| 70 | + | "source build {bid} ({version_str}) has no staged_path; cannot promote" | |
| 71 | + | )) | |
| 72 | + | })?; | |
| 73 | + | if let Some(req) = &body.version | |
| 74 | + | && req != &version_str | |
| 75 | + | { | |
| 76 | + | return Err(crate::error::Error::GateBlocked(format!( | |
| 77 | + | "tier {} is running build {bid} ({version_str}); refusing to promote an \ | |
| 78 | + | explicit version {req} that is not the build the tier vouched for", | |
| 79 | + | source.name | |
| 80 | + | ))); | |
| 81 | + | } | |
| 82 | + | ( | |
| 83 | + | Some(bid), | |
| 84 | + | version_str, | |
| 85 | + | std::path::PathBuf::from(staged_path), | |
| 86 | + | ) | |
| 87 | + | } | |
| 88 | + | // Legacy fallback: NULL current_build_id (a pre-identity tier). Resolve by | |
| 89 | + | // version string through the versions table, exactly as before. One clean | |
| 90 | + | // build-and-promote cycle re-anchors the tier onto the identity path. | |
| 91 | + | _ => { | |
| 92 | + | let version_str = match body.version.clone() { | |
| 93 | + | Some(v) => v, | |
| 94 | + | None => sqlx::query_scalar::<_, Option<String>>( | |
| 95 | + | "SELECT current_version FROM tier_state WHERE tier = ?", | |
| 96 | + | ) | |
| 97 | + | .bind(&source.name) | |
| 98 | + | .fetch_optional(&s.pool) | |
| 99 | + | .await | |
| 100 | + | .map_err(crate::error::Error::Db)? | |
| 101 | + | .flatten() | |
| 102 | + | .ok_or_else(|| { | |
| 103 | + | crate::error::Error::GateBlocked(format!( | |
| 104 | + | "no version specified and tier {} has no current_version", | |
| 105 | + | source.name | |
| 106 | + | )) | |
| 107 | + | })?, | |
| 108 | + | }; | |
| 109 | + | let bin: Option<(String,)> = | |
| 110 | + | sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?") | |
| 111 | + | .bind(&version_str) | |
| 112 | + | .fetch_optional(&s.pool) | |
| 113 | + | .await | |
| 114 | + | .map_err(crate::error::Error::Db)?; | |
| 115 | + | let Some((bin,)) = bin else { | |
| 116 | + | return Err(crate::error::Error::NotFound); | |
| 117 | + | }; | |
| 118 | + | // `artifact_path` is the primary binary; the release dir is its parent. | |
| 119 | + | let staged_dir = std::path::PathBuf::from(&bin) | |
| 120 | + | .parent() | |
| 121 | + | .ok_or_else(|| { | |
| 122 | + | crate::error::Error::Other(anyhow::anyhow!("artifact_path has no parent")) | |
| 123 | + | })? | |
| 124 | + | .to_path_buf(); | |
| 125 | + | (None, version_str, staged_dir) | |
| 126 | + | } | |
| 59 | 127 | }; | |
| 60 | 128 | let version = crate::domain::Version::parse(&version_str) | |
| 61 | 129 | .map_err(|e| crate::error::Error::Other(anyhow::anyhow!(e)))?; | |
| 62 | 130 | ||
| 63 | 131 | // 1. Predecessor must have all of its configured gates satisfied for this | |
| 64 | - | // version (with optional hotfix override that skips burn_in). Evaluated | |
| 132 | + | // build (with optional hotfix override that skips burn_in). Evaluated | |
| 65 | 133 | // against the topology gate list, so a gate that never ran blocks the | |
| 66 | 134 | // promote instead of being treated as green. | |
| 67 | 135 | let pending = unsatisfied_gates( | |
| @@ -69,6 +137,7 @@ pub(super) async fn promote_inner( | |||
| 69 | 137 | &source.name, | |
| 70 | 138 | &source.gates, | |
| 71 | 139 | &version_str, | |
| 140 | + | build_id, | |
| 72 | 141 | body.hotfix, | |
| 73 | 142 | ) | |
| 74 | 143 | .await?; | |
| @@ -81,23 +150,6 @@ pub(super) async fn promote_inner( | |||
| 81 | 150 | ))); | |
| 82 | 151 | } | |
| 83 | 152 | ||
| 84 | - | // 2. Look up the artifact for this version. | |
| 85 | - | let bin: Option<(String,)> = | |
| 86 | - | sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?") | |
| 87 | - | .bind(&version) | |
| 88 | - | .fetch_optional(&s.pool) | |
| 89 | - | .await | |
| 90 | - | .map_err(crate::error::Error::Db)?; | |
| 91 | - | let Some((bin,)) = bin else { | |
| 92 | - | return Err(crate::error::Error::NotFound); | |
| 93 | - | }; | |
| 94 | - | let bin_path = std::path::PathBuf::from(bin); | |
| 95 | - | // `artifact_path` is the primary binary; the staged release dir is its parent. | |
| 96 | - | let staged_dir = bin_path | |
| 97 | - | .parent() | |
| 98 | - | .ok_or_else(|| crate::error::Error::Other(anyhow::anyhow!("artifact_path has no parent")))? | |
| 99 | - | .to_path_buf(); | |
| 100 | - | ||
| 101 | 153 | // The version this tier was running before this promote — the rollback | |
| 102 | 154 | // target if a canary node fails partway through a multi-node rollout. | |
| 103 | 155 | let prev_version: Option<String> = | |
| @@ -148,13 +200,14 @@ pub(super) async fn promote_inner( | |||
| 148 | 200 | let outcome_json = serde_json::to_string(&outcome_obj) | |
| 149 | 201 | .unwrap_or_else(|e| format!("{{\"_serialize_error\":{e:?}}}")); | |
| 150 | 202 | sqlx::query( | |
| 151 | - | "INSERT INTO deploys (version, tier, node, started_at, finished_at, outcome, outcome_json, hotfix, reset_burn_in) | |
| 152 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 203 | + | "INSERT INTO deploys (version, tier, node, started_at, finished_at, outcome, outcome_json, hotfix, reset_burn_in, build_id) | |
| 204 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 153 | 205 | ) | |
| 154 | 206 | .bind(&version).bind(&target.name).bind(&node.name) | |
| 155 | 207 | .bind(&started).bind(&finished).bind(outcome_obj.status_str()) | |
| 156 | 208 | .bind(&outcome_json) | |
| 157 | 209 | .bind(body.hotfix as i64).bind(body.reset_burn_in as i64) | |
| 210 | + | .bind(build_id) | |
| 158 | 211 | .execute(&s.pool).await.map_err(crate::error::Error::Db)?; | |
| 159 | 212 | if let Some(e) = err_for_propagation { | |
| 160 | 213 | let crate::outcome::DeployStatus::Failed { failure } = outcome_obj.status else { | |
| @@ -301,6 +354,10 @@ pub(super) async fn promote_inner( | |||
| 301 | 354 | worktree: std::path::PathBuf::new(), | |
| 302 | 355 | events: s.events.clone(), | |
| 303 | 356 | nodes, | |
| 357 | + | // These post-deploy gate rows are the evidence the NEXT promote (this | |
| 358 | + | // tier -> the following one) resolves through, so they must carry the | |
| 359 | + | // build they vouch for. | |
| 360 | + | build_id, | |
| 304 | 361 | }; | |
| 305 | 362 | post_deploy_failure = match crate::gates::run_all(&ctx, &post_deploy).await { | |
| 306 | 363 | Ok(failed) if failed.is_empty() => None, | |
| @@ -338,7 +395,7 @@ pub(super) async fn promote_inner( | |||
| 338 | 395 | // serialized against rollback and the host build path's advance. | |
| 339 | 396 | // reset_burn_in on the *source* tier nulls its clock only when the operator | |
| 340 | 397 | // explicitly asked. | |
| 341 | - | crate::runs::advance_tier(&s.pool, target.name.as_str(), &version) | |
| 398 | + | crate::runs::advance_tier(&s.pool, target.name.as_str(), &version, build_id) | |
| 342 | 399 | .await | |
| 343 | 400 | .map_err(crate::error::Error::Db)?; | |
| 344 | 401 | ||
| @@ -497,11 +554,22 @@ pub(super) async fn clear_partial(s: &AppState, tier: &crate::domain::TierId) { | |||
| 497 | 554 | /// row would otherwise never flip to passed as time elapses); | |
| 498 | 555 | /// - every other kind requires a `passed` row for (tier, version) — a missing | |
| 499 | 556 | /// or non-passed latest row counts as unsatisfied. | |
| 557 | + | /// | |
| 558 | + | /// `build_id` is the identity of the artifact being promoted (wiki note | |
| 559 | + | /// `release-artifact-identity`). When `Some`, the deterministic build-evidence | |
| 560 | + | /// gates are checked against the row that vouched for *that build* rather than | |
| 561 | + | /// against any row that happens to carry the version string — this is what stops | |
| 562 | + | /// a `promote --version Y` from riding on gate rows a different build left under | |
| 563 | + | /// the same version. `None` is the legacy/pre-identity path: fall back to the | |
| 564 | + | /// version-keyed lookup so a mid-migration tier (NULL `current_build_id`) still | |
| 565 | + | /// promotes. `burn_in` is clock-based either way — the clock is reset on every | |
| 566 | + | /// advance, so it belongs to the build now current on the tier. | |
| 500 | 567 | pub(super) async fn unsatisfied_gates( | |
| 501 | 568 | pool: &sqlx::SqlitePool, | |
| 502 | 569 | tier: &crate::domain::TierId, | |
| 503 | 570 | gates: &[crate::topology::Gate], | |
| 504 | 571 | version: &str, | |
| 572 | + | build_id: Option<i64>, | |
| 505 | 573 | hotfix: bool, | |
| 506 | 574 | ) -> std::result::Result<Vec<String>, crate::error::Error> { | |
| 507 | 575 | use crate::topology::Gate; | |
| @@ -580,14 +648,26 @@ pub(super) async fn unsatisfied_gates( | |||
| 580 | 648 | | Gate::NodeHealth => { | |
| 581 | 649 | // Latest row for this configured gate kind; NULL/missing/any | |
| 582 | 650 | // non-'passed' status all count as unsatisfied (fail closed). | |
| 583 | - | let status: Option<String> = sqlx::query_scalar( | |
| 584 | - | "SELECT status FROM gate_runs | |
| 585 | - | WHERE tier = ?1 AND version = ?2 AND gate_kind = ?3 | |
| 586 | - | ORDER BY id DESC LIMIT 1", | |
| 587 | - | ) | |
| 588 | - | .bind(tier.as_str()) | |
| 589 | - | .bind(version) | |
| 590 | - | .bind(kind.as_str()) | |
| 651 | + | // Keyed on build_id when the artifact has an identity (the | |
| 652 | + | // evidence must be for *this* build), else on the version string. | |
| 653 | + | let status: Option<String> = match build_id { | |
| 654 | + | Some(bid) => sqlx::query_scalar( | |
| 655 | + | "SELECT status FROM gate_runs | |
| 656 | + | WHERE tier = ?1 AND build_id = ?2 AND gate_kind = ?3 | |
| 657 | + | ORDER BY id DESC LIMIT 1", | |
| 658 | + | ) | |
| 659 | + | .bind(tier.as_str()) | |
| 660 | + | .bind(bid) | |
| 661 | + | .bind(kind.as_str()), | |
| 662 | + | None => sqlx::query_scalar( | |
| 663 | + | "SELECT status FROM gate_runs | |
| 664 | + | WHERE tier = ?1 AND version = ?2 AND gate_kind = ?3 | |
| 665 | + | ORDER BY id DESC LIMIT 1", | |
| 666 | + | ) | |
| 667 | + | .bind(tier.as_str()) | |
| 668 | + | .bind(version) | |
| 669 | + | .bind(kind.as_str()), | |
| 670 | + | } | |
| 591 | 671 | .fetch_optional(pool) | |
| 592 | 672 | .await | |
| 593 | 673 | .map_err(crate::error::Error::Db)? |
| @@ -79,19 +79,35 @@ pub async fn set_phase(pool: &SqlitePool, run_id: RunId, phase: Phase) -> Result | |||
| 79 | 79 | /// | |
| 80 | 80 | /// Returns the raw `sqlx::Error` so the route layer can map it to its typed | |
| 81 | 81 | /// `Error::Db`; anyhow callers (the build pipeline) get `?`-conversion for free. | |
| 82 | + | /// `build_id` is the `build_runs.id` of the build landing on the tier — the | |
| 83 | + | /// artifact identity (wiki [[release-artifact-identity]]). It advances in | |
| 84 | + | /// lockstep with the version label: `previous_build_id` takes the old | |
| 85 | + | /// `current_build_id` in the same self-referential UPDATE, so the build the | |
| 86 | + | /// tier just stepped off is the rollback target. `None` for legacy/pre-identity | |
| 87 | + | /// callers writes NULL — treated as "no recorded build" downstream, with the | |
| 88 | + | /// version-string path as the fallback. | |
| 89 | + | /// | |
| 90 | + | /// `burn_in_started_at = now` starts the tier's burn-in clock. Because the | |
| 91 | + | /// clock resets on every advance and the clock is what burn-in reads, the clock | |
| 92 | + | /// always belongs to the build now current on the tier — this is what stops a | |
| 93 | + | /// promote from crediting one build with another build's elapsed burn-in. | |
| 82 | 94 | pub async fn advance_tier( | |
| 83 | 95 | pool: &SqlitePool, | |
| 84 | 96 | tier: &str, | |
| 85 | 97 | version: &Version, | |
| 98 | + | build_id: Option<i64>, | |
| 86 | 99 | ) -> Result<(), sqlx::Error> { | |
| 87 | 100 | sqlx::query( | |
| 88 | 101 | "UPDATE tier_state | |
| 89 | - | SET previous_version = current_version, | |
| 90 | - | current_version = ?, | |
| 102 | + | SET previous_version = current_version, | |
| 103 | + | current_version = ?, | |
| 104 | + | previous_build_id = current_build_id, | |
| 105 | + | current_build_id = ?, | |
| 91 | 106 | burn_in_started_at = ? | |
| 92 | 107 | WHERE tier = ?", | |
| 93 | 108 | ) | |
| 94 | 109 | .bind(version) | |
| 110 | + | .bind(build_id) | |
| 95 | 111 | .bind(Utc::now().to_rfc3339()) | |
| 96 | 112 | .bind(tier) | |
| 97 | 113 | .execute(pool) | |
| @@ -109,6 +125,29 @@ pub async fn set_version(pool: &SqlitePool, run_id: RunId, version: &Version) -> | |||
| 109 | 125 | Ok(()) | |
| 110 | 126 | } | |
| 111 | 127 | ||
| 128 | + | /// Record the build's content identity once its bundle has been staged and | |
| 129 | + | /// hashed: the full 64-hex `bundle_digest` and the `staged_path` it lives at. | |
| 130 | + | /// This is what promote/burn-in/retention key on once build id becomes the | |
| 131 | + | /// identity (wiki [[release-artifact-identity]]). Guarded on `building` so a | |
| 132 | + | /// settled run is never mutated. | |
| 133 | + | pub async fn set_identity( | |
| 134 | + | pool: &SqlitePool, | |
| 135 | + | run_id: RunId, | |
| 136 | + | bundle_digest: &str, | |
| 137 | + | staged_path: &str, | |
| 138 | + | ) -> Result<()> { | |
| 139 | + | sqlx::query( | |
| 140 | + | "UPDATE build_runs SET bundle_digest = ?, staged_path = ? | |
| 141 | + | WHERE id = ? AND result = 'building'", | |
| 142 | + | ) | |
| 143 | + | .bind(bundle_digest) | |
| 144 | + | .bind(staged_path) | |
| 145 | + | .bind(run_id.0) | |
| 146 | + | .execute(pool) | |
| 147 | + | .await?; | |
| 148 | + | Ok(()) | |
| 149 | + | } | |
| 150 | + | ||
| 112 | 151 | /// Settle the run green. First terminal write wins (guarded on `building`). | |
| 113 | 152 | pub async fn mark_passed(pool: &SqlitePool, run_id: RunId) -> Result<()> { | |
| 114 | 153 | sqlx::query( |