max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
11 files changed,
+621 insertions,
-184 deletions
| @@ -1227,7 +1227,7 @@ | |||
| 1227 | 1227 | ||
| 1228 | 1228 | [[package]] | |
| 1229 | 1229 | name = "sando-daemon" | |
| 1230 | - | version = "0.2.8" | |
| 1230 | + | version = "0.2.9" | |
| 1231 | 1231 | dependencies = [ | |
| 1232 | 1232 | "anyhow", | |
| 1233 | 1233 | "async-trait", | |
| @@ -2289,29 +2289,33 @@ | |||
| 2289 | 2289 | name = "tagtree" | |
| 2290 | 2290 | version = "0.4.0" | |
| 2291 | 2291 | ||
| 2292 | + | [[patch.unused]] | |
| 2293 | + | name = "quasi-type" | |
| 2294 | + | version = "0.1.0" | |
| 2295 | + | ||
| 2292 | 2296 | [[patch.unused]] | |
| 2293 | 2297 | name = "quasi-axum" | |
| 2294 | - | version = "0.36.0" | |
| 2298 | + | version = "0.44.0" | |
| 2295 | 2299 | ||
| 2296 | 2300 | [[patch.unused]] | |
| 2297 | 2301 | name = "quasi-basics" | |
| 2298 | - | version = "0.36.0" | |
| 2302 | + | version = "0.44.0" | |
| 2299 | 2303 | ||
| 2300 | 2304 | [[patch.unused]] | |
| 2301 | 2305 | name = "quasi-http" | |
| 2302 | - | version = "0.36.0" | |
| 2306 | + | version = "0.44.0" | |
| 2303 | 2307 | ||
| 2304 | 2308 | [[patch.unused]] | |
| 2305 | 2309 | name = "quasi-immediate" | |
| 2306 | - | version = "0.36.0" | |
| 2310 | + | version = "0.44.0" | |
| 2307 | 2311 | ||
| 2308 | 2312 | [[patch.unused]] | |
| 2309 | 2313 | name = "quasi-notifs" | |
| 2310 | - | version = "0.36.0" | |
| 2314 | + | version = "0.44.0" | |
| 2311 | 2315 | ||
| 2312 | 2316 | [[patch.unused]] | |
| 2313 | 2317 | name = "quasi-router" | |
| 2314 | - | version = "0.36.0" | |
| 2318 | + | version = "0.44.0" | |
| 2315 | 2319 | ||
| 2316 | 2320 | [[patch.unused]] | |
| 2317 | 2321 | name = "quasi-store" | |
| @@ -2319,19 +2323,15 @@ | |||
| 2319 | 2323 | ||
| 2320 | 2324 | [[patch.unused]] | |
| 2321 | 2325 | name = "quasi-tauri" | |
| 2322 | - | version = "0.36.0" | |
| 2326 | + | version = "0.44.0" | |
| 2323 | 2327 | ||
| 2324 | 2328 | [[patch.unused]] | |
| 2325 | 2329 | name = "quasi-webview" | |
| 2326 | - | version = "0.36.0" | |
| 2327 | - | ||
| 2328 | - | [[patch.unused]] | |
| 2329 | - | name = "quasi-type" | |
| 2330 | - | version = "0.1.0" | |
| 2330 | + | version = "0.44.0" | |
| 2331 | 2331 | ||
| 2332 | 2332 | [[patch.unused]] | |
| 2333 | 2333 | name = "makeover-build" | |
| 2334 | - | version = "0.46.0" | |
| 2334 | + | version = "0.47.0" | |
| 2335 | 2335 | ||
| 2336 | 2336 | [[patch.unused]] | |
| 2337 | 2337 | name = "makeover-immediate" | |
| @@ -2351,4 +2351,4 @@ | |||
| 2351 | 2351 | ||
| 2352 | 2352 | [[patch.unused]] | |
| 2353 | 2353 | name = "makeover-webview" | |
| 2354 | - | version = "0.51.0" | |
| 2354 | + | version = "0.52.0" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "sando-daemon" | |
| 3 | - | version = "0.2.8" | |
| 3 | + | version = "0.2.9" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license = "MIT" | |
| 6 | 6 |
| @@ -21,13 +21,18 @@ | |||
| 21 | 21 | # SANDO_DAEMON_CONFIG /etc/sando/sando-daemon.toml config the --check-config self-test loads | |
| 22 | 22 | # | |
| 23 | 23 | # Source: build from the LOCAL bare repo sandod already maintains | |
| 24 | - | # (/srv/sando/mnw.git), not a remote fetch. The self-update sha is one that was | |
| 25 | - | # already deployed as a server release, so `/rebuild` has force-updated | |
| 26 | - | # refs/heads/main in the bare repo to include it — the sha and a current, trusted | |
| 27 | - | # main are both present locally. Building from a remote (git@ssh.makenot.work) | |
| 28 | - | # gave the `sando` user no git creds and broke the moment git hosting was down — | |
| 29 | - | # it blocked the self-update during the 2026-07-09 deploy (postmortem #7). The | |
| 30 | - | # local bare repo has no such external dependency. | |
| 24 | + | # (/srv/sando/mnw.git), not a remote fetch. Building from a remote | |
| 25 | + | # (git@ssh.makenot.work) gave the `sando` user no git creds and broke the moment | |
| 26 | + | # git hosting was down — it blocked the self-update during the 2026-07-09 deploy | |
| 27 | + | # (postmortem #7). The local bare repo has no such external dependency. | |
| 28 | + | # | |
| 29 | + | # sandod fetches the deploy branch from the canonical remote into that bare repo | |
| 30 | + | # at the top of its /self-update handler, so a sha pushed minutes ago is present | |
| 31 | + | # here. It used to be reachable only if a *server* build had fetched since, which | |
| 32 | + | # chained the controller's currency to the server's release cadence: shipping a | |
| 33 | + | # controller fix meant cutting a server release first, and was impossible at all | |
| 34 | + | # while the server was red. sandod remains the only writer of that repo, so the | |
| 35 | + | # provenance seal below is unchanged. | |
| 31 | 36 | # | |
| 32 | 37 | # Provenance: sandod (bearer-gated) only *triggers* this unit with a hex sha; it | |
| 33 | 38 | # does not prove the sha is a commit anyone intended to deploy. Without a check, |
| @@ -770,7 +770,7 @@ | |||
| 770 | 770 | } else { | |
| 771 | 771 | // Pull the first red gate's typed summary into the run so the API | |
| 772 | 772 | // answers "which gate, and why" — not just "failed". | |
| 773 | - | let summary = crate::runs::first_failed_gate_summary(&pool, &cfg.id, &published.version) | |
| 773 | + | let summary = crate::runs::first_failed_gate_summary(&pool, &cfg.id, run_id) | |
| 774 | 774 | .await | |
| 775 | 775 | .unwrap_or_else(|| "host pipeline red".to_string()); | |
| 776 | 776 | if let Err(e) = crate::runs::mark_failed(&pool, run_id, &summary).await { |
| @@ -146,8 +146,9 @@ | |||
| 146 | 146 | /// `["makenotwork", "mnw-admin"]`. | |
| 147 | 147 | #[serde(default = "default_bin_names")] | |
| 148 | 148 | pub bin_names: Vec<String>, | |
| 149 | - | /// Root for per-gate run logs (`<logs_root>/<version>/<gate>.log`). | |
| 150 | - | /// Served via `GET /logs/{version}/{gate}`. Defaults to `/srv/sando/logs`. | |
| 149 | + | /// Root for per-gate run logs (`<logs_root>/<build_runs.id>/<gate>.log`). | |
| 150 | + | /// Served via `GET /logs/{run}/{gate}`, where `run` is the `build_runs.id` the | |
| 151 | + | /// gate ran for. Defaults to `/srv/sando/logs`. | |
| 151 | 152 | #[serde(default = "default_logs_root")] | |
| 152 | 153 | pub logs_root: PathBuf, | |
| 153 | 154 | /// Shared cargo target dir. When set, every `cargo build`/`cargo test` the |
| @@ -81,6 +81,37 @@ | |||
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | 83 | impl GateCtx { | |
| 84 | + | /// The `logs_root` sub-directory this run's gate logs land in. | |
| 85 | + | /// | |
| 86 | + | /// The build id, so two runs of one version keep two sets of logs. Before | |
| 87 | + | /// 2026-08-20 this was the version, which meant a rebuild appended to the | |
| 88 | + | /// previous attempt's file and every run pointed at the same mixed log. | |
| 89 | + | /// | |
| 90 | + | /// Falls back to the version when there is no build identity — a | |
| 91 | + | /// pre-migration-008 run, or a gate unit test. That is also what keeps logs | |
| 92 | + | /// written under the old scheme reachable: their rows still record the | |
| 93 | + | /// version path, and nothing rewrites them. | |
| 94 | + | pub fn log_scope(&self) -> String { | |
| 95 | + | self.build_id | |
| 96 | + | .map_or_else(|| self.version.to_string(), |id| id.to_string()) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// This run's log pointer for `gate`. Always paired with | |
| 100 | + | /// [`Self::log_path`], which resolves the same ref to an absolute path. | |
| 101 | + | pub fn log_ref(&self, gate: GateKind) -> LogRef { | |
| 102 | + | LogRef::new(&self.log_scope(), gate) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Where `gate`'s log is written on this host: `logs_root` joined to | |
| 106 | + | /// [`Self::log_ref`]. The two are derived from one scope so a row's | |
| 107 | + | /// `log_ref` can never name a file the gate did not write. | |
| 108 | + | pub fn log_path(&self, gate: GateKind) -> PathBuf { | |
| 109 | + | self.cfg | |
| 110 | + | .logs_root | |
| 111 | + | .join(self.log_scope()) | |
| 112 | + | .join(format!("{}.log", gate.as_str())) | |
| 113 | + | } | |
| 114 | + | ||
| 84 | 115 | /// Absolute directory a `test_target` runs in: under the worktree, or under | |
| 85 | 116 | /// the named aux repo's checkout. | |
| 86 | 117 | /// | |
| @@ -203,7 +234,7 @@ | |||
| 203 | 234 | gate: GateKind::MigrationDryRun, | |
| 204 | 235 | after_s: ctx.cfg.gate_timeout_secs as u32, | |
| 205 | 236 | }) | |
| 206 | - | .with_log_ref(LogRef::new(&ctx.version, GateKind::MigrationDryRun))), | |
| 237 | + | .with_log_ref(ctx.log_ref(GateKind::MigrationDryRun))), | |
| 207 | 238 | } | |
| 208 | 239 | } | |
| 209 | 240 | // code_smoke boots the real binary (migrate-from-scratch + seed + serve), | |
| @@ -219,7 +250,7 @@ | |||
| 219 | 250 | gate: GateKind::CodeSmoke, | |
| 220 | 251 | after_s: ctx.cfg.gate_timeout_secs as u32, | |
| 221 | 252 | }) | |
| 222 | - | .with_log_ref(LogRef::new(&ctx.version, GateKind::CodeSmoke))), | |
| 253 | + | .with_log_ref(ctx.log_ref(GateKind::CodeSmoke))), | |
| 223 | 254 | } | |
| 224 | 255 | } | |
| 225 | 256 | Gate::BootSmoke => boot_smoke(ctx, run_id).await, | |
| @@ -305,8 +336,8 @@ | |||
| 305 | 336 | /// operator's answer. Its name is carried in the failure so the summary points | |
| 306 | 337 | /// at the crate, not just the test. | |
| 307 | 338 | async fn cargo_test(ctx: &GateCtx, run_id: GateRunId) -> Result<GateOutcome> { | |
| 308 | - | let log_path = gate_log_path(ctx, GateKind::CargoTest); | |
| 309 | - | let log_ref = LogRef::new(&ctx.version, GateKind::CargoTest); | |
| 339 | + | let log_path = ctx.log_path(GateKind::CargoTest); | |
| 340 | + | let log_ref = ctx.log_ref(GateKind::CargoTest); | |
| 310 | 341 | ||
| 311 | 342 | // Best-effort: drop our own role's stale `mnw_test_*` databases (the | |
| 312 | 343 | // template + any per-test clones orphaned by a previously-killed run) | |
| @@ -606,8 +637,8 @@ | |||
| 606 | 637 | kind: GateKind, | |
| 607 | 638 | args_for: impl Fn(&crate::config::TestTarget, &std::path::Path) -> Option<Vec<String>>, | |
| 608 | 639 | ) -> Result<GateOutcome> { | |
| 609 | - | let log_path = gate_log_path(ctx, kind); | |
| 610 | - | let log_ref = LogRef::new(&ctx.version, kind); | |
| 640 | + | let log_path = ctx.log_path(kind); | |
| 641 | + | let log_ref = ctx.log_ref(kind); | |
| 611 | 642 | let started = std::time::Instant::now(); | |
| 612 | 643 | let deadline = started + std::time::Duration::from_secs(ctx.cfg.gate_timeout_secs); | |
| 613 | 644 | let mut ran = 0usize; | |
| @@ -763,8 +794,8 @@ | |||
| 763 | 794 | all_features: false, | |
| 764 | 795 | scratch_db: true, | |
| 765 | 796 | }; | |
| 766 | - | let log_path = gate_log_path(ctx, GateKind::HardeningTest); | |
| 767 | - | let log_ref = LogRef::new(&ctx.version, GateKind::HardeningTest); | |
| 797 | + | let log_path = ctx.log_path(GateKind::HardeningTest); | |
| 798 | + | let log_ref = ctx.log_ref(GateKind::HardeningTest); | |
| 768 | 799 | ||
| 769 | 800 | if let Some(scratch_url) = ctx.cfg.scratch_db_url.as_deref() { | |
| 770 | 801 | clean_stale_test_dbs(scratch_url).await; | |
| @@ -939,7 +970,7 @@ | |||
| 939 | 970 | let log = GateLog::open(ctx, run_id, GateKind::MigrationDryRun).await; | |
| 940 | 971 | let outcome = migration_dry_run_inner(ctx, &log).await; | |
| 941 | 972 | log.close().await; | |
| 942 | - | outcome.map(|o| o.with_log_ref(LogRef::new(&ctx.version, GateKind::MigrationDryRun))) | |
| 973 | + | outcome.map(|o| o.with_log_ref(ctx.log_ref(GateKind::MigrationDryRun))) | |
| 943 | 974 | } | |
| 944 | 975 | ||
| 945 | 976 | /// The staged interior of [`migration_dry_run`], writing every step through the | |
| @@ -1457,7 +1488,7 @@ | |||
| 1457 | 1488 | let log = GateLog::open(ctx, run_id, GateKind::CodeSmoke).await; | |
| 1458 | 1489 | let outcome = code_smoke_inner(ctx, &log).await; | |
| 1459 | 1490 | log.close().await; | |
| 1460 | - | outcome.map(|o| o.with_log_ref(LogRef::new(&ctx.version, GateKind::CodeSmoke))) | |
| 1491 | + | outcome.map(|o| o.with_log_ref(ctx.log_ref(GateKind::CodeSmoke))) | |
| 1461 | 1492 | } | |
| 1462 | 1493 | ||
| 1463 | 1494 | /// The staged interior of [`code_smoke`], writing every step through the gate's | |
| @@ -2002,8 +2033,8 @@ | |||
| 2002 | 2033 | if let Some(scratch_url) = ctx.cfg.scratch_db_url.as_deref() { | |
| 2003 | 2034 | cmd.env("DATABASE_URL", scratch_url); | |
| 2004 | 2035 | } | |
| 2005 | - | let log_path = gate_log_path(ctx, GateKind::BootSmoke); | |
| 2006 | - | let log_ref = LogRef::new(&ctx.version, GateKind::BootSmoke); | |
| 2036 | + | let log_path = ctx.log_path(GateKind::BootSmoke); | |
| 2037 | + | let log_ref = ctx.log_ref(GateKind::BootSmoke); | |
| 2007 | 2038 | let mut child = match cmd.spawn() { | |
| 2008 | 2039 | Ok(c) => c, | |
| 2009 | 2040 | Err(e) => { | |
| @@ -2190,7 +2221,7 @@ | |||
| 2190 | 2221 | gate: GateKind::PageSmoke, | |
| 2191 | 2222 | after_s: ctx.cfg.gate_timeout_secs as u32, | |
| 2192 | 2223 | }) | |
| 2193 | - | .with_log_ref(LogRef::new(&ctx.version, GateKind::PageSmoke))); | |
| 2224 | + | .with_log_ref(ctx.log_ref(GateKind::PageSmoke))); | |
| 2194 | 2225 | } | |
| 2195 | 2226 | }; | |
| 2196 | 2227 | ||
| @@ -2199,12 +2230,12 @@ | |||
| 2199 | 2230 | String::from_utf8_lossy(&out.stdout), | |
| 2200 | 2231 | String::from_utf8_lossy(&out.stderr) | |
| 2201 | 2232 | ); | |
| 2202 | - | append_to_log(&gate_log_path(ctx, GateKind::PageSmoke), log.as_bytes()).await; | |
| 2233 | + | append_to_log(&ctx.log_path(GateKind::PageSmoke), log.as_bytes()).await; | |
| 2203 | 2234 | ||
| 2204 | 2235 | if out.status.success() { | |
| 2205 | 2236 | return Ok( | |
| 2206 | 2237 | GateOutcome::passed(PassNote::PagesClean { base: base.into() }) | |
| 2207 | - | .with_log_ref(LogRef::new(&ctx.version, GateKind::PageSmoke)), | |
| 2238 | + | .with_log_ref(ctx.log_ref(GateKind::PageSmoke)), | |
| 2208 | 2239 | ); | |
| 2209 | 2240 | } | |
| 2210 | 2241 | ||
| @@ -2222,7 +2253,7 @@ | |||
| 2222 | 2253 | base: base.into(), | |
| 2223 | 2254 | detail: first.to_string(), | |
| 2224 | 2255 | }) | |
| 2225 | - | .with_log_ref(LogRef::new(&ctx.version, GateKind::PageSmoke))) | |
| 2256 | + | .with_log_ref(ctx.log_ref(GateKind::PageSmoke))) | |
| 2226 | 2257 | } | |
| 2227 | 2258 | ||
| 2228 | 2259 | async fn node_health(ctx: &GateCtx) -> Result<GateOutcome> { | |
| @@ -2359,7 +2390,7 @@ | |||
| 2359 | 2390 | async fn open(ctx: &GateCtx, run_id: GateRunId, gate: GateKind) -> Self { | |
| 2360 | 2391 | Self::new( | |
| 2361 | 2392 | LiveLog::open( | |
| 2362 | - | gate_log_path(ctx, gate), | |
| 2393 | + | ctx.log_path(gate), | |
| 2363 | 2394 | gate_chunk_cb(ctx.events.clone(), run_id), | |
| 2364 | 2395 | ) | |
| 2365 | 2396 | .await, | |
| @@ -2433,13 +2464,6 @@ | |||
| 2433 | 2464 | } | |
| 2434 | 2465 | } | |
| 2435 | 2466 | ||
| 2436 | - | fn gate_log_path(ctx: &GateCtx, gate: GateKind) -> PathBuf { | |
| 2437 | - | ctx.cfg | |
| 2438 | - | .logs_root | |
| 2439 | - | .join(ctx.version.to_string()) | |
| 2440 | - | .join(format!("{}.log", gate.as_str())) | |
| 2441 | - | } | |
| 2442 | - | ||
| 2443 | 2467 | /// Live check: has `tier`'s burn-in window of `hours` elapsed since its clock | |
| 2444 | 2468 | /// (`tier_state.burn_in_started_at`, started by a promote onto the tier)? Used | |
| 2445 | 2469 | /// by the promote-time gate check (`unsatisfied_gates`) so a stale `blocked` | |
| @@ -2958,7 +2982,7 @@ | |||
| 2958 | 2982 | /// Close `log` (flushing it) and read back what it wrote on disk. | |
| 2959 | 2983 | async fn read_gate_log(ctx: &GateCtx, log: GateLog) -> String { | |
| 2960 | 2984 | log.close().await; | |
| 2961 | - | tokio::fs::read_to_string(gate_log_path(ctx, GateKind::CodeSmoke)) | |
| 2985 | + | tokio::fs::read_to_string(ctx.log_path(GateKind::CodeSmoke)) | |
| 2962 | 2986 | .await | |
| 2963 | 2987 | .expect("the gate log must exist on disk") | |
| 2964 | 2988 | } |
| @@ -92,6 +92,27 @@ | |||
| 92 | 92 | Ok(()) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | + | /// True iff `sha` is reachable from `refs/heads/<branch>` in the bare repo. | |
| 96 | + | /// | |
| 97 | + | /// The same provenance seal `deploy/sando-self-update.sh` enforces before it | |
| 98 | + | /// builds, evaluated here so `/self-update` can refuse a non-deploy-branch sha | |
| 99 | + | /// synchronously instead of returning `accepted: true` and failing with exit 4 | |
| 100 | + | /// in the updater unit's journal, where nobody is watching. | |
| 101 | + | /// | |
| 102 | + | /// Fail-closed: `git merge-base --is-ancestor` exits 1 for a non-ancestor and | |
| 103 | + | /// >1 for an unresolvable ref, and both read as "not an ancestor" here. | |
| 104 | + | pub async fn is_ancestor(bare: &Path, sha: &str, branch: &str) -> Result<bool> { | |
| 105 | + | let out = Command::new("git") | |
| 106 | + | .arg("--git-dir") | |
| 107 | + | .arg(bare) | |
| 108 | + | .args(["merge-base", "--is-ancestor", sha]) | |
| 109 | + | .arg(format!("refs/heads/{branch}")) | |
| 110 | + | .output() | |
| 111 | + | .await | |
| 112 | + | .context("spawning git merge-base --is-ancestor")?; | |
| 113 | + | Ok(out.status.success()) | |
| 114 | + | } | |
| 115 | + | ||
| 95 | 116 | /// True if `sha` resolves to a commit object in the bare repo. Used to give a | |
| 96 | 117 | /// clear "push first" error instead of a cryptic `git worktree add` failure. | |
| 97 | 118 | pub async fn sha_present(bare: &Path, sha: &str) -> Result<bool> { | |
| @@ -302,6 +323,49 @@ | |||
| 302 | 323 | assert!(!without.join("hooks/post-receive").exists()); | |
| 303 | 324 | } | |
| 304 | 325 | ||
| 326 | + | #[tokio::test] | |
| 327 | + | async fn is_ancestor_seals_the_deploy_branch() { | |
| 328 | + | // The provenance seal `/self-update` checks before it triggers the | |
| 329 | + | // privileged updater: a sha on the deploy branch passes, anything else | |
| 330 | + | // is refused fail-closed. `two_commit_repo` puts both commits on `main`. | |
| 331 | + | let (tmp, gitdir, sha1, sha2) = two_commit_repo().await; | |
| 332 | + | assert!(is_ancestor(&gitdir, &sha1, "main").await.unwrap()); | |
| 333 | + | assert!(is_ancestor(&gitdir, &sha2, "main").await.unwrap()); | |
| 334 | + | ||
| 335 | + | // A commit that exists but is not on the deploy branch. | |
| 336 | + | let repo = tmp.path().join("repo"); | |
| 337 | + | assert!( | |
| 338 | + | git(&repo, &["checkout", "-q", "-b", "side", &sha1]) | |
| 339 | + | .await | |
| 340 | + | .status | |
| 341 | + | .success() | |
| 342 | + | ); | |
| 343 | + | tokio::fs::write(repo.join("c.txt"), b"three") | |
| 344 | + | .await | |
| 345 | + | .unwrap(); | |
| 346 | + | assert!(git(&repo, &["add", "."]).await.status.success()); | |
| 347 | + | assert!( | |
| 348 | + | git(&repo, &["commit", "-q", "-m", "three"]) | |
| 349 | + | .await | |
| 350 | + | .status | |
| 351 | + | .success() | |
| 352 | + | ); | |
| 353 | + | let side = rev_parse(&repo, "HEAD").await; | |
| 354 | + | assert!( | |
| 355 | + | !is_ancestor(&gitdir, &side, "main").await.unwrap(), | |
| 356 | + | "a feature-branch tip is not deployable", | |
| 357 | + | ); | |
| 358 | + | ||
| 359 | + | // An unresolvable ref is "not an ancestor", never an error that a caller | |
| 360 | + | // could mistake for a pass. | |
| 361 | + | assert!( | |
| 362 | + | !is_ancestor(&gitdir, "0123456789abcdef0123456789abcdef01234567", "main") | |
| 363 | + | .await | |
| 364 | + | .unwrap() | |
| 365 | + | ); | |
| 366 | + | assert!(!is_ancestor(&gitdir, &sha1, "no-such-branch").await.unwrap()); | |
| 367 | + | } | |
| 368 | + | ||
| 305 | 369 | #[tokio::test] | |
| 306 | 370 | async fn checkout_worktree_creates_at_sha() { | |
| 307 | 371 | let (tmp, gitdir, sha1, _sha2) = two_commit_repo().await; |
| @@ -504,16 +504,29 @@ | |||
| 504 | 504 | } | |
| 505 | 505 | ||
| 506 | 506 | /// Pointer to the on-disk gate log: a path relative to `cfg.logs_root` | |
| 507 | - | /// of the form `<version>/<gate_kind>.log`. Stored in `gate_runs.log_ref` | |
| 508 | - | /// and surfaced in `/state` so the TUI/operator can request the full | |
| 509 | - | /// tail via `GET /logs/<version>/<gate>` only when needed. | |
| 507 | + | /// of the form `<scope>/<gate_kind>.log`. Stored in `gate_runs.log_ref` and | |
| 508 | + | /// surfaced in `/state` so the TUI/operator can request the full tail via | |
| 509 | + | /// `GET /logs/<scope>/<gate>` only when needed. | |
| 510 | + | /// | |
| 511 | + | /// **`scope` is the `build_runs.id` the gate ran for**, not the version. It was | |
| 512 | + | /// the version until 2026-08-20, and that made every run of a version write the | |
| 513 | + | /// same file: a rebuild at an unchanged version — which is the normal way to | |
| 514 | + | /// retry a red build — appended its output to the previous attempt's log, and | |
| 515 | + | /// every one of those runs' `log_ref`s pointed at the shared file. Reading | |
| 516 | + | /// `/runs/62`'s `cargo_deny.log` returned runs 60 and 61's output, naming a | |
| 517 | + | /// dependency failure that run 62's worktree had already fixed. | |
| 518 | + | /// | |
| 519 | + | /// [`GateCtx::log_ref`](crate::gates::GateCtx::log_ref) is what builds these; | |
| 520 | + | /// it falls back to the version string for a run with no build identity | |
| 521 | + | /// (pre-migration-008 rows and gate unit tests), which is also why logs written | |
| 522 | + | /// under the old scheme stay readable at the path their row already records. | |
| 510 | 523 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 511 | 524 | #[serde(transparent)] | |
| 512 | 525 | pub struct LogRef(pub String); | |
| 513 | 526 | ||
| 514 | 527 | impl LogRef { | |
| 515 | - | pub fn new(version: &Version, gate: GateKind) -> Self { | |
| 516 | - | Self(format!("{}/{}.log", version, gate.as_str())) | |
| 528 | + | pub fn new(scope: &str, gate: GateKind) -> Self { | |
| 529 | + | Self(format!("{}/{}.log", scope, gate.as_str())) | |
| 517 | 530 | } | |
| 518 | 531 | pub fn as_str(&self) -> &str { | |
| 519 | 532 | &self.0 | |
| @@ -575,9 +588,17 @@ | |||
| 575 | 588 | ||
| 576 | 589 | #[test] | |
| 577 | 590 | fn log_ref_construction_matches_disk_layout() { | |
| 591 | + | // A build-scoped ref: two runs of one version get two files. | |
| 592 | + | assert_eq!( | |
| 593 | + | LogRef::new("62", GateKind::CargoTest).as_str(), | |
| 594 | + | "62/cargo_test.log" | |
| 595 | + | ); | |
| 596 | + | // The pre-identity fallback, still addressable at the path old rows record. | |
| 578 | 597 | let v: Version = "0.9.6".parse().unwrap(); | |
| 579 | - | let lr = LogRef::new(&v, GateKind::CargoTest); | |
| 580 | - | assert_eq!(lr.as_str(), "0.9.6/cargo_test.log"); | |
| 598 | + | assert_eq!( | |
| 599 | + | LogRef::new(&v.to_string(), GateKind::CargoTest).as_str(), | |
| 600 | + | "0.9.6/cargo_test.log" | |
| 601 | + | ); | |
| 581 | 602 | } | |
| 582 | 603 | ||
| 583 | 604 | #[test] |
| @@ -292,32 +292,41 @@ | |||
| 292 | 292 | // belongs to. Asking the caller would let a lookup for one product's run | |
| 293 | 293 | // return another's gates. | |
| 294 | 294 | let app: String = row.get("app"); | |
| 295 | - | // Gates are keyed by (app, tier, version); a build run drives the `host` tier. | |
| 296 | - | // Latest row per gate_kind, matching `/state`'s per-tier query shape. | |
| 297 | - | let gates: Vec<RunGateView> = if let Some(ver) = version.as_deref() { | |
| 298 | - | sqlx::query( | |
| 299 | - | "SELECT gate_kind, status, log_ref | |
| 300 | - | FROM gate_runs g | |
| 301 | - | WHERE app = ?1 AND tier = 'host' AND version = ?2 | |
| 302 | - | AND id = (SELECT MAX(id) FROM gate_runs | |
| 303 | - | WHERE app = ?1 AND tier = 'host' AND version = ?2 | |
| 304 | - | AND gate_kind = g.gate_kind) | |
| 305 | - | ORDER BY gate_kind", | |
| 306 | - | ) | |
| 307 | - | .bind(&app) | |
| 308 | - | .bind(ver) | |
| 309 | - | .fetch_all(pool) | |
| 310 | - | .await? | |
| 311 | - | .into_iter() | |
| 312 | - | .map(|gr| RunGateView { | |
| 313 | - | kind: gr.get("gate_kind"), | |
| 314 | - | status: gr.get("status"), | |
| 315 | - | log_ref: gr.get("log_ref"), | |
| 316 | - | }) | |
| 317 | - | .collect() | |
| 318 | - | } else { | |
| 319 | - | Vec::new() | |
| 320 | - | }; | |
| 295 | + | // **This run's gates, keyed on this run.** A build run's id IS the build | |
| 296 | + | // identity every gate it ran recorded (`gate_runs.build_id`), so asking for | |
| 297 | + | // them is an exact question with an exact answer. | |
| 298 | + | // | |
| 299 | + | // It was keyed on (tier, version) until 2026-08-20, and a rebuild at an | |
| 300 | + | // unchanged version is the normal way to retry a red build: runs 60, 61 and | |
| 301 | + | // 62 of mnw-server 0.11.20 wrote over each other's rows, and `/runs/62` | |
| 302 | + | // answered with whichever run had touched each gate last. Two reads a minute | |
| 303 | + | // apart, same run, no promote between them, disagreed about whether | |
| 304 | + | // `hardening_test` had passed or not run at all. | |
| 305 | + | // | |
| 306 | + | // A pre-migration-008 run left rows with a NULL `build_id` and so reports no | |
| 307 | + | // gates here. That is the honest answer for a row that never recorded which | |
| 308 | + | // build it vouched for, and the same call the migration made in refusing to | |
| 309 | + | // backfill one. | |
| 310 | + | let gates: Vec<RunGateView> = sqlx::query( | |
| 311 | + | "SELECT gate_kind, status, log_ref | |
| 312 | + | FROM gate_runs g | |
| 313 | + | WHERE app = ?1 AND build_id = ?2 | |
| 314 | + | AND id = (SELECT MAX(id) FROM gate_runs | |
| 315 | + | WHERE app = ?1 AND build_id = ?2 | |
| 316 | + | AND gate_kind = g.gate_kind) | |
| 317 | + | ORDER BY gate_kind", | |
| 318 | + | ) | |
| 319 | + | .bind(&app) | |
| 320 | + | .bind(run_id.0) | |
| 321 | + | .fetch_all(pool) | |
| 322 | + | .await? | |
| 323 | + | .into_iter() | |
| 324 | + | .map(|gr| RunGateView { | |
| 325 | + | kind: gr.get("gate_kind"), | |
| 326 | + | status: gr.get("status"), | |
| 327 | + | log_ref: gr.get("log_ref"), | |
| 328 | + | }) | |
| 329 | + | .collect(); | |
| 321 | 330 | ||
| 322 | 331 | Ok(Some(RunView { | |
| 323 | 332 | run_id: row.get("id"), | |
| @@ -387,22 +396,31 @@ | |||
| 387 | 396 | (end - start.with_timezone(&Utc)).num_seconds().max(0) | |
| 388 | 397 | } | |
| 389 | 398 | ||
| 390 | - | /// The summary of the first failed gate for `version` on the host tier, if | |
| 391 | - | /// any — used by the build pipeline to populate `failure_summary` when | |
| 392 | - | /// `run_all` reports a red pipeline. Reads the typed `outcome_json` so the | |
| 393 | - | /// stored headline matches what the TUI renders. | |
| 399 | + | /// The summary of the first gate `run_id` failed, if any — used by the build | |
| 400 | + | /// pipeline to populate `failure_summary` when `run_all` reports a red | |
| 401 | + | /// pipeline. Reads the typed `outcome_json` so the stored headline matches what | |
| 402 | + | /// the TUI renders. | |
| 403 | + | /// | |
| 404 | + | /// Keyed on the run, not on (tier, version). Version-keyed, this read the first | |
| 405 | + | /// failure any run of that version had ever recorded, so a rebuild inherited its | |
| 406 | + | /// predecessor's headline: run 61 of mnw-server 0.11.20 settled `failed` with | |
| 407 | + | /// `failure_summary: "code_smoke: migrate+seed run failed: exit 1"` while the | |
| 408 | + | /// same response listed `code_smoke passed`. The `/deploy` runbook says to | |
| 409 | + | /// surface `failure_summary` to the operator, so the documented procedure was | |
| 410 | + | /// pointing at a green gate — it cost a wasted repin of seventeen seed digests | |
| 411 | + | /// that turned out to be correct. | |
| 394 | 412 | pub async fn first_failed_gate_summary( | |
| 395 | 413 | pool: &SqlitePool, | |
| 396 | 414 | app: &AppId, | |
| 397 | - | version: &Version, | |
| 415 | + | run_id: RunId, | |
| 398 | 416 | ) -> Option<String> { | |
| 399 | 417 | let row = sqlx::query( | |
| 400 | 418 | "SELECT gate_kind, outcome_json FROM gate_runs | |
| 401 | - | WHERE app = ? AND tier = 'host' AND version = ? AND status = 'failed' | |
| 419 | + | WHERE app = ? AND build_id = ? AND status = 'failed' | |
| 402 | 420 | ORDER BY id ASC LIMIT 1", | |
| 403 | 421 | ) | |
| 404 | 422 | .bind(app) | |
| 405 | - | .bind(version.to_string()) | |
| 423 | + | .bind(run_id.0) | |
| 406 | 424 | .fetch_optional(pool) | |
| 407 | 425 | .await | |
| 408 | 426 | .ok() |
| @@ -35,7 +35,7 @@ | |||
| 35 | 35 | .route("/status.json", get(get_status_json)) | |
| 36 | 36 | .route("/runs/{id}", get(get_run)) | |
| 37 | 37 | .route("/runs/{id}/wait", get(get_run_wait)) | |
| 38 | - | .route("/logs/{version}/{gate}", get(get_gate_log)) | |
| 38 | + | .route("/logs/{run}/{gate}", get(get_gate_log)) | |
| 39 | 39 | .route("/events", get(events_ws)) | |
| 40 | 40 | .route_layer(axum::middleware::from_fn(move |req, next| { | |
| 41 | 41 | require_bearer(token.clone(), req, next) | |
| @@ -208,7 +208,7 @@ | |||
| 208 | 208 | let rows = sqlx::query( | |
| 209 | 209 | "SELECT t.name, t.ord, t.provisioned, t.canary, | |
| 210 | 210 | ts.current_version, ts.previous_version, ts.burn_in_started_at, | |
| 211 | - | ts.partial_reason | |
| 211 | + | ts.partial_reason, ts.current_build_id | |
| 212 | 212 | FROM tiers t | |
| 213 | 213 | LEFT JOIN tier_state ts ON ts.app = t.app AND ts.tier = t.name | |
| 214 | 214 | WHERE t.app = ? | |
| @@ -222,6 +222,7 @@ | |||
| 222 | 222 | for r in rows { | |
| 223 | 223 | let name: String = r.get("name"); | |
| 224 | 224 | let current_version: Option<String> = r.get("current_version"); | |
| 225 | + | let current_build_id: Option<i64> = r.get("current_build_id"); | |
| 225 | 226 | ||
| 226 | 227 | let nodes: Vec<String> = | |
| 227 | 228 | sqlx::query_scalar("SELECT name FROM nodes WHERE app = ? AND tier = ? ORDER BY name") | |
| @@ -230,15 +231,32 @@ | |||
| 230 | 231 | .fetch_all(&s.pool) | |
| 231 | 232 | .await?; | |
| 232 | 233 | ||
| 233 | - | // Surface gates for current_version when set, otherwise for the most | |
| 234 | - | // recently attempted version on this tier. Without the fallback, a | |
| 235 | - | // tier that has never gone green (MM after a build failure, B before | |
| 236 | - | // first deploy) exposes no gate detail via /state — debugging required | |
| 237 | - | // SSH and direct SQLite access. See sando todo: gate observability. | |
| 238 | - | let gate_version: Option<String> = if current_version.is_some() { | |
| 239 | - | current_version.clone() | |
| 240 | - | } else { | |
| 241 | - | sqlx::query_scalar( | |
| 234 | + | // Which run's gates this tier reports. | |
| 235 | + | // | |
| 236 | + | // The build the tier is actually running, when it has an identity: gate | |
| 237 | + | // rows are evidence about a build, and a version string is not one. This | |
| 238 | + | // was keyed on the version until 2026-08-20, and a rebuild at an | |
| 239 | + | // unchanged version — the normal way to retry a red build — made the | |
| 240 | + | // rows of two runs interleave, each gate showing whichever run wrote it | |
| 241 | + | // last. Statuses appeared to move backwards on a tier nobody had touched | |
| 242 | + | // (`hardening_test passed` then `hardening_test None`, a minute apart), | |
| 243 | + | // because a reader cannot tell a not-yet-run gate from one a sibling run | |
| 244 | + | // has just replaced. | |
| 245 | + | // | |
| 246 | + | // `GateScope::Version` is the fallback, and it covers two live cases: a | |
| 247 | + | // pre-identity tier (NULL `current_build_id`, the same legacy path | |
| 248 | + | // `unsatisfied_gates` takes), and a tier that has never gone green at all | |
| 249 | + | // — MM after a build failure, B before its first deploy — where the most | |
| 250 | + | // recently attempted version is the only handle there is. Without that | |
| 251 | + | // second one, debugging a red tier meant SSH and direct SQLite access. | |
| 252 | + | enum GateScope { | |
| 253 | + | Build(i64), | |
| 254 | + | Version(String), | |
| 255 | + | } | |
| 256 | + | let scope: Option<GateScope> = match (current_build_id, current_version.clone()) { | |
| 257 | + | (Some(bid), _) => Some(GateScope::Build(bid)), | |
| 258 | + | (None, Some(ver)) => Some(GateScope::Version(ver)), | |
| 259 | + | (None, None) => sqlx::query_scalar::<_, String>( | |
| 242 | 260 | "SELECT version FROM gate_runs WHERE app = ? AND tier = ? | |
| 243 | 261 | ORDER BY id DESC LIMIT 1", | |
| 244 | 262 | ) | |
| @@ -246,24 +264,47 @@ | |||
| 246 | 264 | .bind(&name) | |
| 247 | 265 | .fetch_optional(&s.pool) | |
| 248 | 266 | .await? | |
| 267 | + | .map(GateScope::Version), | |
| 249 | 268 | }; | |
| 250 | 269 | ||
| 251 | - | let gates: Vec<GateView> = if let Some(ver) = gate_version.as_ref() { | |
| 252 | - | // Most recent gate_runs row per gate_kind for (tier, ver). | |
| 253 | - | sqlx::query( | |
| 254 | - | "SELECT gate_kind, finished_at, status, outcome_json, log_ref | |
| 255 | - | FROM gate_runs g | |
| 256 | - | WHERE app = ?1 AND tier = ?2 AND version = ?3 | |
| 257 | - | AND id = (SELECT MAX(id) FROM gate_runs | |
| 258 | - | WHERE app = ?1 AND tier = ?2 AND version = ?3 | |
| 259 | - | AND gate_kind = g.gate_kind) | |
| 260 | - | ORDER BY gate_kind", | |
| 261 | - | ) | |
| 262 | - | .bind(&s.cfg.id) | |
| 263 | - | .bind(&name) | |
| 264 | - | .bind(ver) | |
| 265 | - | .fetch_all(&s.pool) | |
| 266 | - | .await? | |
| 270 | + | // Most recent row per gate_kind within the scope. Both arms share the | |
| 271 | + | // shape; only the key differs. | |
| 272 | + | let gate_rows = match &scope { | |
| 273 | + | Some(GateScope::Build(bid)) => { | |
| 274 | + | sqlx::query( | |
| 275 | + | "SELECT gate_kind, finished_at, status, outcome_json, log_ref | |
| 276 | + | FROM gate_runs g | |
| 277 | + | WHERE app = ?1 AND tier = ?2 AND build_id = ?3 | |
| 278 | + | AND id = (SELECT MAX(id) FROM gate_runs | |
| 279 | + | WHERE app = ?1 AND tier = ?2 AND build_id = ?3 | |
| 280 | + | AND gate_kind = g.gate_kind) | |
| 281 | + | ORDER BY gate_kind", | |
| 282 | + | ) | |
| 283 | + | .bind(&s.cfg.id) | |
| 284 | + | .bind(&name) | |
| 285 | + | .bind(bid) | |
| 286 | + | .fetch_all(&s.pool) | |
| 287 | + | .await? | |
| 288 | + | } | |
| 289 | + | Some(GateScope::Version(ver)) => { | |
| 290 | + | sqlx::query( | |
| 291 | + | "SELECT gate_kind, finished_at, status, outcome_json, log_ref | |
| 292 | + | FROM gate_runs g | |
| 293 | + | WHERE app = ?1 AND tier = ?2 AND version = ?3 | |
| 294 | + | AND id = (SELECT MAX(id) FROM gate_runs | |
| 295 | + | WHERE app = ?1 AND tier = ?2 AND version = ?3 | |
| 296 | + | AND gate_kind = g.gate_kind) | |
| 297 | + | ORDER BY gate_kind", | |
| 298 | + | ) | |
| 299 | + | .bind(&s.cfg.id) | |
| 300 | + | .bind(&name) | |
| 301 | + | .bind(ver) | |
| 302 | + | .fetch_all(&s.pool) | |
| 303 | + | .await? | |
| 304 | + | } | |
| 305 | + | None => Vec::new(), | |
| 306 | + | }; | |
| 307 | + | let gates: Vec<GateView> = gate_rows | |
| 267 | 308 | .into_iter() | |
| 268 | 309 | .map(|gr| GateView { | |
| 269 | 310 | kind: gr.get("gate_kind"), | |
| @@ -274,10 +315,7 @@ | |||
| 274 | 315 | .and_then(|s| serde_json::from_str(&s).ok()), | |
| 275 | 316 | log_ref: gr.get("log_ref"), | |
| 276 | 317 | }) | |
| 277 | - | .collect() | |
| 278 | - | } else { | |
| 279 | - | Vec::new() | |
| 280 | - | }; | |
| 318 | + | .collect(); | |
| 281 | 319 | ||
| 282 | 320 | tiers.push(TierView { | |
| 283 | 321 | name, | |
| @@ -793,6 +831,24 @@ | |||
| 793 | 831 | format!("sando-update@{sha}.service") | |
| 794 | 832 | } | |
| 795 | 833 | ||
| 834 | + | /// The bare repo sandod's own source lives in, and the branch that seals it. | |
| 835 | + | /// | |
| 836 | + | /// sandod is built from `sando/daemon` inside the **default product's** repo, so | |
| 837 | + | /// that product's `[repo]` is the one the self-update path has to keep current. | |
| 838 | + | /// Resolved here rather than from `s.topo` because `/self-update` is mounted | |
| 839 | + | /// under every product (`/apps/pom/self-update` reaches the same daemon), and | |
| 840 | + | /// pom's topology is intake-only — it declares no `[repo]` at all. Reading the | |
| 841 | + | /// per-request product would make the fetch below silently a no-op on every | |
| 842 | + | /// mount but one. | |
| 843 | + | fn self_update_source(s: &AppState) -> Option<(std::path::PathBuf, String, Option<String>)> { | |
| 844 | + | let repo = s.apps.get(&s.default_app)?.topo.repo.as_ref()?; | |
| 845 | + | Some(( | |
| 846 | + | std::path::PathBuf::from(&repo.bare_path), | |
| 847 | + | repo.branch.clone(), | |
| 848 | + | repo.upstream.clone(), | |
| 849 | + | )) | |
| 850 | + | } | |
| 851 | + | ||
| 796 | 852 | /// Trigger a rebuild + restart of sandod *itself* to `sha`. sandod runs | |
| 797 | 853 | /// unprivileged (User=sando, NoNewPrivileges, ProtectSystem=strict) and cannot | |
| 798 | 854 | /// write `/usr/local/bin/sandod` or restart its own service — so it only | |
| @@ -801,6 +857,16 @@ | |||
| 801 | 857 | /// `sando/daemon` as the sando user in a dedicated checkout, installs the new | |
| 802 | 858 | /// binary, and restarts sandod. Bearer-gated like the other mutators; the new | |
| 803 | 859 | /// version shows up in `/state`'s `sandod_version` once the restart lands. | |
| 860 | + | /// | |
| 861 | + | /// **The upstream fetch below is what makes a fresh sha reachable at all.** The | |
| 862 | + | /// updater builds from the local bare repo and refuses any sha that is not an | |
| 863 | + | /// ancestor of its `main` (see `deploy/sando-self-update.sh`, "Provenance"). That | |
| 864 | + | /// bare repo used to advance only when `/rebuild` fetched upstream during a | |
| 865 | + | /// *server* build, which chained the controller's currency to the server's | |
| 866 | + | /// release cadence and meant an urgent controller fix could not ship while the | |
| 867 | + | /// server was red. Fetching here decouples the two: the rule stays exactly as | |
| 868 | + | /// strict, it is simply checked against the canonical remote rather than against | |
| 869 | + | /// a local cache of it that only a server build refreshed. | |
| 804 | 870 | async fn self_update( | |
| 805 | 871 | State(s): State<AppState>, | |
| 806 | 872 | crate::error::TypedBody(body): crate::error::TypedBody<SelfUpdateBody>, | |
| @@ -810,6 +876,40 @@ | |||
| 810 | 876 | ||
| 811 | 877 | let unit = self_update_unit(&sha); | |
| 812 | 878 | ||
| 879 | + | // Refresh the bare repo from the canonical remote, then check the sha against | |
| 880 | + | // it here — before the trigger, so a bad sha is a synchronous 4xx rather than | |
| 881 | + | // an `accepted: true` and an exit 4 buried in the updater's journal. | |
| 882 | + | // | |
| 883 | + | // Only sandod writes this repo, and only by fetching the deploy branch from | |
| 884 | + | // the authenticated upstream, which is the whole basis of the updater's | |
| 885 | + | // provenance seal. Doing the fetch here keeps that true: no new writer. | |
| 886 | + | if let Some((bare, branch, upstream)) = self_update_source(&s) { | |
| 887 | + | // A fetch failure is non-fatal, matching the build path: the sha may | |
| 888 | + | // already be present from an earlier fetch, and the ancestry check below | |
| 889 | + | // is the real gate. What must not happen is proceeding *silently*. | |
| 890 | + | if let Some(upstream) = upstream.as_deref() | |
| 891 | + | && let Err(e) = crate::git::fetch_upstream(&bare, upstream, &branch).await | |
| 892 | + | { | |
| 893 | + | tracing::warn!(error = %e, upstream, "self-update upstream fetch failed; checking the sha against the current bare-repo state"); | |
| 894 | + | } | |
| 895 | + | match crate::git::is_ancestor(&bare, sha.as_str(), &branch).await { | |
| 896 | + | Ok(true) => {} | |
| 897 | + | Ok(false) => { | |
| 898 | + | return Err(crate::error::Error::BadRequest(format!( | |
| 899 | + | "sha {sha} is not an ancestor of {branch} in {} — push it to the upstream remote first (the updater refuses any sha off the deploy branch)", | |
| 900 | + | bare.display(), | |
| 901 | + | ))); | |
| 902 | + | } | |
| 903 | + | // Never block the update on our own pre-check being unable to answer: | |
| 904 | + | // the updater re-runs the identical check as root before it builds, so | |
| 905 | + | // the seal holds regardless. Losing the good error message is the only | |
| 906 | + | // cost of passing through here. | |
| 907 | + | Err(e) => { | |
| 908 | + | tracing::warn!(error = %e, "self-update ancestry pre-check could not run; deferring to the updater's own check"); | |
| 909 | + | } | |
| 910 | + | } | |
| 911 | + | } | |
| 912 | + | ||
| 813 | 913 | // Don't restart the controller out from under an in-flight server build — the | |
| 814 | 914 | // restart would SIGKILL it mid-deploy. Hold the active_build slot across the | |
| 815 | 915 | // trigger, not just the check: releasing it before triggering let a /rebuild | |
| @@ -873,14 +973,19 @@ | |||
| 873 | 973 | .find(|t| t.name == tier) | |
| 874 | 974 | .ok_or(crate::error::Error::NotFound)?; | |
| 875 | 975 | ||
| 876 | - | let version_str: Option<String> = | |
| 877 | - | sqlx::query_scalar("SELECT current_version FROM tier_state WHERE app = ? AND tier = ?") | |
| 878 | - | .bind(&s.cfg.id) | |
| 879 | - | .bind(&target.name) | |
| 880 | - | .fetch_optional(&s.pool) | |
| 881 | - | .await | |
| 882 | - | .map_err(crate::error::Error::Db)? | |
| 883 | - | .flatten(); | |
| 976 | + | // The build id comes back with the version so the row this writes carries the | |
| 977 | + | // identity every other gate row on this tier carries. Without it a | |
| 978 | + | // `manual_confirm` was the one gate `/state` could not scope to a build, and | |
| 979 | + | // it read as "not run" on a tier reporting per build. | |
| 980 | + | let confirmed: Option<(Option<String>, Option<i64>)> = sqlx::query_as( | |
| 981 | + | "SELECT current_version, current_build_id FROM tier_state WHERE app = ? AND tier = ?", | |
| 982 | + | ) | |
| 983 | + | .bind(&s.cfg.id) | |
| 984 | + | .bind(&target.name) | |
| 985 | + | .fetch_optional(&s.pool) | |
| 986 | + | .await | |
| 987 | + | .map_err(crate::error::Error::Db)?; | |
| 988 | + | let (version_str, build_id) = confirmed.unwrap_or((None, None)); | |
| 884 | 989 | let version_str = version_str.ok_or_else(|| { | |
| 885 | 990 | crate::error::Error::GateBlocked(format!( | |
| 886 | 991 | "tier {tier} has no current_version; nothing to confirm" | |
| @@ -897,11 +1002,12 @@ | |||
| 897 | 1002 | let outcome_json = serde_json::to_string(&outcome) | |
| 898 | 1003 | .map_err(|e| crate::error::Error::Other(anyhow::anyhow!(e)))?; | |
| 899 | 1004 | sqlx::query( | |
| 900 | - | "INSERT INTO gate_runs (app, version, tier, gate_kind, started_at, finished_at, status, outcome_json) | |
| 901 | - | VALUES (?, ?, ?, 'manual_confirm', ?, ?, 'passed', ?)", | |
| 1005 | + | "INSERT INTO gate_runs (app, version, tier, gate_kind, started_at, finished_at, status, outcome_json, build_id) | |
| 1006 | + | VALUES (?, ?, ?, 'manual_confirm', ?, ?, 'passed', ?, ?)", | |
| 902 | 1007 | ) | |
| 903 | 1008 | .bind(&s.cfg.id) | |
| 904 | 1009 | .bind(&version).bind(&target.name).bind(&now).bind(&now).bind(&outcome_json) | |
| 1010 | + | .bind(build_id) | |
| 905 | 1011 | .execute(&s.pool).await.map_err(crate::error::Error::Db)?; | |
| 906 | 1012 | ||
| 907 | 1013 | tracing::info!(tier = %tier, version = %version, "manual_confirm recorded"); | |
| @@ -968,28 +1074,47 @@ | |||
| 968 | 1074 | }))) | |
| 969 | 1075 | } | |
| 970 | 1076 | ||
| 1077 | + | /// The tail of one gate's log. | |
| 1078 | + | /// | |
| 1079 | + | /// `run` is the `build_runs.id` the gate ran for — the directory | |
| 1080 | + | /// [`GateCtx::log_scope`](crate::gates::GateCtx::log_scope) wrote under. It was | |
| 1081 | + | /// the version string until 2026-08-20, when every run of a version shared one | |
| 1082 | + | /// file and a reader of a rebuild got the previous attempt's output. Callers | |
| 1083 | + | /// follow the `log_ref` on the gate row rather than building this path, so both | |
| 1084 | + | /// scopes resolve and logs written under the old scheme stay readable at the | |
| 1085 | + | /// path their row records. | |
| 971 | 1086 | async fn get_gate_log( | |
| 972 | 1087 | State(s): State<AppState>, | |
| 973 | - | Path((version, gate)): Path<(String, String)>, | |
| 1088 | + | Path((run, gate)): Path<(String, String)>, | |
| 974 | 1089 | ) -> Result<axum::response::Response> { | |
| 975 | - | // Guard against `..` / absolute paths — the version segment must be a single | |
| 1090 | + | // Guard against `..` / absolute paths — the scope segment must be a single | |
| 976 | 1091 | // safe component. Without this, `GET /logs/..%2Fetc/passwd` would escape | |
| 977 | 1092 | // logs_root. | |
| 978 | 1093 | fn safe(seg: &str) -> bool { | |
| 979 | 1094 | !seg.is_empty() && !seg.contains('/') && !seg.contains('\\') && seg != "." && seg != ".." | |
| 980 | 1095 | } | |
| 981 | - | if !safe(&version) { | |
| 1096 | + | if !safe(&run) { | |
| 982 | 1097 | return Err(crate::error::Error::NotFound); | |
| 983 | 1098 | } | |
| 984 | 1099 | // The gate segment is an allowlisted kind, not a free-form filename: parse it | |
| 985 | - | // to `GateKind` so only the five known `<kind>.log` files are reachable. This | |
| 986 | - | // closes `*.log` filename probing within a version dir (traversal was already | |
| 1100 | + | // to `GateKind` so only the known `<kind>.log` files are reachable. This | |
| 1101 | + | // closes `*.log` filename probing within a scope dir (traversal was already | |
| 987 | 1102 | // blocked; this bounds the basename to the known set). | |
| 988 | - | let kind: crate::domain::GateKind = gate.parse().map_err(|_| crate::error::Error::NotFound)?; | |
| 1103 | + | // | |
| 1104 | + | // A trailing `.log` is accepted so a caller can append a gate row's `log_ref` | |
| 1105 | + | // to `/logs/` verbatim rather than taking it apart and putting it back | |
| 1106 | + | // together. Following the ref is the correct way to reach a log — building | |
| 1107 | + | // the path from the version reaches a different run's output on any version | |
| 1108 | + | // that was rebuilt — so the shape that gets it right should be the easy one. | |
| 1109 | + | let kind: crate::domain::GateKind = gate | |
| 1110 | + | .strip_suffix(".log") | |
| 1111 | + | .unwrap_or(&gate) | |
| 1112 | + | .parse() | |
| 1113 | + | .map_err(|_| crate::error::Error::NotFound)?; | |
| 989 | 1114 | let path = s | |
| 990 | 1115 | .cfg | |
| 991 | 1116 | .logs_root | |
| 992 | - | .join(&version) | |
| 1117 | + | .join(&run) | |
| 993 | 1118 | .join(format!("{}.log", kind.as_str())); | |
| 994 | 1119 | // Bound how much a single request can pull into the daemon's memory: a | |
| 995 | 1120 | // runaway gate log shouldn't be read whole. Past the cap we return the tail | |
| @@ -1083,7 +1208,7 @@ | |||
| 1083 | 1208 | #[cfg(test)] | |
| 1084 | 1209 | mod tests { | |
| 1085 | 1210 | use super::promotion::{ | |
| 1086 | - | PromotedBuild, RollbackReport, rollback_deployed_nodes, unsatisfied_gates, | |
| 1211 | + | Evidence, PromotedBuild, RollbackReport, rollback_deployed_nodes, unsatisfied_gates, | |
| 1087 | 1212 | }; | |
| 1088 | 1213 | use super::*; | |
| 1089 | 1214 | use crate::config::AppConfig; | |
| @@ -1380,6 +1505,57 @@ | |||
| 1380 | 1505 | .unwrap(); | |
| 1381 | 1506 | } | |
| 1382 | 1507 | ||
| 1508 | + | /// A rebuild at an unchanged version must not move what a tier reports. | |
| 1509 | + | /// | |
| 1510 | + | /// `/state` keyed its gate rows on (tier, version), so two runs of one | |
| 1511 | + | /// version interleaved and each gate showed whichever had written it last. | |
| 1512 | + | /// The visible symptom was statuses that moved while nothing was touched: | |
| 1513 | + | /// two reads of the same tier a minute apart disagreed about whether | |
| 1514 | + | /// `hardening_test` had passed or never run. Runs 60-62 of mnw-server | |
| 1515 | + | /// 0.11.20, 2026-08-19. | |
| 1516 | + | #[tokio::test] | |
| 1517 | + | async fn state_reports_the_build_the_tier_is_running() { | |
| 1518 | + | let state = test_state().await; | |
| 1519 | + | seed(&state.pool, "a", "0.11.20").await; | |
| 1520 | + | let first = seed_build(&state.pool, "2a53c900", "0.11.20", "/rel/1111111111111111").await; | |
| 1521 | + | let second = seed_build(&state.pool, "adf56cd9", "0.11.20", "/rel/2222222222222222").await; | |
| 1522 | + | // The tier is running the first build, and it went green there. | |
| 1523 | + | insert_gate_build(&state.pool, "a", "0.11.20", "hardening_test", 1, first).await; | |
| 1524 | + | sqlx::query( | |
| 1525 | + | "UPDATE tier_state SET current_version = ?, current_build_id = ? WHERE tier = 'a'", | |
| 1526 | + | ) | |
| 1527 | + | .bind("0.11.20") | |
| 1528 | + | .bind(first) | |
| 1529 | + | .execute(&state.pool) | |
| 1530 | + | .await | |
| 1531 | + | .unwrap(); | |
| 1532 | + | ||
| 1533 | + | let before = state_view(&state).await.unwrap(); | |
| 1534 | + | let gates_of = |v: &StateView| { | |
| 1535 | + | v.tiers | |
| 1536 | + | .iter() | |
| 1537 | + | .find(|t| t.name == "a") | |
| 1538 | + | .unwrap() | |
| 1539 | + | .gates | |
| 1540 | + | .iter() | |
| 1541 | + | .map(|g| (g.kind.clone(), g.status.clone())) | |
| 1542 | + | .collect::<Vec<_>>() | |
| 1543 | + | }; | |
| 1544 | + | assert_eq!( | |
| 1545 | + | gates_of(&before), | |
| 1546 | + | vec![("hardening_test".to_string(), Some("passed".to_string()))], | |
| 1547 | + | ); | |
| 1548 | + | ||
| 1549 | + | // A retry of the same version fails the same gate. The tier still runs | |
| 1550 | + | // the first build, so what it reports is unchanged. | |
| 1551 | + | insert_gate_build(&state.pool, "a", "0.11.20", "hardening_test", 0, second).await; | |
| 1552 | + | assert_eq!( | |
| 1553 | + | gates_of(&state_view(&state).await.unwrap()), | |
| 1554 | + | gates_of(&before), | |
| 1555 | + | "a sibling rebuild rewrote a tier it was never deployed to", | |
| 1556 | + | ); | |
| 1557 | + | } | |
| 1558 | + | ||
| 1383 | 1559 | // ---- unsatisfied_gates ---- | |
| 1384 | 1560 | ||
| 1385 | 1561 | fn tid(s: &str) -> crate::domain::TierId { | |
| @@ -1396,8 +1572,11 @@ | |||
| 1396 | 1572 | &crate::domain::AppId::default(), | |
| 1397 | 1573 | &tid("host"), | |
| 1398 | 1574 | &[], | |
| 1399 | - | "0.8.12", | |
| 1400 | - | &[], | |
| 1575 | + | &Evidence { | |
| 1576 | + | version: "0.8.12", | |
| 1577 | + | builds: &[], | |
| 1578 | + | tier_build: None, | |
| 1579 | + | }, | |
| 1401 | 1580 | false, | |
| 1402 | 1581 | ) | |
| 1403 | 1582 | .await | |
| @@ -1418,8 +1597,11 @@ | |||
| 1418 | 1597 | &crate::domain::AppId::default(), | |
| 1419 | 1598 | &tid("a"), | |
| 1420 | 1599 | &[Gate::BootSmoke], | |
| 1421 | - | "0.8.12", | |
| 1422 | - | &[], | |
| 1600 | + | &Evidence { | |
| 1601 | + | version: "0.8.12", | |
| 1602 | + | builds: &[], | |
| 1603 | + | tier_build: None, | |
| 1604 | + | }, | |
| 1423 | 1605 | false, | |
| 1424 | 1606 | ) | |
| 1425 | 1607 | .await | |
| @@ -1438,8 +1620,11 @@ | |||
| 1438 | 1620 | &crate::domain::AppId::default(), | |
| 1439 | 1621 | &tid("host"), | |
| 1440 | 1622 | &[Gate::CargoTest, Gate::BootSmoke], | |
| 1441 | - | "0.8.12", | |
| 1442 | - | &[], | |
| 1623 | + | &Evidence { | |
| 1624 | + | version: "0.8.12", | |
| 1625 | + | builds: &[], | |
| 1626 | + | tier_build: None, | |
| 1627 | + | }, | |
| 1443 | 1628 | false, | |
| 1444 | 1629 | ) | |
| 1445 | 1630 | .await | |
| @@ -1460,8 +1645,11 @@ | |||
| 1460 | 1645 | &crate::domain::AppId::default(), | |
| 1461 | 1646 | &tid("host"), | |
| 1462 | 1647 | &[Gate::CargoTest], | |
| 1463 | - | "0.8.12", | |
| 1464 | - | &[], | |
| 1648 | + | &Evidence { | |
| 1649 | + | version: "0.8.12", | |
| 1650 | + | builds: &[], | |
| 1651 | + | tier_build: None, | |
| 1652 | + | }, | |
| 1465 | 1653 | false, | |
| 1466 | 1654 | ) | |
| 1467 | 1655 | .await | |
| @@ -1509,8 +1697,11 @@ | |||
| 1509 | 1697 | &crate::domain::AppId::default(), | |
| 1510 | 1698 | &tid("a"), | |
| 1511 | 1699 | &[Gate::ManualConfirm], | |
| 1512 | - | "0.8.12", | |
| 1513 | - | &[], | |
| 1700 | + | &Evidence { | |
| 1701 | + | version: "0.8.12", | |
| 1702 | + | builds: &[], | |
| 1703 | + | tier_build: None, | |
| 1704 | + | }, | |
| 1514 | 1705 | false, | |
| 1515 | 1706 | ) | |
| 1516 | 1707 | .await | |
| @@ -1528,8 +1719,11 @@ | |||
| 1528 | 1719 | &crate::domain::AppId::default(), | |
| 1529 | 1720 | &tid("a"), | |
| 1530 | 1721 | &[Gate::ManualConfirm], | |
| 1531 | - | "0.8.12", | |
| 1532 | - | &[], | |
| 1722 | + | &Evidence { | |
| 1723 | + | version: "0.8.12", | |
| 1724 | + | builds: &[], | |
| 1725 | + | tier_build: None, | |
| 1726 | + | }, | |
| 1533 | 1727 | false, | |
| 1534 | 1728 | ) | |
| 1535 | 1729 | .await | |
| @@ -1549,8 +1743,11 @@ | |||
| 1549 | 1743 | &crate::domain::AppId::default(), | |
| 1550 | 1744 | &tid("a"), | |
| 1551 | 1745 | &[Gate::ManualConfirm], | |
| 1552 | - | "0.8.12", | |
| 1553 | - | &[], | |
| 1746 | + | &Evidence { | |
| 1747 | + | version: "0.8.12", | |
| 1748 | + | builds: &[], | |
| 1749 | + | tier_build: None, | |
| 1750 | + | }, | |
| 1554 | 1751 | false, | |
| 1555 | 1752 | ) | |
| 1556 | 1753 | .await | |
| @@ -1578,8 +1775,11 @@ | |||
| 1578 | 1775 | &crate::domain::AppId::default(), | |
| 1579 | 1776 | &tid("a"), | |
| 1580 | 1777 | &gates, | |
| 1581 | - | "0.8.12", | |
| 1582 | - | &[], | |
| 1778 | + | &Evidence { | |
| 1779 | + | version: "0.8.12", | |
| 1780 | + | builds: &[], | |
| 1781 | + | tier_build: None, | |
| 1782 | + | }, | |
| 1583 | 1783 | false, | |
| 1584 | 1784 | ) | |
| 1585 | 1785 | .await | |
| @@ -1594,8 +1794,11 @@ | |||
| 1594 | 1794 | &crate::domain::AppId::default(), | |
| 1595 | 1795 | &tid("a"), | |
| 1596 | 1796 | &gates, | |
| 1597 | - | "0.8.12", | |
| 1598 | - | &[], | |
| 1797 | + | &Evidence { | |
| 1798 | + | version: "0.8.12", | |
| 1799 | + | builds: &[], | |
| 1800 | + | tier_build: None, | |
| 1801 | + | }, | |
| 1599 | 1802 | true, | |
| 1600 | 1803 | ) | |
| 1601 | 1804 | .await | |
| @@ -1619,8 +1822,11 @@ | |||
| 1619 | 1822 | &crate::domain::AppId::default(), | |
| 1620 | 1823 | &tid("a"), | |
| 1621 | 1824 | &[Gate::BurnIn { hours: 48 }], | |
| 1622 | - | "0.8.12", | |
| 1623 | - | &[], |
Lines truncated
| @@ -188,13 +188,35 @@ | |||
| 188 | 188 | }); | |
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | + | // The build the TARGET tier ends up running. The same as the source's for a | |
| 192 | + | // single-platform product, and its cross-architecture sibling when the two | |
| 193 | + | // tiers differ — promoting pom astra -> hetzner reads an aarch64 build from | |
| 194 | + | // the source and ships the x86_64 one. Every row this promote writes about | |
| 195 | + | // the target (its `deploys`, its post-deploy gate runs, its `tier_state`) | |
| 196 | + | // keys on this, so the next promote out of that tier resolves through | |
| 197 | + | // evidence for bytes that box actually ran. | |
| 198 | + | // | |
| 199 | + | // A tier whose nodes span platforms has no single current build, and NULL | |
| 200 | + | // says so rather than naming one of them arbitrarily. No such tier exists | |
| 201 | + | // today: pom's astra and hetzner tiers are one node each. | |
| 202 | + | let target_build_id = match promoted_builds.as_slice() { | |
| 203 | + | [only] => only.build_id, | |
| 204 | + | _ => None, | |
| 205 | + | }; | |
| 206 | + | ||
| 191 | 207 | let pending = unsatisfied_gates( | |
| 192 | 208 | &s.pool, | |
| 193 | 209 | &s.cfg.id, | |
| 194 | 210 | &source.name, | |
| 195 | 211 | &effective_gates, | |
| 196 | - | &version_str, | |
| 197 | - | &promoted_builds, | |
| 212 | + | &Evidence { | |
| 213 | + | version: &version_str, | |
| 214 | + | builds: &promoted_builds, | |
| 215 | + | // The source tier's own build: what its node_health / page_smoke rows | |
| 216 | + | // can possibly be about, which is not the same thing as what this | |
| 217 | + | // promote ships when the two tiers are different architectures. | |
| 218 | + | tier_build: build_id, | |
| 219 | + | }, | |
| 198 | 220 | body.hotfix, | |
| 199 | 221 | ) | |
| 200 | 222 | .await?; | |
| @@ -223,9 +245,7 @@ | |||
| 223 | 245 | // sequential path. Track the nodes already flipped to the new version so | |
| 224 | 246 | // a mid-rollout failure can roll them back (canary rollback). | |
| 225 | 247 | let mut deployed: Vec<&crate::topology::Node> = Vec::new(); | |
| 226 | - | // The build id is not read here: it did its work above, keying the gate | |
| 227 | - | // check to each bundle's own evidence. What ships is decided by `Placement`. | |
| 228 | - | for (node, node_bundle, node_bundle_platform, _) in &bundles { | |
| 248 | + | for (node, node_bundle, node_bundle_platform, node_build_id) in &bundles { | |
| 229 | 249 | // The proof that these bytes belong on this box. Built before the | |
| 230 | 250 | // deploy row is written, so a mismatch never becomes an `in_progress` | |
| 231 | 251 | // deploy that has to be reconciled. | |
| @@ -262,7 +282,12 @@ | |||
| 262 | 282 | .bind(&version).bind(&target.name).bind(&node.name) | |
| 263 | 283 | .bind(&started) | |
| 264 | 284 | .bind(body.hotfix as i64).bind(body.reset_burn_in as i64) | |
| 265 | - | .bind(build_id) | |
| 285 | + | // The build THIS node received, which is not the source tier's when the | |
| 286 | + | // two are different architectures: promoting pom astra -> hetzner reads | |
| 287 | + | // an aarch64 build from the source and ships the x86_64 sibling. Binding | |
| 288 | + | // the source's id here recorded the wrong bytes against the deploy, and | |
| 289 | + | // `crate::reconcile` reads these rows. | |
| 290 | + | .bind(node_build_id) | |
| 266 | 291 | .fetch_one(&s.pool).await.map_err(crate::error::Error::Db)?; | |
| 267 | 292 | let result = crate::deploy::deploy_node( | |
| 268 | 293 | executor.as_ref(), | |
| @@ -468,8 +493,9 @@ | |||
| 468 | 493 | nodes, | |
| 469 | 494 | // These post-deploy gate rows are the evidence the NEXT promote (this | |
| 470 | 495 | // tier -> the following one) resolves through, so they must carry the | |
| 471 | - | // build they vouch for. | |
| 472 | - | build_id, | |
| 496 | + | // build they vouch for — the one this tier just received, which is | |
| 497 | + | // not the source's across an architecture boundary. | |
| 498 | + | build_id: target_build_id, | |
| 473 | 499 | // The hostname the public uses, straight from the tier. page_smoke | |
| 474 | 500 | // has to request the site the way a visitor does; anything derived | |
| 475 | 501 | // from a node would reach the origin and miss the CDN, which is the | |
| @@ -514,9 +540,18 @@ | |||
| 514 | 540 | // serialized against rollback and the host build path's advance. | |
| 515 | 541 | // reset_burn_in on the *source* tier nulls its clock only when the operator | |
| 516 | 542 | // explicitly asked. | |
| 517 | - | crate::runs::advance_tier(&s.pool, &s.cfg.id, target.name.as_str(), &version, build_id) | |
| 518 | - | .await | |
| 519 | - | .map_err(crate::error::Error::Db)?; | |
| 543 | + | // `target_build_id`, not the source's: recording the source's would have | |
| 544 | + | // left hetzner's `current_build_id` pointing at astra's aarch64 bundle, | |
| 545 | + | // i.e. at bytes that box cannot execute. | |
| 546 | + | crate::runs::advance_tier( | |
| 547 | + | &s.pool, | |
| 548 | + | &s.cfg.id, | |
| 549 | + | target.name.as_str(), | |
| 550 | + | &version, | |
| 551 | + | target_build_id, | |
| 552 | + | ) | |
| 553 | + | .await | |
| 554 | + | .map_err(crate::error::Error::Db)?; | |
| 520 | 555 | ||
| 521 | 556 | if body.reset_burn_in { | |
| 522 | 557 | sqlx::query("UPDATE tier_state SET burn_in_started_at = NULL WHERE app = ? AND tier = ?") | |
| @@ -625,6 +660,21 @@ | |||
| 625 | 660 | pub build_id: Option<i64>, | |
| 626 | 661 | } | |
| 627 | 662 | ||
| 663 | + | /// What a promote is judged against, in one value: the release it names, the | |
| 664 | + | /// artifacts it will ship, and the build the tier it is leaving is running. | |
| 665 | + | /// | |
| 666 | + | /// The three travel together because the gate check needs all three at once and | |
| 667 | + | /// they answer different questions. `version` is the label and the pre-identity | |
| 668 | + | /// fallback key. `builds` are the bytes going out, which artifact evidence is | |
| 669 | + | /// keyed on. `tier_build` is what the source tier is itself running, which | |
| 670 | + | /// post-deploy evidence is keyed on — and across an architecture boundary those | |
| 671 | + | /// last two are different builds. See [`unsatisfied_gates`]. | |
| 672 | + | pub(super) struct Evidence<'a> { | |
| 673 | + | pub version: &'a str, | |
| 674 | + | pub builds: &'a [PromotedBuild], | |
| 675 | + | pub tier_build: Option<i64>, | |
| 676 | + | } | |
| 677 | + | ||
| 628 | 678 | /// Which bytes each node gets, resolved before any node is touched. | |
| 629 | 679 | /// | |
| 630 | 680 | /// A single-platform product answers with `fallback` for every node, which is | |
| @@ -949,16 +999,38 @@ | |||
| 949 | 999 | /// on the tier's own clock (`tier_state.burn_in_started_at`), not on a build, so | |
| 950 | 1000 | /// asking per build would ask the same question N times and answer it N times in | |
| 951 | 1001 | /// the error. | |
| 1002 | + | /// | |
| 1003 | + | /// **So are `node_health` and `page_smoke`, and for a sharper reason: they are | |
| 1004 | + | /// evidence about this tier, not about the bytes leaving it.** `tier_build` is | |
| 1005 | + | /// the build this tier is itself running, and it is the only build those two can | |
| 1006 | + | /// ever have a row for. Asking them per shipped build is a category error that | |
| 1007 | + | /// made a cross-architecture promote structurally impossible: promoting pom | |
| 1008 | + | /// 0.4.3 astra -> hetzner ships the **x86_64** bundle, astra has only ever run | |
| 1009 | + | /// the **aarch64** one, and so `node_health` was looked up against a build astra | |
| 1010 | + | /// never saw and never could — astra is aarch64. Fail-closed reported it | |
| 1011 | + | /// unsatisfied, `/state` reported it passed (astra's own row, correctly), and the | |
| 1012 | + | /// promote refused twice with the gate satisfied by every reading the operator | |
| 1013 | + | /// had. It could not have been cleared by re-running anything. | |
| 1014 | + | /// | |
| 1015 | + | /// The artifact gates below stay per build and must: `cargo_test` on the aarch64 | |
| 1016 | + | /// bundle says nothing about the x86_64 one, and each stands on its own intake. | |
| 1017 | + | /// What separates the two lists is whether the evidence travels with the bytes. | |
| 1018 | + | /// A single-platform product is unaffected either way — its source tier's build | |
| 1019 | + | /// *is* the build being shipped, so both readings ask the same question. | |
| 952 | 1020 | pub(super) async fn unsatisfied_gates( | |
| 953 | 1021 | pool: &sqlx::SqlitePool, | |
| 954 | 1022 | app: &crate::domain::AppId, | |
| 955 | 1023 | tier: &crate::domain::TierId, | |
| 956 | 1024 | gates: &[crate::topology::Gate], | |
| 957 | - | version: &str, | |
| 958 | - | builds: &[PromotedBuild], | |
| 1025 | + | evidence: &Evidence<'_>, | |
| 959 | 1026 | hotfix: bool, | |
| 960 | 1027 | ) -> std::result::Result<Vec<String>, crate::error::Error> { | |
| 961 | 1028 | use crate::topology::Gate; | |
| 1029 | + | let Evidence { | |
| 1030 | + | version, | |
| 1031 | + | builds, | |
| 1032 | + | tier_build, | |
| 1033 | + | } = *evidence; | |
| 962 | 1034 | let mut bad = Vec::new(); | |
| 963 | 1035 | for gate in gates { | |
| 964 | 1036 | let kind = gate.kind(); | |
| @@ -1020,12 +1092,47 @@ | |||
| 1020 | 1092 | bad.push(kind.as_str().to_string()); | |
| 1021 | 1093 | } | |
| 1022 | 1094 | } | |
| 1023 | - | // Build/post-deploy gates that leave a `gate_runs` row: the latest | |
| 1024 | - | // row for this (tier, version, kind) must be `passed`. Listed | |
| 1025 | - | // explicitly (no `_` catch-all) so adding a new `Gate` variant is a | |
| 1026 | - | // compile error here until its promotion semantics are decided — | |
| 1027 | - | // a transient-`blocked` kind silently falling into "needs a passed | |
| 1028 | - | // row" would be permanently unsatisfiable. | |
| 1095 | + | // Post-deploy tier gates: evidence that THIS tier is healthy running | |
| 1096 | + | // what it is running. Keyed on the tier's own build, once, because | |
| 1097 | + | // that is the only build a row here can ever exist for — see the note | |
| 1098 | + | // on `tier_build` above. `None` is the pre-identity tier, where the | |
| 1099 | + | // version-keyed lookup is the legacy behaviour. | |
| 1100 | + | Gate::NodeHealth | Gate::PageSmoke => { | |
| 1101 | + | let status: Option<String> = match tier_build { | |
| 1102 | + | Some(bid) => sqlx::query_scalar( | |
| 1103 | + | "SELECT status FROM gate_runs | |
| 1104 | + | WHERE app = ?1 AND tier = ?2 AND build_id = ?3 AND gate_kind = ?4 | |
| 1105 | + | ORDER BY id DESC LIMIT 1", | |
| 1106 | + | ) | |
| 1107 | + | .bind(app) | |
| 1108 | + | .bind(tier.as_str()) | |
| 1109 | + | .bind(bid) | |
| 1110 | + | .bind(kind.as_str()), | |
| 1111 | + | None => sqlx::query_scalar( | |
| 1112 | + | "SELECT status FROM gate_runs | |
| 1113 | + | WHERE app = ?1 AND tier = ?2 AND version = ?3 AND gate_kind = ?4 | |
| 1114 | + | ORDER BY id DESC LIMIT 1", | |
| 1115 | + | ) | |
| 1116 | + | .bind(app) | |
| 1117 | + | .bind(tier.as_str()) | |
| 1118 | + | .bind(version) | |
| 1119 | + | .bind(kind.as_str()), | |
| 1120 | + | } | |
| 1121 | + | .fetch_optional(pool) | |
| 1122 | + | .await | |
| 1123 | + | .map_err(crate::error::Error::Db)? | |
| 1124 | + | .flatten(); | |
| 1125 | + | if status.as_deref() != Some("passed") { | |
| 1126 | + | bad.push(kind.as_str().to_string()); | |
| 1127 | + | } | |
| 1128 | + | } | |
| 1129 | + | // Artifact gates: evidence about the bytes, which travels with them. | |
| 1130 | + | // The latest row for this (build, kind) must be `passed`, per build | |
| 1131 | + | // this promote ships. Listed explicitly (no `_` catch-all) so adding | |
| 1132 | + | // a new `Gate` variant is a compile error here until its promotion | |
| 1133 | + | // semantics are decided — a transient-`blocked` kind silently falling | |
| 1134 | + | // into "needs a passed row" would be permanently unsatisfiable, and | |
| 1135 | + | // a post-deploy kind landing here is the defect the arm above fixes. | |
| 1029 | 1136 | Gate::CargoTest | |
| 1030 | 1137 | | Gate::HardeningTest | |
| 1031 | 1138 | | Gate::Clippy | |
| @@ -1034,11 +1141,7 @@ | |||
| 1034 | 1141 | | Gate::CargoDeny | |
| 1035 | 1142 | | Gate::MigrationDryRun | |
| 1036 | 1143 | | Gate::CodeSmoke | |
| 1037 | - | | Gate::BootSmoke | |
| 1038 | - | | Gate::NodeHealth | |
| 1039 | - | // Same evidence rule as node_health: a passed row for this tier's | |
| 1040 | - | // current build, or the promote out of here is refused. | |
| 1041 | - | | Gate::PageSmoke => { | |
| 1144 | + | | Gate::BootSmoke => { | |
| 1042 | 1145 | // Latest row for this configured gate kind; NULL/missing/any | |
| 1043 | 1146 | // non-'passed' status all count as unsatisfied (fail closed). | |
| 1044 | 1147 | // Every build this promote ships has to show its own passed row. |