max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+336 insertions,
-146 deletions
| @@ -199,7 +199,7 @@ | |||
| 199 | 199 | /// Pull one configured dump into its `local_path`. | |
| 200 | 200 | async fn fetch_one( | |
| 201 | 201 | pool: &SqlitePool, | |
| 202 | - | _cfg: &Arc<AppConfig>, | |
| 202 | + | cfg: &Arc<AppConfig>, | |
| 203 | 203 | backup: &crate::topology::BackupConfig, | |
| 204 | 204 | force: bool, | |
| 205 | 205 | ) -> Result<FetchedBackup> { | |
| @@ -228,8 +228,10 @@ | |||
| 228 | 228 | // truncated server dump through. The first-ever fetch of a name (no prior | |
| 229 | 229 | // row) falls back to the absolute floor. | |
| 230 | 230 | let last_size: Option<i64> = sqlx::query_scalar( | |
| 231 | - | "SELECT byte_size FROM backups WHERE name = ? ORDER BY fetched_at DESC LIMIT 1", | |
| 231 | + | "SELECT byte_size FROM backups | |
| 232 | + | WHERE app = ? AND name = ? ORDER BY fetched_at DESC LIMIT 1", | |
| 232 | 233 | ) | |
| 234 | + | .bind(&cfg.id) | |
| 233 | 235 | .bind(&name) | |
| 234 | 236 | .fetch_optional(pool) | |
| 235 | 237 | .await?; | |
| @@ -326,9 +328,10 @@ | |||
| 326 | 328 | // reference a path that no longer exists — keep the table from growing. | |
| 327 | 329 | let mut tx = pool.begin().await?; | |
| 328 | 330 | sqlx::query( | |
| 329 | - | "INSERT INTO backups (name, fetched_at, source, local_path, byte_size) \ | |
| 330 | - | VALUES (?, ?, ?, ?, ?)", | |
| 331 | + | "INSERT INTO backups (app, name, fetched_at, source, local_path, byte_size) \ | |
| 332 | + | VALUES (?, ?, ?, ?, ?, ?)", | |
| 331 | 333 | ) | |
| 334 | + | .bind(&cfg.id) | |
| 332 | 335 | .bind(&name) | |
| 333 | 336 | .bind(Utc::now().to_rfc3339()) | |
| 334 | 337 | .bind(&source) | |
| @@ -336,7 +339,10 @@ | |||
| 336 | 339 | .bind(size) | |
| 337 | 340 | .execute(&mut *tx) | |
| 338 | 341 | .await?; | |
| 339 | - | sqlx::query("DELETE FROM backups WHERE fetched_at < datetime('now', '-30 days')") | |
| 342 | + | // Scoped: one product's fetch is not a licence to prune another's history, | |
| 343 | + | // and the 30-day window is about this product's own overwritten dumps. | |
| 344 | + | sqlx::query("DELETE FROM backups WHERE app = ? AND fetched_at < datetime('now', '-30 days')") | |
| 345 | + | .bind(&cfg.id) | |
| 340 | 346 | .execute(&mut *tx) | |
| 341 | 347 | .await?; | |
| 342 | 348 | tx.commit().await?; |
| @@ -211,9 +211,10 @@ | |||
| 211 | 211 | } | |
| 212 | 212 | ||
| 213 | 213 | sqlx::query( | |
| 214 | - | "INSERT OR IGNORE INTO versions (version, git_sha, built_at, artifact_path) | |
| 215 | - | VALUES (?, ?, ?, ?)", | |
| 214 | + | "INSERT OR IGNORE INTO versions (app, version, git_sha, built_at, artifact_path) | |
| 215 | + | VALUES (?, ?, ?, ?, ?)", | |
| 216 | 216 | ) | |
| 217 | + | .bind(&cfg.id) | |
| 217 | 218 | .bind(&version) | |
| 218 | 219 | .bind(&sha) | |
| 219 | 220 | .bind(Utc::now().to_rfc3339()) | |
| @@ -450,8 +451,9 @@ | |||
| 450 | 451 | deploy::finalize_local_release(host_release_root, &staging, digest.short()).await?; | |
| 451 | 452 | ||
| 452 | 453 | let staged_bin = released.join(cfg.primary_bin()); | |
| 453 | - | sqlx::query("UPDATE versions SET artifact_path = ? WHERE version = ?") | |
| 454 | + | sqlx::query("UPDATE versions SET artifact_path = ? WHERE app = ? AND version = ?") | |
| 454 | 455 | .bind(staged_bin.to_string_lossy().as_ref()) | |
| 456 | + | .bind(&cfg.id) | |
| 455 | 457 | .bind(&art.version) | |
| 456 | 458 | .execute(&pool) | |
| 457 | 459 | .await?; | |
| @@ -502,7 +504,7 @@ | |||
| 502 | 504 | // ultra-fuzz Run 2, S1). Held only for the atomic UPDATE, never the gates. | |
| 503 | 505 | { | |
| 504 | 506 | let _deploy_guard = deploy_lock.lock().await; | |
| 505 | - | crate::runs::advance_tier(&pool, "host", &art.version, Some(run_id.0)).await?; | |
| 507 | + | crate::runs::advance_tier(&pool, &cfg.id, "host", &art.version, Some(run_id.0)).await?; | |
| 506 | 508 | } | |
| 507 | 509 | // Terminal verdict: unlike the phase pings above (best-effort), a dropped | |
| 508 | 510 | // pass/fail write leaves the run wedged at `building`. Log it loudly if it | |
| @@ -515,7 +517,7 @@ | |||
| 515 | 517 | } else { | |
| 516 | 518 | // Pull the first red gate's typed summary into the run so the API | |
| 517 | 519 | // answers "which gate, and why" — not just "failed". | |
| 518 | - | let summary = crate::runs::first_failed_gate_summary(&pool, &art.version) | |
| 520 | + | let summary = crate::runs::first_failed_gate_summary(&pool, &cfg.id, &art.version) | |
| 519 | 521 | .await | |
| 520 | 522 | .unwrap_or_else(|| "host pipeline red".to_string()); | |
| 521 | 523 | if let Err(e) = crate::runs::mark_failed(&pool, run_id, &summary).await { | |
| @@ -689,9 +691,13 @@ | |||
| 689 | 691 | .await | |
| 690 | 692 | .unwrap(); | |
| 691 | 693 | ||
| 692 | - | let run_id = crate::runs::create(&pool, &git_sha.to_string()) | |
| 693 | - | .await | |
| 694 | - | .unwrap(); | |
| 694 | + | let run_id = crate::runs::create( | |
| 695 | + | &pool, | |
| 696 | + | &crate::domain::AppId::default(), | |
| 697 | + | &git_sha.to_string(), | |
| 698 | + | ) | |
| 699 | + | .await | |
| 700 | + | .unwrap(); | |
| 695 | 701 | ||
| 696 | 702 | let cfg = AppConfig { | |
| 697 | 703 | id: crate::domain::AppId::default(), |
| @@ -5,7 +5,7 @@ | |||
| 5 | 5 | ||
| 6 | 6 | use crate::classify; | |
| 7 | 7 | use crate::config::AppConfig; | |
| 8 | - | use crate::domain::{GateKind, GateRunId, TierId, Version}; | |
| 8 | + | use crate::domain::{AppId, GateKind, GateRunId, TierId, Version}; | |
| 9 | 9 | use crate::events::{self, Event, EventTx}; | |
| 10 | 10 | use crate::outcome::{GateBlocker, GateFailure, GateOutcome, LogRef, PassNote}; | |
| 11 | 11 | use crate::topology::Gate; | |
| @@ -97,9 +97,11 @@ | |||
| 97 | 97 | let started_at = Utc::now().to_rfc3339(); | |
| 98 | 98 | ||
| 99 | 99 | let id: i64 = sqlx::query_scalar( | |
| 100 | - | "INSERT INTO gate_runs (version, tier, gate_kind, started_at, build_id) VALUES (?, ?, ?, ?, ?) | |
| 100 | + | "INSERT INTO gate_runs (app, version, tier, gate_kind, started_at, build_id) | |
| 101 | + | VALUES (?, ?, ?, ?, ?, ?) | |
| 101 | 102 | RETURNING id", | |
| 102 | 103 | ) | |
| 104 | + | .bind(&ctx.cfg.id) | |
| 103 | 105 | .bind(&ctx.version) | |
| 104 | 106 | .bind(&ctx.tier) | |
| 105 | 107 | .bind(kind) | |
| @@ -931,8 +933,10 @@ | |||
| 931 | 933 | let label = check.dir.display().to_string(); | |
| 932 | 934 | ||
| 933 | 935 | let backup: Option<(String, String)> = sqlx::query_as( | |
| 934 | - | "SELECT local_path, fetched_at FROM backups WHERE name = ? ORDER BY id DESC LIMIT 1", | |
| 936 | + | "SELECT local_path, fetched_at FROM backups | |
| 937 | + | WHERE app = ? AND name = ? ORDER BY id DESC LIMIT 1", | |
| 935 | 938 | ) | |
| 939 | + | .bind(&ctx.cfg.id) | |
| 936 | 940 | .bind(&check.backup) | |
| 937 | 941 | .fetch_optional(&ctx.pool) | |
| 938 | 942 | .await?; | |
| @@ -1386,7 +1390,8 @@ | |||
| 1386 | 1390 | // runs first among the host gates, but staging precedes all gating, so the | |
| 1387 | 1391 | // artifact path is already recorded. | |
| 1388 | 1392 | let bin: Option<(String,)> = | |
| 1389 | - | sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?") | |
| 1393 | + | sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?") | |
| 1394 | + | .bind(&ctx.cfg.id) | |
| 1390 | 1395 | .bind(&ctx.version) | |
| 1391 | 1396 | .fetch_optional(&ctx.pool) | |
| 1392 | 1397 | .await?; | |
| @@ -1859,7 +1864,8 @@ | |||
| 1859 | 1864 | ||
| 1860 | 1865 | async fn boot_smoke(ctx: &GateCtx, run_id: GateRunId) -> Result<GateOutcome> { | |
| 1861 | 1866 | let bin: Option<(String,)> = | |
| 1862 | - | sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?") | |
| 1867 | + | sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?") | |
| 1868 | + | .bind(&ctx.cfg.id) | |
| 1863 | 1869 | .bind(&ctx.version) | |
| 1864 | 1870 | .fetch_optional(&ctx.pool) | |
| 1865 | 1871 | .await?; | |
| @@ -2247,9 +2253,15 @@ | |||
| 2247 | 2253 | /// by the promote-time gate check (`unsatisfied_gates`) so a stale `blocked` | |
| 2248 | 2254 | /// row never masks an elapsed — or not-yet-elapsed — window. The `burn_in` gate | |
| 2249 | 2255 | /// runner below wraps the same state with a richer outcome for `/state`. | |
| 2250 | - | pub async fn burn_in_satisfied(pool: &SqlitePool, tier: &TierId, hours: u32) -> Result<bool> { | |
| 2256 | + | pub async fn burn_in_satisfied( | |
| 2257 | + | pool: &SqlitePool, | |
| 2258 | + | app: &AppId, | |
| 2259 | + | tier: &TierId, | |
| 2260 | + | hours: u32, | |
| 2261 | + | ) -> Result<bool> { | |
| 2251 | 2262 | let started: Option<String> = | |
| 2252 | - | sqlx::query_scalar("SELECT burn_in_started_at FROM tier_state WHERE tier = ?") | |
| 2263 | + | sqlx::query_scalar("SELECT burn_in_started_at FROM tier_state WHERE app = ? AND tier = ?") | |
| 2264 | + | .bind(app) | |
| 2253 | 2265 | .bind(tier) | |
| 2254 | 2266 | .fetch_optional(pool) | |
| 2255 | 2267 | .await? | |
| @@ -2266,7 +2278,8 @@ | |||
| 2266 | 2278 | // has elapsed. The clock is started by /promote when a version lands on | |
| 2267 | 2279 | // the burn-in tier. | |
| 2268 | 2280 | let started: Option<String> = | |
| 2269 | - | sqlx::query_scalar("SELECT burn_in_started_at FROM tier_state WHERE tier = ?") | |
| 2281 | + | sqlx::query_scalar("SELECT burn_in_started_at FROM tier_state WHERE app = ? AND tier = ?") | |
| 2282 | + | .bind(&ctx.cfg.id) | |
| 2270 | 2283 | .bind(&ctx.tier) | |
| 2271 | 2284 | .fetch_optional(&ctx.pool) | |
| 2272 | 2285 | .await? | |
| @@ -2297,9 +2310,11 @@ | |||
| 2297 | 2310 | // look for a prior confirmation row. | |
| 2298 | 2311 | let prior_at: Option<String> = sqlx::query_scalar( | |
| 2299 | 2312 | "SELECT finished_at FROM gate_runs | |
| 2300 | - | WHERE tier = ? AND version = ? AND gate_kind = 'manual_confirm' AND status = 'passed' | |
| 2313 | + | WHERE app = ? AND tier = ? AND version = ? AND gate_kind = 'manual_confirm' | |
| 2314 | + | AND status = 'passed' | |
| 2301 | 2315 | ORDER BY id DESC LIMIT 1", | |
| 2302 | 2316 | ) | |
| 2317 | + | .bind(&ctx.cfg.id) | |
| 2303 | 2318 | .bind(&ctx.tier) | |
| 2304 | 2319 | .bind(&ctx.version) | |
| 2305 | 2320 | .fetch_optional(&ctx.pool) |
| @@ -103,29 +103,6 @@ | |||
| 103 | 103 | }), | |
| 104 | 104 | ); | |
| 105 | 105 | } | |
| 106 | - | // TEMPORARY, and loud on purpose. The config and the schema carry the | |
| 107 | - | // product dimension; the queries do not yet — they address `versions`, | |
| 108 | - | // `tier_state`, `gate_runs` and the rest without naming an app. With one | |
| 109 | - | // product configured that is correct, because every row is that product's. | |
| 110 | - | // With two it is silent cross-product corruption: pom's promote would read | |
| 111 | - | // MNW's tier state and advance it. | |
| 112 | - | // | |
| 113 | - | // So refuse, rather than run wrong. This check is deleted by the commit that | |
| 114 | - | // finishes threading `app` through the queries, and until then it is what | |
| 115 | - | // makes a half-finished migration safe instead of a trap. | |
| 116 | - | anyhow::ensure!( | |
| 117 | - | apps.len() == 1, | |
| 118 | - | "{} apps configured ({}), but Sando's queries are still app-blind: they \ | |
| 119 | - | address versions/tier_state/gate_runs without naming a product, so a \ | |
| 120 | - | second app would read and advance the first one's state. Configure one \ | |
| 121 | - | app until that threading lands (GoingsOn sando `{}`).", | |
| 122 | - | apps.len(), | |
| 123 | - | apps.keys() | |
| 124 | - | .map(ToString::to_string) | |
| 125 | - | .collect::<Vec<_>>() | |
| 126 | - | .join(", "), | |
| 127 | - | "b072577a", | |
| 128 | - | ); | |
| 129 | 106 | let apps = Arc::new(apps); | |
| 130 | 107 | // The product a request that names none is about. First in declared order, | |
| 131 | 108 | // which for a legacy config is the only one there is. |
| @@ -34,6 +34,7 @@ | |||
| 34 | 34 | //! and settled to a terminal `failed` so the audit trail carries no eternal | |
| 35 | 35 | //! in-flight row. | |
| 36 | 36 | ||
| 37 | + | use crate::domain::AppId; | |
| 37 | 38 | use anyhow::Result; | |
| 38 | 39 | use chrono::{DateTime, Utc}; | |
| 39 | 40 | use sqlx::{Row, SqlitePool}; | |
| @@ -57,8 +58,12 @@ | |||
| 57 | 58 | /// stands — and settles orphaned `in_progress` rows. Returns how many tiers were | |
| 58 | 59 | /// flagged. Run once at startup, before serving. | |
| 59 | 60 | pub async fn recover_unrecorded_deploys(pool: &SqlitePool) -> Result<u64> { | |
| 61 | + | // Every product's tiers, deliberately: startup reconciles the whole daemon, | |
| 62 | + | // and a promote interrupted by the restart is no less interrupted for | |
| 63 | + | // belonging to another product. Each tier is then compared only against its | |
| 64 | + | // own product's deploy trail. | |
| 60 | 65 | let tiers = sqlx::query( | |
| 61 | - | "SELECT tier, current_version, current_build_id, advanced_at, partial_reason | |
| 66 | + | "SELECT app, tier, current_version, current_build_id, advanced_at, partial_reason | |
| 62 | 67 | FROM tier_state", | |
| 63 | 68 | ) | |
| 64 | 69 | .fetch_all(pool) | |
| @@ -66,13 +71,14 @@ | |||
| 66 | 71 | ||
| 67 | 72 | let mut flagged = 0u64; | |
| 68 | 73 | for row in tiers { | |
| 74 | + | let app: AppId = AppId::new(row.get::<String, _>("app")); | |
| 69 | 75 | let tier: String = row.get("tier"); | |
| 70 | 76 | // A tier already flagged partial carries a more specific reason from the | |
| 71 | 77 | // path that flagged it; don't overwrite it. | |
| 72 | 78 | if row.get::<Option<String>, _>("partial_reason").is_some() { | |
| 73 | 79 | continue; | |
| 74 | 80 | } | |
| 75 | - | let Some(landing) = latest_deploy(pool, &tier).await? else { | |
| 81 | + | let Some(landing) = latest_deploy(pool, &app, &tier).await? else { | |
| 76 | 82 | continue; | |
| 77 | 83 | }; | |
| 78 | 84 | let current_version: Option<String> = row.get("current_version"); | |
| @@ -118,14 +124,16 @@ | |||
| 118 | 124 | // reason since the read above (startup is single-threaded, but the guard | |
| 119 | 125 | // costs nothing and states the intent). | |
| 120 | 126 | let res = sqlx::query( | |
| 121 | - | "UPDATE tier_state SET partial_reason = ? WHERE tier = ? AND partial_reason IS NULL", | |
| 127 | + | "UPDATE tier_state SET partial_reason = ? | |
| 128 | + | WHERE app = ? AND tier = ? AND partial_reason IS NULL", | |
| 122 | 129 | ) | |
| 123 | 130 | .bind(&reason) | |
| 131 | + | .bind(&app) | |
| 124 | 132 | .bind(&tier) | |
| 125 | 133 | .execute(pool) | |
| 126 | 134 | .await?; | |
| 127 | 135 | if res.rows_affected() > 0 { | |
| 128 | - | tracing::error!(tier = %tier, %reason, "startup reconcile: interrupted/unrecorded deploy on tier"); | |
| 136 | + | tracing::error!(%app, tier = %tier, %reason, "startup reconcile: interrupted/unrecorded deploy on tier"); | |
| 129 | 137 | flagged += 1; | |
| 130 | 138 | } | |
| 131 | 139 | } | |
| @@ -139,11 +147,12 @@ | |||
| 139 | 147 | ||
| 140 | 148 | /// The most recent deploy on `tier`, any outcome. Rows are ordered by `id` | |
| 141 | 149 | /// (monotonic autoincrement), so this is the last one regardless of clock skew. | |
| 142 | - | async fn latest_deploy(pool: &SqlitePool, tier: &str) -> Result<Option<LastDeploy>> { | |
| 150 | + | async fn latest_deploy(pool: &SqlitePool, app: &AppId, tier: &str) -> Result<Option<LastDeploy>> { | |
| 143 | 151 | let row = sqlx::query( | |
| 144 | 152 | "SELECT version, build_id, outcome, started_at, finished_at FROM deploys | |
| 145 | - | WHERE tier = ? ORDER BY id DESC LIMIT 1", | |
| 153 | + | WHERE app = ? AND tier = ? ORDER BY id DESC LIMIT 1", | |
| 146 | 154 | ) | |
| 155 | + | .bind(app) | |
| 147 | 156 | .bind(tier) | |
| 148 | 157 | .fetch_optional(pool) | |
| 149 | 158 | .await?; | |
| @@ -160,6 +169,9 @@ | |||
| 160 | 169 | } | |
| 161 | 170 | ||
| 162 | 171 | /// Mark every `in_progress` deploy row terminal-failed with an interruption note, | |
| 172 | + | /// across every product: the daemon that was interrupted was running all of | |
| 173 | + | /// them, so an unscoped sweep is the accurate one here. | |
| 174 | + | /// | |
| 163 | 175 | /// mirroring [`crate::runs::recover_orphaned_running`] for build runs. Returns the | |
| 164 | 176 | /// number of rows settled. | |
| 165 | 177 | async fn settle_orphaned_in_progress(pool: &SqlitePool) -> Result<u64> { |
| @@ -15,7 +15,7 @@ | |||
| 15 | 15 | //! build-step compile error, the first red gate, or the task-level catch for | |
| 16 | 16 | //! pre-build bails. Later writes are silent no-ops. | |
| 17 | 17 | ||
| 18 | - | use crate::domain::{RunId, Version}; | |
| 18 | + | use crate::domain::{AppId, RunId, Version}; | |
| 19 | 19 | use anyhow::Result; | |
| 20 | 20 | use chrono::Utc; | |
| 21 | 21 | use serde::Serialize; | |
| @@ -42,12 +42,13 @@ | |||
| 42 | 42 | } | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | - | /// Insert a fresh `building` run for `sha` and return its id. | |
| 46 | - | pub async fn create(pool: &SqlitePool, sha: &str) -> Result<RunId> { | |
| 45 | + | /// Insert a fresh `building` run for `app` at `sha` and return its id. | |
| 46 | + | pub async fn create(pool: &SqlitePool, app: &AppId, sha: &str) -> Result<RunId> { | |
| 47 | 47 | let id: i64 = sqlx::query_scalar( | |
| 48 | - | "INSERT INTO build_runs (sha, phase, result, started_at) | |
| 49 | - | VALUES (?, 'queued', 'building', ?) RETURNING id", | |
| 48 | + | "INSERT INTO build_runs (app, sha, phase, result, started_at) | |
| 49 | + | VALUES (?, ?, 'queued', 'building', ?) RETURNING id", | |
| 50 | 50 | ) | |
| 51 | + | .bind(app) | |
| 51 | 52 | .bind(sha) | |
| 52 | 53 | .bind(Utc::now().to_rfc3339()) | |
| 53 | 54 | .fetch_one(pool) | |
| @@ -99,6 +100,7 @@ | |||
| 99 | 100 | /// 009 / [`crate::reconcile`]). | |
| 100 | 101 | pub async fn advance_tier( | |
| 101 | 102 | pool: &SqlitePool, | |
| 103 | + | app: &AppId, | |
| 102 | 104 | tier: &str, | |
| 103 | 105 | version: &Version, | |
| 104 | 106 | build_id: Option<i64>, | |
| @@ -112,12 +114,13 @@ | |||
| 112 | 114 | current_build_id = ?, | |
| 113 | 115 | burn_in_started_at = ?, | |
| 114 | 116 | advanced_at = ? | |
| 115 | - | WHERE tier = ?", | |
| 117 | + | WHERE app = ? AND tier = ?", | |
| 116 | 118 | ) | |
| 117 | 119 | .bind(version) | |
| 118 | 120 | .bind(build_id) | |
| 119 | 121 | .bind(&now) | |
| 120 | 122 | .bind(&now) | |
| 123 | + | .bind(app) | |
| 121 | 124 | .bind(tier) | |
| 122 | 125 | .execute(pool) | |
| 123 | 126 | .await?; | |
| @@ -253,7 +256,7 @@ | |||
| 253 | 256 | /// Load a run plus its host-tier gate statuses. `None` if the id is unknown. | |
| 254 | 257 | pub async fn get(pool: &SqlitePool, run_id: RunId) -> Result<Option<RunView>> { | |
| 255 | 258 | let Some(row) = sqlx::query( | |
| 256 | - | "SELECT id, sha, version, phase, result, started_at, finished_at, failure_summary | |
| 259 | + | "SELECT id, app, sha, version, phase, result, started_at, finished_at, failure_summary | |
| 257 | 260 | FROM build_runs WHERE id = ?", | |
| 258 | 261 | ) | |
| 259 | 262 | .bind(run_id.0) | |
| @@ -264,17 +267,24 @@ | |||
| 264 | 267 | }; | |
| 265 | 268 | ||
| 266 | 269 | let version: Option<String> = row.get("version"); | |
| 267 | - | // Gates are keyed by (tier, version); a build run drives the `host` tier. | |
| 270 | + | // The run's own app, read from its row rather than passed in: a run id is | |
| 271 | + | // unique across products, and the row is the authority on which product it | |
| 272 | + | // belongs to. Asking the caller would let a lookup for one product's run | |
| 273 | + | // return another's gates. | |
| 274 | + | let app: String = row.get("app"); | |
| 275 | + | // Gates are keyed by (app, tier, version); a build run drives the `host` tier. | |
| 268 | 276 | // Latest row per gate_kind, matching `/state`'s per-tier query shape. | |
| 269 | 277 | let gates: Vec<RunGateView> = if let Some(ver) = version.as_deref() { | |
| 270 | 278 | sqlx::query( | |
| 271 | 279 | "SELECT gate_kind, status, log_ref | |
| 272 | 280 | FROM gate_runs g | |
| 273 | - | WHERE tier = 'host' AND version = ?1 | |
| 281 | + | WHERE app = ?1 AND tier = 'host' AND version = ?2 | |
| 274 | 282 | AND id = (SELECT MAX(id) FROM gate_runs | |
| 275 | - | WHERE tier = 'host' AND version = ?1 AND gate_kind = g.gate_kind) | |
| 283 | + | WHERE app = ?1 AND tier = 'host' AND version = ?2 | |
| 284 | + | AND gate_kind = g.gate_kind) | |
| 276 | 285 | ORDER BY gate_kind", | |
| 277 | 286 | ) | |
| 287 | + | .bind(&app) | |
| 278 | 288 | .bind(ver) | |
| 279 | 289 | .fetch_all(pool) | |
| 280 | 290 | .await? | |
| @@ -317,12 +327,14 @@ | |||
| 317 | 327 | pub elapsed_s: i64, | |
| 318 | 328 | } | |
| 319 | 329 | ||
| 320 | - | /// The most recent build run, for `/state`. `None` until the first `/rebuild`. | |
| 321 | - | pub async fn latest_summary(pool: &SqlitePool) -> Result<Option<BuildSummary>> { | |
| 330 | + | /// The most recent build run for `app`, for `/state`. `None` until that | |
| 331 | + | /// product's first `/rebuild`. | |
| 332 | + | pub async fn latest_summary(pool: &SqlitePool, app: &AppId) -> Result<Option<BuildSummary>> { | |
| 322 | 333 | let Some(row) = sqlx::query( | |
| 323 | 334 | "SELECT id, sha, version, phase, result, failure_summary, started_at, finished_at | |
| 324 | - | FROM build_runs ORDER BY id DESC LIMIT 1", | |
| 335 | + | FROM build_runs WHERE app = ? ORDER BY id DESC LIMIT 1", | |
| 325 | 336 | ) | |
| 337 | + | .bind(app) | |
| 326 | 338 | .fetch_optional(pool) | |
| 327 | 339 | .await? | |
| 328 | 340 | else { | |
| @@ -359,12 +371,17 @@ | |||
| 359 | 371 | /// any — used by the build pipeline to populate `failure_summary` when | |
| 360 | 372 | /// `run_all` reports a red pipeline. Reads the typed `outcome_json` so the | |
| 361 | 373 | /// stored headline matches what the TUI renders. | |
| 362 | - | pub async fn first_failed_gate_summary(pool: &SqlitePool, version: &Version) -> Option<String> { | |
| 374 | + | pub async fn first_failed_gate_summary( | |
| 375 | + | pool: &SqlitePool, | |
| 376 | + | app: &AppId, | |
| 377 | + | version: &Version, | |
| 378 | + | ) -> Option<String> { | |
| 363 | 379 | let row = sqlx::query( | |
| 364 | 380 | "SELECT gate_kind, outcome_json FROM gate_runs | |
| 365 | - | WHERE tier = 'host' AND version = ? AND status = 'failed' | |
| 381 | + | WHERE app = ? AND tier = 'host' AND version = ? AND status = 'failed' | |
| 366 | 382 | ORDER BY id ASC LIMIT 1", | |
| 367 | 383 | ) | |
| 384 | + | .bind(app) | |
| 368 | 385 | .bind(version.to_string()) | |
| 369 | 386 | .fetch_optional(pool) | |
| 370 | 387 | .await | |
| @@ -399,10 +416,77 @@ | |||
| 399 | 416 | pool | |
| 400 | 417 | } | |
| 401 | 418 | ||
| 419 | + | /// Two products do not see each other's builds or tier state. | |
| 420 | + | /// | |
| 421 | + | /// The whole point of the app dimension, in one test. Before it, every one | |
| 422 | + | /// of these reads answered from a single global pile: pom's `/state` would | |
| 423 | + | /// report MNW's latest build, and advancing pom's `host` tier would move | |
| 424 | + | /// MNW's — silently, because a shared row looks exactly like a correct one. | |
| 425 | + | #[tokio::test] | |
| 426 | + | async fn one_apps_state_is_invisible_to_another() { | |
| 427 | + | let pool = pool().await; | |
| 428 | + | let mnw = AppId::new("mnw"); | |
| 429 | + | let pom = AppId::new("pom"); | |
| 430 | + | ||
| 431 | + | // Each product needs its own tier and version rows to advance against. | |
| 432 | + | for app in [&mnw, &pom] { | |
| 433 | + | sqlx::query("INSERT INTO tiers (app, name, ord) VALUES (?, 'host', 0)") | |
| 434 | + | .bind(app) | |
| 435 | + | .execute(&pool) | |
| 436 | + | .await | |
| 437 | + | .unwrap(); | |
| 438 | + | sqlx::query("INSERT INTO tier_state (app, tier) VALUES (?, 'host')") | |
| 439 | + | .bind(app) | |
| 440 | + | .execute(&pool) | |
| 441 | + | .await | |
| 442 | + | .unwrap(); | |
| 443 | + | sqlx::query( | |
| 444 | + | "INSERT INTO versions (app, version, git_sha, built_at, artifact_path) | |
| 445 | + | VALUES (?, '1.0.0', 'sha', '2026-08-06T00:00:00Z', '/r')", | |
| 446 | + | ) | |
| 447 | + | .bind(app) | |
| 448 | + | .execute(&pool) | |
| 449 | + | .await | |
| 450 | + | .unwrap(); | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | let mnw_run = create(&pool, &mnw, "aaaaaaa").await.unwrap(); | |
| 454 | + | let pom_run = create(&pool, &pom, "bbbbbbb").await.unwrap(); | |
| 455 | + | ||
| 456 | + | // Latest build is per product, not "whichever ran last". | |
| 457 | + | assert_eq!( | |
| 458 | + | latest_summary(&pool, &mnw).await.unwrap().unwrap().sha, | |
| 459 | + | "aaaaaaa" | |
| 460 | + | ); | |
| 461 | + | assert_eq!( | |
| 462 | + | latest_summary(&pool, &pom).await.unwrap().unwrap().sha, | |
| 463 | + | "bbbbbbb", | |
| 464 | + | "pom's newest build is pom's, even though mnw's is older" | |
| 465 | + | ); | |
| 466 | + | ||
| 467 | + | // Advancing one product's `host` tier leaves the other's alone. | |
| 468 | + | let v = Version::parse("1.0.0").unwrap(); | |
| 469 | + | advance_tier(&pool, &pom, "host", &v, Some(pom_run.0)) | |
| 470 | + | .await | |
| 471 | + | .unwrap(); | |
| 472 | + | let mnw_current: Option<String> = | |
| 473 | + | sqlx::query_scalar("SELECT current_version FROM tier_state WHERE app = 'mnw'") | |
| 474 | + | .fetch_one(&pool) | |
| 475 | + | .await | |
| 476 | + | .unwrap(); | |
| 477 | + | assert_eq!( | |
| 478 | + | mnw_current, None, | |
| 479 | + | "advancing pom's host tier must not advance mnw's" | |
| 480 | + | ); | |
| 481 | + | ||
| 482 | + | // And a run resolves its gates through its own product. | |
| 483 | + | assert_eq!(get(&pool, mnw_run).await.unwrap().unwrap().sha, "aaaaaaa"); | |
| 484 | + | } | |
| 485 | + | ||
| 402 | 486 | #[tokio::test] | |
| 403 | 487 | async fn create_then_get_roundtrips_building() { | |
| 404 | 488 | let pool = pool().await; | |
| 405 | - | let id = create(&pool, "abc1234").await.unwrap(); | |
| 489 | + | let id = create(&pool, &AppId::default(), "abc1234").await.unwrap(); | |
| 406 | 490 | let v = get(&pool, id).await.unwrap().expect("run exists"); | |
| 407 | 491 | assert_eq!(v.sha, "abc1234"); | |
| 408 | 492 | assert_eq!(v.result, "building"); | |
| @@ -417,9 +501,9 @@ | |||
| 417 | 501 | let pool = pool().await; | |
| 418 | 502 | // Two in-flight runs (as if the daemon died mid-build) + one already | |
| 419 | 503 | // settled, which must be left untouched. | |
| 420 | - | let run_a = create(&pool, "aaaaaaa").await.unwrap(); | |
| 421 | - | let run_b = create(&pool, "bbbbbbb").await.unwrap(); | |
| 422 | - | let run_c = create(&pool, "ccccccc").await.unwrap(); | |
| 504 | + | let run_a = create(&pool, &AppId::default(), "aaaaaaa").await.unwrap(); | |
| 505 | + | let run_b = create(&pool, &AppId::default(), "bbbbbbb").await.unwrap(); | |
| 506 | + | let run_c = create(&pool, &AppId::default(), "ccccccc").await.unwrap(); | |
| 423 | 507 | mark_passed(&pool, run_c).await.unwrap(); | |
| 424 | 508 | ||
| 425 | 509 | let reconciled = recover_orphaned_running(&pool).await.unwrap(); | |
| @@ -445,7 +529,7 @@ | |||
| 445 | 529 | #[tokio::test] | |
| 446 | 530 | async fn phase_and_version_advance_then_pass() { | |
| 447 | 531 | let pool = pool().await; | |
| 448 | - | let id = create(&pool, "abc1234").await.unwrap(); | |
| 532 | + | let id = create(&pool, &AppId::default(), "abc1234").await.unwrap(); | |
| 449 | 533 | set_phase(&pool, id, Phase::Compiling).await.unwrap(); | |
| 450 | 534 | let ver: Version = "0.10.2".parse().unwrap(); | |
| 451 | 535 | set_version(&pool, id, &ver).await.unwrap(); | |
| @@ -461,7 +545,7 @@ | |||
| 461 | 545 | #[tokio::test] | |
| 462 | 546 | async fn first_terminal_write_wins() { | |
| 463 | 547 | let pool = pool().await; | |
| 464 | - | let id = create(&pool, "abc1234").await.unwrap(); | |
| 548 | + | let id = create(&pool, &AppId::default(), "abc1234").await.unwrap(); | |
| 465 | 549 | mark_failed(&pool, id, "error[E0063]: missing field user_pages_host") | |
| 466 | 550 | .await | |
| 467 | 551 | .unwrap(); | |
| @@ -482,7 +566,7 @@ | |||
| 482 | 566 | #[tokio::test] | |
| 483 | 567 | async fn phase_write_after_terminal_is_noop() { | |
| 484 | 568 | let pool = pool().await; | |
| 485 | - | let id = create(&pool, "abc1234").await.unwrap(); | |
| 569 | + | let id = create(&pool, &AppId::default(), "abc1234").await.unwrap(); | |
| 486 | 570 | mark_passed(&pool, id).await.unwrap(); | |
| 487 | 571 | set_phase(&pool, id, Phase::Gating).await.unwrap(); | |
| 488 | 572 | let v = get(&pool, id).await.unwrap().unwrap(); | |
| @@ -504,11 +588,19 @@ | |||
| 504 | 588 | #[tokio::test] | |
| 505 | 589 | async fn latest_summary_reports_most_recent_run() { | |
| 506 | 590 | let pool = pool().await; | |
| 507 | - | assert!(latest_summary(&pool).await.unwrap().is_none()); | |
| 508 | - | let _old = create(&pool, "old1234").await.unwrap(); | |
| 509 | - | let new = create(&pool, "new5678").await.unwrap(); | |
| 591 | + | assert!( | |
| 592 | + | latest_summary(&pool, &AppId::default()) | |
| 593 | + | .await | |
| 594 | + | .unwrap() | |
| 595 | + | .is_none() | |
| 596 | + | ); | |
| 597 | + | let _old = create(&pool, &AppId::default(), "old1234").await.unwrap(); | |
| 598 | + | let new = create(&pool, &AppId::default(), "new5678").await.unwrap(); | |
| 510 | 599 | set_phase(&pool, new, Phase::Compiling).await.unwrap(); | |
| 511 | - | let sum = latest_summary(&pool).await.unwrap().expect("a run exists"); | |
| 600 | + | let sum = latest_summary(&pool, &AppId::default()) | |
| 601 | + | .await | |
| 602 | + | .unwrap() | |
| 603 | + | .expect("a run exists"); | |
| 512 | 604 | assert_eq!(sum.run_id, new.0); | |
| 513 | 605 | assert_eq!(sum.sha, "new5678"); | |
| 514 | 606 | assert_eq!(sum.phase, "compiling"); | |
| @@ -524,7 +616,7 @@ | |||
| 524 | 616 | #[tokio::test] | |
| 525 | 617 | async fn failure_summary_is_bounded() { | |
| 526 | 618 | let pool = pool().await; | |
| 527 | - | let id = create(&pool, "abc1234").await.unwrap(); | |
| 619 | + | let id = create(&pool, &AppId::default(), "abc1234").await.unwrap(); | |
| 528 | 620 | mark_failed(&pool, id, &"x".repeat(5_000)).await.unwrap(); | |
| 529 | 621 | let v = get(&pool, id).await.unwrap().unwrap(); | |
| 530 | 622 | assert!(v.failure_summary.unwrap().len() <= 600); |
| @@ -151,9 +151,11 @@ | |||
| 151 | 151 | ts.current_version, ts.previous_version, ts.burn_in_started_at, | |
| 152 | 152 | ts.partial_reason | |
| 153 | 153 | FROM tiers t | |
| 154 | - | LEFT JOIN tier_state ts ON ts.tier = t.name | |
| 154 | + | LEFT JOIN tier_state ts ON ts.app = t.app AND ts.tier = t.name | |
| 155 | + | WHERE t.app = ? | |
| 155 | 156 | ORDER BY t.ord", | |
| 156 | 157 | ) | |
| 158 | + | .bind(&s.cfg.id) | |
| 157 | 159 | .fetch_all(&s.pool) | |
| 158 | 160 | .await?; | |
| 159 | 161 | ||
| @@ -163,7 +165,8 @@ | |||
| 163 | 165 | let current_version: Option<String> = r.get("current_version"); | |
| 164 | 166 | ||
| 165 | 167 | let nodes: Vec<String> = | |
| 166 | - | sqlx::query_scalar("SELECT name FROM nodes WHERE tier = ? ORDER BY name") | |
| 168 | + | sqlx::query_scalar("SELECT name FROM nodes WHERE app = ? AND tier = ? ORDER BY name") | |
| 169 | + | .bind(&s.cfg.id) | |
| 167 | 170 | .bind(&name) | |
| 168 | 171 | .fetch_all(&s.pool) | |
| 169 | 172 | .await?; | |
| @@ -177,9 +180,10 @@ | |||
| 177 | 180 | current_version.clone() | |
| 178 | 181 | } else { | |
| 179 | 182 | sqlx::query_scalar( | |
| 180 | - | "SELECT version FROM gate_runs WHERE tier = ? | |
| 183 | + | "SELECT version FROM gate_runs WHERE app = ? AND tier = ? | |
| 181 | 184 | ORDER BY id DESC LIMIT 1", | |
| 182 | 185 | ) | |
| 186 | + | .bind(&s.cfg.id) | |
| 183 | 187 | .bind(&name) | |
| 184 | 188 | .fetch_optional(&s.pool) | |
| 185 | 189 | .await? | |
| @@ -190,11 +194,13 @@ | |||
| 190 | 194 | sqlx::query( | |
| 191 | 195 | "SELECT gate_kind, finished_at, status, outcome_json, log_ref | |
| 192 | 196 | FROM gate_runs g | |
| 193 | - | WHERE tier = ?1 AND version = ?2 | |
| 197 | + | WHERE app = ?1 AND tier = ?2 AND version = ?3 | |
| 194 | 198 | AND id = (SELECT MAX(id) FROM gate_runs | |
| 195 | - | WHERE tier = ?1 AND version = ?2 AND gate_kind = g.gate_kind) | |
| 199 | + | WHERE app = ?1 AND tier = ?2 AND version = ?3 | |
| 200 | + | AND gate_kind = g.gate_kind) | |
| 196 | 201 | ORDER BY gate_kind", | |
| 197 | 202 | ) | |
| 203 | + | .bind(&s.cfg.id) | |
| 198 | 204 | .bind(&name) | |
| 199 | 205 | .bind(ver) | |
| 200 | 206 | .fetch_all(&s.pool) | |
| @@ -228,7 +234,7 @@ | |||
| 228 | 234 | }); | |
| 229 | 235 | } | |
| 230 | 236 | ||
| 231 | - | let build = crate::runs::latest_summary(&s.pool).await?; | |
| 237 | + | let build = crate::runs::latest_summary(&s.pool, &s.cfg.id).await?; | |
| 232 | 238 | Ok(StateView { | |
| 233 | 239 | sandod_version: env!("CARGO_PKG_VERSION"), | |
| 234 | 240 | tiers, | |
| @@ -293,12 +299,14 @@ | |||
| 293 | 299 | .find(|t| t.name == tier) | |
| 294 | 300 | .ok_or(crate::error::Error::NotFound)?; | |
| 295 | 301 | ||
| 296 | - | let row: Option<(Option<String>, Option<String>)> = | |
| 297 | - | sqlx::query_as("SELECT current_version, previous_version FROM tier_state WHERE tier = ?") | |
| 298 | - | .bind(&tier) | |
| 299 | - | .fetch_optional(&s.pool) | |
| 300 | - | .await | |
| 301 | - | .map_err(crate::error::Error::Db)?; | |
| 302 | + | let row: Option<(Option<String>, Option<String>)> = sqlx::query_as( | |
| 303 | + | "SELECT current_version, previous_version FROM tier_state WHERE app = ? AND tier = ?", | |
| 304 | + | ) | |
| 305 | + | .bind(&s.cfg.id) | |
| 306 | + | .bind(&tier) | |
| 307 | + | .fetch_optional(&s.pool) | |
| 308 | + | .await | |
| 309 | + | .map_err(crate::error::Error::Db)?; | |
| 302 | 310 | let (Some(current_str), Some(previous_str)) = row.unwrap_or((None, None)) else { | |
| 303 | 311 | return Err(crate::error::Error::GateBlocked( | |
| 304 | 312 | "no previous_version to roll back to".into(), | |
| @@ -310,7 +318,8 @@ | |||
| 310 | 318 | .map_err(|e| crate::error::Error::Other(anyhow::anyhow!(e)))?; | |
| 311 | 319 | ||
| 312 | 320 | let bin: Option<(String,)> = | |
| 313 | - | sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?") | |
| 321 | + | sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?") | |
| 322 | + | .bind(&s.cfg.id) | |
| 314 | 323 | .bind(&previous) | |
| 315 | 324 | .fetch_optional(&s.pool) | |
| 316 | 325 | .await | |
| @@ -378,10 +387,11 @@ | |||
| 378 | 387 | "UPDATE tier_state | |
| 379 | 388 | SET current_version = ?, previous_version = NULL, | |
| 380 | 389 | burn_in_started_at = NULL, advanced_at = ? | |
| 381 | - | WHERE tier = ?", | |
| 390 | + | WHERE app = ? AND tier = ?", | |
| 382 | 391 | ) | |
| 383 | 392 | .bind(&previous) | |
| 384 | 393 | .bind(&now) | |
| 394 | + | .bind(&s.cfg.id) | |
| 385 | 395 | .bind(&tier) | |
| 386 | 396 | .execute(&s.pool) | |
| 387 | 397 | .await | |
| @@ -455,7 +465,7 @@ | |||
| 455 | 465 | // One pollable resource per triggered build. Created before the spawn so | |
| 456 | 466 | // the run id is in the response even if the task is aborted milliseconds | |
| 457 | 467 | // later by a still-newer /rebuild. | |
| 458 | - | let run_id = crate::runs::create(&s.pool, sha.as_str()) | |
| 468 | + | let run_id = crate::runs::create(&s.pool, &s.cfg.id, sha.as_str()) | |
| 459 | 469 | .await | |
| 460 | 470 | .map_err(crate::error::Error::Other)?; | |
| 461 | 471 | ||
| @@ -665,7 +675,8 @@ | |||
| 665 | 675 | .ok_or(crate::error::Error::NotFound)?; | |
| 666 | 676 | ||
| 667 | 677 | let version_str: Option<String> = | |
| 668 | - | sqlx::query_scalar("SELECT current_version FROM tier_state WHERE tier = ?") | |
| 678 | + | sqlx::query_scalar("SELECT current_version FROM tier_state WHERE app = ? AND tier = ?") | |
| 679 | + | .bind(&s.cfg.id) | |
| 669 | 680 | .bind(&target.name) | |
| 670 | 681 | .fetch_optional(&s.pool) | |
| 671 | 682 | .await | |
| @@ -687,9 +698,10 @@ | |||
| 687 | 698 | let outcome_json = serde_json::to_string(&outcome) | |
| 688 | 699 | .map_err(|e| crate::error::Error::Other(anyhow::anyhow!(e)))?; | |
| 689 | 700 | sqlx::query( | |
| 690 | - | "INSERT INTO gate_runs (version, tier, gate_kind, started_at, finished_at, status, outcome_json) | |
| 691 | - | VALUES (?, ?, 'manual_confirm', ?, ?, 'passed', ?)", | |
| 701 | + | "INSERT INTO gate_runs (app, version, tier, gate_kind, started_at, finished_at, status, outcome_json) | |
| 702 | + | VALUES (?, ?, ?, 'manual_confirm', ?, ?, 'passed', ?)", | |
| 692 | 703 | ) | |
| 704 | + | .bind(&s.cfg.id) | |
| 693 | 705 | .bind(&version).bind(&target.name).bind(&now).bind(&now).bind(&outcome_json) | |
| 694 | 706 | .execute(&s.pool).await.map_err(crate::error::Error::Db)?; | |
| 695 | 707 | ||
| @@ -1053,9 +1065,17 @@ | |||
| 1053 | 1065 | // A tier that configures no gates has nothing to satisfy. | |
| 1054 | 1066 | let pool = fresh_pool().await; | |
| 1055 | 1067 | seed(&pool, "host", "0.8.12").await; | |
| 1056 | - | let pending = unsatisfied_gates(&pool, &tid("host"), &[], "0.8.12", None, false) | |
| 1057 | - | .await | |
| 1058 | - | .unwrap(); | |
| 1068 | + | let pending = unsatisfied_gates( | |
| 1069 | + | &pool, | |
| 1070 | + | &crate::domain::AppId::default(), | |
| 1071 | + | &tid("host"), | |
| 1072 | + | &[], | |
| 1073 | + | "0.8.12", | |
| 1074 | + | None, | |
| 1075 | + | false, | |
| 1076 | + | ) | |
| 1077 | + | .await | |
| 1078 | + | .unwrap(); | |
| 1059 | 1079 | assert_eq!(pending, Vec::<String>::new()); | |
| 1060 | 1080 | } | |
| 1061 | 1081 | ||
| @@ -1067,10 +1087,17 @@ | |||
| 1067 | 1087 | // straight through to prod. | |
| 1068 | 1088 | let pool = fresh_pool().await; | |
| 1069 | 1089 | seed(&pool, "a", "0.8.12").await; | |
| 1070 | - | let pending = | |
| 1071 | - | unsatisfied_gates(&pool, &tid("a"), &[Gate::BootSmoke], "0.8.12", None, false) | |
| 1072 | - | .await | |
| 1073 | - | .unwrap(); | |
| 1090 | + | let pending = unsatisfied_gates( | |
| 1091 | + | &pool, | |
| 1092 | + | &crate::domain::AppId::default(), | |
| 1093 | + | &tid("a"), | |
| 1094 | + | &[Gate::BootSmoke], | |
| 1095 | + | "0.8.12", | |
| 1096 | + | None, | |
| 1097 | + | false, | |
| 1098 | + | ) | |
| 1099 | + | .await | |
| 1100 | + | .unwrap(); | |
| 1074 | 1101 | assert_eq!(pending, vec!["boot_smoke".to_string()]); | |
| 1075 | 1102 | } | |
| 1076 | 1103 | ||
| @@ -1082,6 +1109,7 @@ | |||
| 1082 | 1109 | insert_gate(&pool, "host", "0.8.12", "boot_smoke", 1).await; | |
| 1083 | 1110 | let pending = unsatisfied_gates( | |
| 1084 | 1111 | &pool, | |
| 1112 | + | &crate::domain::AppId::default(), | |
| 1085 | 1113 | &tid("host"), | |
| 1086 | 1114 | &[Gate::CargoTest, Gate::BootSmoke], | |
| 1087 | 1115 | "0.8.12", | |
| @@ -1103,6 +1131,7 @@ | |||
| 1103 | 1131 | insert_gate(&pool, "host", "0.8.12", "cargo_test", 1).await; | |
| 1104 | 1132 | let pending = unsatisfied_gates( | |
| 1105 | 1133 | &pool, | |
| 1134 | + | &crate::domain::AppId::default(), | |
| 1106 | 1135 | &tid("host"), | |
| 1107 | 1136 | &[Gate::CargoTest], | |
| 1108 | 1137 | "0.8.12", | |
| @@ -1151,6 +1180,7 @@ | |||
| 1151 | 1180 | insert_confirm(&pool, "a", "0.8.12", landed - chrono::Duration::hours(1)).await; | |
| 1152 | 1181 | let pending = unsatisfied_gates( | |
| 1153 | 1182 | &pool, | |
| 1183 | + | &crate::domain::AppId::default(), | |
| 1154 | 1184 | &tid("a"), | |
| 1155 | 1185 | &[Gate::ManualConfirm], | |
| 1156 | 1186 | "0.8.12", | |
| @@ -1169,6 +1199,7 @@ | |||
| 1169 | 1199 | insert_confirm(&pool, "a", "0.8.12", landed + chrono::Duration::minutes(5)).await; | |
| 1170 | 1200 | let pending = unsatisfied_gates( | |
| 1171 | 1201 | &pool, | |
| 1202 | + | &crate::domain::AppId::default(), | |
| 1172 | 1203 | &tid("a"), | |
| 1173 | 1204 | &[Gate::ManualConfirm], | |
| 1174 | 1205 | "0.8.12", | |
| @@ -1189,6 +1220,7 @@ | |||
| 1189 | 1220 | insert_confirm(&pool, "a", "0.8.12", chrono::Utc::now()).await; | |
| 1190 | 1221 | let pending = unsatisfied_gates( | |
| 1191 | 1222 | &pool, | |
| 1223 | + | &crate::domain::AppId::default(), | |
| 1192 | 1224 | &tid("a"), | |
| 1193 | 1225 | &[Gate::ManualConfirm], | |
| 1194 | 1226 | "0.8.12", | |
| @@ -1215,17 +1247,33 @@ | |||
| 1215 | 1247 | insert_gate(&pool, "a", "0.8.12", "cargo_test", 0).await; | |
| 1216 | 1248 | let gates = [Gate::BurnIn { hours: 48 }, Gate::CargoTest]; | |
| 1217 | 1249 | ||
| 1218 | - | let normal = unsatisfied_gates(&pool, &tid("a"), &gates, "0.8.12", None, false) | |
| 1219 | - | .await | |
| 1220 | - | .unwrap(); | |
| 1250 | + | let normal = unsatisfied_gates( | |
| 1251 | + | &pool, | |
| 1252 | + | &crate::domain::AppId::default(), | |
| 1253 | + | &tid("a"), | |
| 1254 | + | &gates, | |
| 1255 | + | "0.8.12", | |
| 1256 | + | None, | |
| 1257 | + | false, | |
| 1258 | + | ) | |
| 1259 | + | .await | |
| 1260 | + | .unwrap(); | |
| 1221 | 1261 | assert_eq!( | |
| 1222 | 1262 | normal, | |
| 1223 | 1263 | vec!["burn_in".to_string(), "cargo_test".to_string()] | |
| 1224 | 1264 | ); | |
| 1225 | 1265 | ||
| 1226 | - | let with_hotfix = unsatisfied_gates(&pool, &tid("a"), &gates, "0.8.12", None, true) | |
| 1227 | - | .await | |
| 1228 | - | .unwrap(); | |
| 1266 | + | let with_hotfix = unsatisfied_gates( | |
| 1267 | + | &pool, | |
| 1268 | + | &crate::domain::AppId::default(), | |
| 1269 | + | &tid("a"), | |
| 1270 | + | &gates, | |
| 1271 | + | "0.8.12", | |
| 1272 | + | None, | |
| 1273 | + | true, | |
| 1274 | + | ) | |
| 1275 | + | .await | |
| 1276 | + | .unwrap(); | |
| 1229 | 1277 | assert_eq!(with_hotfix, vec!["cargo_test".to_string()]); | |
| 1230 | 1278 | } | |
| 1231 | 1279 | ||
| @@ -1242,6 +1290,7 @@ | |||
| 1242 | 1290 | .unwrap(); | |
| 1243 | 1291 | let pending = unsatisfied_gates( | |
| 1244 | 1292 | &pool, | |
| 1293 | + | &crate::domain::AppId::default(), | |
| 1245 | 1294 | &tid("a"), | |
| 1246 | 1295 | &[Gate::BurnIn { hours: 48 }], | |
| 1247 | 1296 | "0.8.12", | |
| @@ -1267,6 +1316,7 @@ | |||
| 1267 | 1316 | ||
| 1268 | 1317 | let pending = unsatisfied_gates( | |
| 1269 | 1318 | &pool, | |
| 1319 | + | &crate::domain::AppId::default(), | |
| 1270 | 1320 | &tid("host"), | |
| 1271 | 1321 | &[Gate::CargoTest], | |
| 1272 | 1322 | "0.8.12", | |
| @@ -1295,6 +1345,7 @@ | |||
| 1295 | 1345 | ||
| 1296 | 1346 | let pending = unsatisfied_gates( | |
| 1297 | 1347 | &pool, | |
| 1348 | + | &crate::domain::AppId::default(), | |
| 1298 | 1349 | &tid("host"), | |
| 1299 | 1350 | &[Gate::CargoTest], | |
| 1300 | 1351 | "0.8.12", | |
| @@ -1403,7 +1454,7 @@ | |||
| 1403 | 1454 | async fn get_run_returns_view_with_gates() { | |
| 1404 | 1455 | let state = test_state().await; | |
| 1405 | 1456 | // A run that reached version 0.10.2 and ran two host gates (one red). | |
| 1406 | - | let run_id = crate::runs::create(&state.pool, "abc1234def") | |
| 1457 | + | let run_id = crate::runs::create(&state.pool, &state.cfg.id, "abc1234def") | |
| 1407 | 1458 | .await | |
| 1408 | 1459 | .unwrap(); | |
| 1409 | 1460 | let ver: crate::domain::Version = "0.10.2".parse().unwrap(); | |
| @@ -1441,7 +1492,7 @@ | |||
| 1441 | 1492 | #[tokio::test] | |
| 1442 | 1493 | async fn get_run_wait_returns_immediately_when_settled() { | |
| 1443 | 1494 | let state = test_state().await; | |
| 1444 | - | let run_id = crate::runs::create(&state.pool, "abc1234def") | |
| 1495 | + | let run_id = crate::runs::create(&state.pool, &state.cfg.id, "abc1234def") | |
| 1445 | 1496 | .await | |
| 1446 | 1497 | .unwrap(); | |
| 1447 | 1498 | crate::runs::mark_passed(&state.pool, run_id).await.unwrap(); | |
| @@ -1465,7 +1516,7 @@ | |||
| 1465 | 1516 | #[tokio::test] | |
| 1466 | 1517 | async fn get_run_wait_returns_building_at_timeout() { | |
| 1467 | 1518 | let state = test_state().await; | |
| 1468 | - | let run_id = crate::runs::create(&state.pool, "abc1234def") | |
| 1519 | + | let run_id = crate::runs::create(&state.pool, &state.cfg.id, "abc1234def") | |
| 1469 | 1520 | .await | |
| 1470 | 1521 | .unwrap(); | |
| 1471 | 1522 | ||
| @@ -1733,7 +1784,7 @@ | |||
| 1733 | 1784 | // Exercise the sealed forward-advance primitive itself — the same op | |
| 1734 | 1785 | // /promote and the host build path both call (S1), not a copy of its SQL. | |
| 1735 | 1786 | let v = crate::domain::Version::parse("2.0.0").unwrap(); | |
| 1736 | - | crate::runs::advance_tier(&pool, "a", &v, None) | |
| 1787 | + | crate::runs::advance_tier(&pool, &crate::domain::AppId::default(), "a", &v, None) | |
| 1737 | 1788 | .await | |
| 1738 | 1789 | .unwrap(); | |
| 1739 | 1790 | ||
| @@ -2518,7 +2569,9 @@ | |||
| 2518 | 2569 | assert!(view.build.is_none()); | |
| 2519 | 2570 | ||
| 2520 | 2571 | // A failed run must surface its cause in /state, not just in /runs. | |
| 2521 | - | let run_id = crate::runs::create(&state.pool, "deadbeef").await.unwrap(); | |
| 2572 | + | let run_id = crate::runs::create(&state.pool, &state.cfg.id, "deadbeef") | |
| 2573 | + | .await | |
| 2574 | + | .unwrap(); | |
| 2522 | 2575 | crate::runs::mark_failed(&state.pool, run_id, "cargo_test: 3 test(s) failed") | |
| 2523 | 2576 | .await | |
| 2524 | 2577 | .unwrap(); | |
| @@ -2861,6 +2914,7 @@ | |||
| 2861 | 2914 | // b1's own evidence satisfies. | |
| 2862 | 2915 | let ok = unsatisfied_gates( | |
| 2863 | 2916 | &pool, | |
| 2917 | + | &crate::domain::AppId::default(), | |
| 2864 | 2918 | &tid("a"), | |
| 2865 | 2919 | &[Gate::CargoTest], | |
| 2866 | 2920 | "3.0.0", | |
| @@ -2875,6 +2929,7 @@ | |||
| 2875 | 2929 | // is the hole — a rebuild reusing the version must not ride b1's evidence. | |
| 2876 | 2930 | let bad = unsatisfied_gates( | |
| 2877 | 2931 | &pool, | |
| 2932 | + | &crate::domain::AppId::default(), | |
| 2878 | 2933 | &tid("a"), | |
| 2879 | 2934 | &[Gate::CargoTest], | |
| 2880 | 2935 | "3.0.0", | |
| @@ -2890,9 +2945,17 @@ | |||
| 2890 | 2945 | ); | |
| 2891 | 2946 | ||
| 2892 | 2947 | // Legacy (pre-identity) callers still resolve by version string. | |
| 2893 | - | let legacy = unsatisfied_gates(&pool, &tid("a"), &[Gate::CargoTest], "3.0.0", None, false) | |
| 2894 | - | .await | |
| 2895 | - | .unwrap(); | |
| 2948 | + | let legacy = unsatisfied_gates( | |
| 2949 | + | &pool, | |
| 2950 | + | &crate::domain::AppId::default(), | |
| 2951 | + | &tid("a"), | |
| 2952 | + | &[Gate::CargoTest], | |
| 2953 | + | "3.0.0", | |
| 2954 | + | None, | |
| 2955 | + | false, | |
| 2956 | + | ) | |
| 2957 | + | .await | |
| 2958 | + | .unwrap(); | |
| 2896 | 2959 | assert!(legacy.is_empty(), "version-keyed legacy path unchanged"); | |
| 2897 | 2960 | } | |
| 2898 | 2961 |
| @@ -49,8 +49,9 @@ | |||
| 49 | 49 | "SELECT ts.current_build_id, br.version, br.staged_path | |
| 50 | 50 | FROM tier_state ts | |
| 51 | 51 | LEFT JOIN build_runs br ON br.id = ts.current_build_id | |
| 52 | - | WHERE ts.tier = ?", | |
| 52 | + | WHERE ts.app = ? AND ts.tier = ?", | |
| 53 | 53 | ) | |
| 54 | + | .bind(&s.cfg.id) | |
| 54 | 55 | .bind(&source.name) | |
| 55 | 56 | .fetch_optional(&s.pool) | |
| 56 | 57 | .await | |
| @@ -92,8 +93,9 @@ | |||
| 92 | 93 | let version_str = match body.version.clone() { | |
| 93 | 94 | Some(v) => v, | |
| 94 | 95 | None => sqlx::query_scalar::<_, Option<String>>( | |
| 95 | - | "SELECT current_version FROM tier_state WHERE tier = ?", | |
| 96 | + | "SELECT current_version FROM tier_state WHERE app = ? AND tier = ?", | |
| 96 | 97 | ) | |
| 98 | + | .bind(&s.cfg.id) | |
| 97 | 99 | .bind(&source.name) | |
| 98 | 100 | .fetch_optional(&s.pool) | |
| 99 | 101 | .await | |
| @@ -107,7 +109,8 @@ | |||
| 107 | 109 | })?, | |
| 108 | 110 | }; | |
| 109 | 111 | let bin: Option<(String,)> = | |
| 110 | - | sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?") | |
| 112 | + | sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?") | |
| 113 | + | .bind(&s.cfg.id) | |
| 111 | 114 | .bind(&version_str) | |
| 112 | 115 | .fetch_optional(&s.pool) | |
| 113 | 116 | .await | |
| @@ -149,6 +152,7 @@ | |||
| 149 | 152 | } | |
| 150 | 153 | let pending = unsatisfied_gates( | |
| 151 | 154 | &s.pool, | |
| 155 | + | &s.cfg.id, | |
| 152 | 156 | &source.name, | |
| 153 | 157 | &effective_gates, | |
| 154 | 158 | &version_str, | |
| @@ -168,7 +172,8 @@ | |||
| 168 | 172 | // The version this tier was running before this promote — the rollback | |
| 169 | 173 | // target if a canary node fails partway through a multi-node rollout. | |
| 170 | 174 | let prev_version: Option<String> = | |
| 171 | - | sqlx::query_scalar("SELECT current_version FROM tier_state WHERE tier = ?") | |
| 175 | + | sqlx::query_scalar("SELECT current_version FROM tier_state WHERE app = ? AND tier = ?") | |
| 176 | + | .bind(&s.cfg.id) | |
| 172 | 177 | .bind(&target.name) | |
| 173 | 178 | .fetch_optional(&s.pool) | |
| 174 | 179 | .await | |
| @@ -204,9 +209,10 @@ | |||
| 204 | 209 | // any row still `in_progress` at startup is an orphan the reconcile settles | |
| 205 | 210 | // and flags ([`crate::reconcile`]). | |
| 206 | 211 | let deploy_id: i64 = sqlx::query_scalar( | |
| 207 | - | "INSERT INTO deploys (version, tier, node, started_at, outcome, hotfix, reset_burn_in, build_id) | |
| 208 | - | VALUES (?, ?, ?, ?, 'in_progress', ?, ?, ?) RETURNING id", | |
| 212 | + | "INSERT INTO deploys (app, version, tier, node, started_at, outcome, hotfix, reset_burn_in, build_id) | |
| 213 | + | VALUES (?, ?, ?, ?, ?, 'in_progress', ?, ?, ?) RETURNING id", | |
| 209 | 214 | ) | |
| 215 | + | .bind(&s.cfg.id) | |
| 210 | 216 | .bind(&version).bind(&target.name).bind(&node.name) | |
| 211 | 217 | .bind(&started) | |
| 212 | 218 | .bind(body.hotfix as i64).bind(body.reset_burn_in as i64) | |
| @@ -456,12 +462,13 @@ | |||
| 456 | 462 | // serialized against rollback and the host build path's advance. | |
| 457 | 463 | // reset_burn_in on the *source* tier nulls its clock only when the operator | |
| 458 | 464 | // explicitly asked. | |
| 459 | - | crate::runs::advance_tier(&s.pool, target.name.as_str(), &version, build_id) | |
| 465 | + | crate::runs::advance_tier(&s.pool, &s.cfg.id, target.name.as_str(), &version, build_id) | |
| 460 | 466 | .await | |
| 461 | 467 | .map_err(crate::error::Error::Db)?; | |
| 462 | 468 | ||
| 463 | 469 | if body.reset_burn_in { | |
| 464 | - | sqlx::query("UPDATE tier_state SET burn_in_started_at = NULL WHERE tier = ?") | |
| 470 | + | sqlx::query("UPDATE tier_state SET burn_in_started_at = NULL WHERE app = ? AND tier = ?") | |
| 471 | + | .bind(&s.cfg.id) | |
| 465 | 472 | .bind(&source.name) | |
| 466 | 473 | .execute(&s.pool) | |
| 467 | 474 | .await | |
| @@ -553,7 +560,8 @@ | |||
| 553 | 560 | prev_version: &str, | |
| 554 | 561 | ) -> RollbackReport { | |
| 555 | 562 | let bin: Option<(String,)> = | |
| 556 | - | match sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?") | |
| 563 | + | match sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?") | |
| 564 | + | .bind(&s.cfg.id) | |
| 557 | 565 | .bind(prev_version) | |
| 558 | 566 | .fetch_optional(&s.pool) | |
| 559 | 567 | .await | |
| @@ -647,11 +655,13 @@ | |||
| 647 | 655 | /// the flag is logged, never propagated — the caller is already on an error path | |
| 648 | 656 | /// and the worse outcome is to mask the original failure with a bookkeeping one. | |
| 649 | 657 | pub(super) async fn set_partial(s: &AppState, tier: &crate::domain::TierId, reason: &str) { | |
| 650 | - | if let Err(e) = sqlx::query("UPDATE tier_state SET partial_reason = ? WHERE tier = ?") | |
| 651 | - | .bind(reason) | |
| 652 | - | .bind(tier) | |
| 653 | - | .execute(&s.pool) | |
| 654 | - | .await | |
| 658 | + | if let Err(e) = | |
| 659 | + | sqlx::query("UPDATE tier_state SET partial_reason = ? WHERE app = ? AND tier = ?") | |
| 660 | + | .bind(reason) | |
| 661 | + | .bind(&s.cfg.id) | |
| 662 | + | .bind(tier) | |
| 663 | + | .execute(&s.pool) | |
| 664 | + | .await | |
| 655 | 665 | { | |
| 656 | 666 | tracing::error!(tier = %tier, reason, error = %e, | |
| 657 | 667 | "failed to record tier partial state; the fleet may be inconsistent without a /state flag"); | |
| @@ -663,10 +673,12 @@ | |||
| 663 | 673 | /// visible nuisance, not a safety regression (the operator sees a partial marker | |
| 664 | 674 | /// on a tier that is actually fine, and re-checks). | |
| 665 | 675 | pub(super) async fn clear_partial(s: &AppState, tier: &crate::domain::TierId) { | |
| 666 | - | if let Err(e) = sqlx::query("UPDATE tier_state SET partial_reason = NULL WHERE tier = ?") | |
| 667 | - | .bind(tier) | |
| 668 | - | .execute(&s.pool) | |
| 669 | - | .await | |
| 676 | + | if let Err(e) = | |
| 677 | + | sqlx::query("UPDATE tier_state SET partial_reason = NULL WHERE app = ? AND tier = ?") | |
| 678 | + | .bind(&s.cfg.id) | |
| 679 | + | .bind(tier) | |
| 680 | + | .execute(&s.pool) | |
| 681 | + | .await | |
| 670 | 682 | { | |
| 671 | 683 | tracing::warn!(tier = %tier, error = %e, "failed to clear tier partial flag"); | |
| 672 | 684 | } | |
| @@ -697,6 +709,7 @@ | |||
| 697 | 709 | /// advance, so it belongs to the build now current on the tier. | |
| 698 | 710 | pub(super) async fn unsatisfied_gates( | |
| 699 | 711 | pool: &sqlx::SqlitePool, | |
| 712 | + | app: &crate::domain::AppId, | |
| 700 | 713 | tier: &crate::domain::TierId, | |
| 701 | 714 | gates: &[crate::topology::Gate], | |
| 702 | 715 | version: &str, | |
| @@ -712,7 +725,7 @@ | |||
| 712 | 725 | if hotfix { | |
| 713 | 726 | continue; | |
| 714 | 727 | } | |
| 715 | - | let ok = crate::gates::burn_in_satisfied(pool, tier, *hours) | |
| 728 | + | let ok = crate::gates::burn_in_satisfied(pool, app, tier, *hours) | |
| 716 | 729 | .await | |
| 717 | 730 | .map_err(crate::error::Error::Other)?; | |
| 718 | 731 | if !ok { | |
| @@ -729,22 +742,26 @@ | |||
| 729 | 742 | // No baseline (NULL) => fail closed: require a fresh confirm. | |
| 730 | 743 | let confirmed_at: Option<String> = sqlx::query_scalar( | |
| 731 | 744 | "SELECT finished_at FROM gate_runs | |
| 732 | - | WHERE tier = ?1 AND version = ?2 AND gate_kind = 'manual_confirm' AND status = 'passed' | |
| 745 | + | WHERE app = ?1 AND tier = ?2 AND version = ?3 | |
| 746 | + | AND gate_kind = 'manual_confirm' AND status = 'passed' | |
| 733 | 747 | ORDER BY id DESC LIMIT 1", | |
| 734 | 748 | ) | |
| 749 | + | .bind(app) | |
| 735 | 750 | .bind(tier.as_str()) | |
| 736 | 751 | .bind(version) | |
| 737 | 752 | .fetch_optional(pool) | |
| 738 | 753 | .await | |
| 739 | 754 | .map_err(crate::error::Error::Db)? | |
| 740 | 755 | .flatten(); | |
| 741 | - | let landed_at: Option<String> = | |
| 742 | - | sqlx::query_scalar("SELECT burn_in_started_at FROM tier_state WHERE tier = ?") | |
| 743 | - | .bind(tier.as_str()) | |
| 744 | - | .fetch_optional(pool) | |
| 745 | - | .await | |
| 746 | - | .map_err(crate::error::Error::Db)? | |
| 747 | - | .flatten(); | |
| 756 | + | let landed_at: Option<String> = sqlx::query_scalar( | |
| 757 | + | "SELECT burn_in_started_at FROM tier_state WHERE app = ? AND tier = ?", | |
| 758 | + | ) | |
| 759 | + | .bind(app) | |
| 760 | + | .bind(tier.as_str()) | |
| 761 | + | .fetch_optional(pool) | |
| 762 | + | .await | |
| 763 | + | .map_err(crate::error::Error::Db)? | |
| 764 | + | .flatten(); | |
| 748 | 765 | let fresh = match (confirmed_at, landed_at) { | |
| 749 | 766 | (Some(c), Some(l)) => { | |
| 750 | 767 | match ( | |
| @@ -784,17 +801,19 @@ | |||
| 784 | 801 | let status: Option<String> = match build_id { | |
| 785 | 802 | Some(bid) => sqlx::query_scalar( | |
| 786 | 803 | "SELECT status FROM gate_runs | |
| 787 | - | WHERE tier = ?1 AND build_id = ?2 AND gate_kind = ?3 | |
| 804 | + | WHERE app = ?1 AND tier = ?2 AND build_id = ?3 AND gate_kind = ?4 | |
| 788 | 805 | ORDER BY id DESC LIMIT 1", | |
| 789 | 806 | ) | |
| 807 | + | .bind(app) | |
| 790 | 808 | .bind(tier.as_str()) | |
| 791 | 809 | .bind(bid) | |
| 792 | 810 | .bind(kind.as_str()), | |
| 793 | 811 | None => sqlx::query_scalar( | |
| 794 | 812 | "SELECT status FROM gate_runs | |
| 795 | - | WHERE tier = ?1 AND version = ?2 AND gate_kind = ?3 | |
| 813 | + | WHERE app = ?1 AND tier = ?2 AND version = ?3 AND gate_kind = ?4 | |
| 796 | 814 | ORDER BY id DESC LIMIT 1", | |
| 797 | 815 | ) | |
| 816 | + | .bind(app) | |
| 798 | 817 | .bind(tier.as_str()) | |
| 799 | 818 | .bind(version) | |
| 800 | 819 | .bind(kind.as_str()), |