max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
13 files changed,
+617 insertions,
-23 deletions
| @@ -2265,21 +2265,37 @@ | |||
| 2265 | 2265 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2266 | 2266 | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" | |
| 2267 | 2267 | ||
| 2268 | + | [[patch.unused]] | |
| 2269 | + | name = "docengine" | |
| 2270 | + | version = "0.7.0" | |
| 2271 | + | ||
| 2272 | + | [[patch.unused]] | |
| 2273 | + | name = "quasi-axum" | |
| 2274 | + | version = "0.3.0" | |
| 2275 | + | ||
| 2268 | 2276 | [[patch.unused]] | |
| 2269 | 2277 | name = "quasi-http" | |
| 2270 | - | version = "0.1.0" | |
| 2278 | + | version = "0.3.0" | |
| 2279 | + | ||
| 2280 | + | [[patch.unused]] | |
| 2281 | + | name = "quasi-immediate" | |
| 2282 | + | version = "0.3.0" | |
| 2271 | 2283 | ||
| 2272 | 2284 | [[patch.unused]] | |
| 2273 | 2285 | name = "quasi-router" | |
| 2286 | + | version = "0.3.0" | |
| 2287 | + | ||
| 2288 | + | [[patch.unused]] | |
| 2289 | + | name = "quasi-store" | |
| 2274 | 2290 | version = "0.1.0" | |
| 2275 | 2291 | ||
| 2276 | 2292 | [[patch.unused]] | |
| 2277 | 2293 | name = "quasi-tauri" | |
| 2278 | - | version = "0.1.0" | |
| 2294 | + | version = "0.3.0" | |
| 2279 | 2295 | ||
| 2280 | 2296 | [[patch.unused]] | |
| 2281 | 2297 | name = "quasi-webview" | |
| 2282 | - | version = "0.1.0" | |
| 2298 | + | version = "0.3.0" | |
| 2283 | 2299 | ||
| 2284 | 2300 | [[patch.unused]] | |
| 2285 | 2301 | name = "kberg" | |
| @@ -2293,10 +2309,6 @@ | |||
| 2293 | 2309 | name = "tagtree" | |
| 2294 | 2310 | version = "0.4.0" | |
| 2295 | 2311 | ||
| 2296 | - | [[patch.unused]] | |
| 2297 | - | name = "docengine" | |
| 2298 | - | version = "0.4.0" | |
| 2299 | - | ||
| 2300 | 2312 | [[patch.unused]] | |
| 2301 | 2313 | name = "synckit-client" | |
| 2302 | 2314 | version = "0.8.0" |
| @@ -10688,12 +10688,16 @@ | |||
| 10688 | 10688 | ] | |
| 10689 | 10689 | ||
| 10690 | 10690 | [[patch.unused]] | |
| 10691 | - | name = "synckit-client" | |
| 10692 | - | version = "0.8.0" | |
| 10691 | + | name = "quasi-immediate" | |
| 10692 | + | version = "0.3.0" | |
| 10693 | 10693 | ||
| 10694 | 10694 | [[patch.unused]] | |
| 10695 | - | name = "synckit-config" | |
| 10696 | - | version = "0.2.0" | |
| 10695 | + | name = "quasi-store" | |
| 10696 | + | version = "0.1.0" | |
| 10697 | + | ||
| 10698 | + | [[patch.unused]] | |
| 10699 | + | name = "quasi-tauri" | |
| 10700 | + | version = "0.3.0" | |
| 10697 | 10701 | ||
| 10698 | 10702 | [[patch.unused]] | |
| 10699 | 10703 | name = "kberg" | |
| @@ -10708,13 +10712,9 @@ | |||
| 10708 | 10712 | version = "0.1.0" | |
| 10709 | 10713 | ||
| 10710 | 10714 | [[patch.unused]] | |
| 10711 | - | name = "quasi-immediate" | |
| 10712 | - | version = "0.3.0" | |
| 10715 | + | name = "synckit-client" | |
| 10716 | + | version = "0.8.0" | |
| 10713 | 10717 | ||
| 10714 | 10718 | [[patch.unused]] | |
| 10715 | - | name = "quasi-store" | |
| 10716 | - | version = "0.1.0" | |
| 10717 | - | ||
| 10718 | - | [[patch.unused]] | |
| 10719 | - | name = "quasi-tauri" | |
| 10720 | - | version = "0.3.0" | |
| 10719 | + | name = "synckit-config" | |
| 10720 | + | version = "0.2.0" |
| @@ -744,6 +744,7 @@ | |||
| 744 | 744 | build_id: Some(run_id.0), | |
| 745 | 745 | // Where checkout_aux_repos put each aux repo, so a test_target naming | |
| 746 | 746 | // one resolves. Shared derivation, so the two cannot disagree. | |
| 747 | + | public_url: None, | |
| 747 | 748 | aux_dirs: aux_checkout_dirs(&cfg, &topo), | |
| 748 | 749 | }; | |
| 749 | 750 | let failed = gates::run_all(&ctx, &host.gates).await?; | |
| @@ -953,6 +954,7 @@ | |||
| 953 | 954 | .unwrap(); | |
| 954 | 955 | ||
| 955 | 956 | let cfg = AppConfig { | |
| 957 | + | page_smoke_cmd: None, | |
| 956 | 958 | platform: None, | |
| 957 | 959 | id: crate::domain::AppId::default(), | |
| 958 | 960 | topology_path: PathBuf::from("/tmp/test-sando.toml"), | |
| @@ -993,6 +995,7 @@ | |||
| 993 | 995 | local_path: "/tmp/local-backup.sql".into(), | |
| 994 | 996 | }], | |
| 995 | 997 | tiers: vec![Tier { | |
| 998 | + | public_url: None, | |
| 996 | 999 | name: "host".into(), | |
| 997 | 1000 | provisioned: true, | |
| 998 | 1001 | gates, | |
| @@ -1174,6 +1177,7 @@ | |||
| 1174 | 1177 | events: crate::events::channel(), | |
| 1175 | 1178 | nodes: Vec::new(), | |
| 1176 | 1179 | build_id: Some(run_id.0), | |
| 1180 | + | public_url: None, | |
| 1177 | 1181 | aux_dirs: std::collections::HashMap::default(), | |
| 1178 | 1182 | }; | |
| 1179 | 1183 | let outcome = ctx | |
| @@ -1218,6 +1222,7 @@ | |||
| 1218 | 1222 | events: crate::events::channel(), | |
| 1219 | 1223 | nodes: Vec::new(), | |
| 1220 | 1224 | build_id: Some(run_id.0), | |
| 1225 | + | public_url: None, | |
| 1221 | 1226 | aux_dirs: std::collections::HashMap::default(), | |
| 1222 | 1227 | }; | |
| 1223 | 1228 | assert_eq!( | |
| @@ -1269,6 +1274,7 @@ | |||
| 1269 | 1274 | /// A minimal `Config` whose only field this test path reads is `workdir`. | |
| 1270 | 1275 | fn cfg_with_workdir(workdir: PathBuf) -> AppConfig { | |
| 1271 | 1276 | AppConfig { | |
| 1277 | + | page_smoke_cmd: None, | |
| 1272 | 1278 | platform: None, | |
| 1273 | 1279 | id: crate::domain::AppId::default(), | |
| 1274 | 1280 | topology_path: PathBuf::from("/tmp/test-sando.toml"), | |
| @@ -1353,6 +1359,7 @@ | |||
| 1353 | 1359 | events: crate::events::channel(), | |
| 1354 | 1360 | nodes: Vec::new(), | |
| 1355 | 1361 | build_id: None, | |
| 1362 | + | public_url: None, | |
| 1356 | 1363 | aux_dirs: super::aux_checkout_dirs(&cfg, &topo), | |
| 1357 | 1364 | }; | |
| 1358 | 1365 | let target = crate::config::TestTarget { |
| @@ -58,6 +58,21 @@ | |||
| 58 | 58 | /// One product's pipeline: what to build, what to prove about it, where it goes. | |
| 59 | 59 | #[derive(Debug, Clone, Deserialize)] | |
| 60 | 60 | pub struct AppConfig { | |
| 61 | + | /// Command the `page_smoke` gate runs, when the product has one. | |
| 62 | + | /// | |
| 63 | + | /// A shell command, run on the daemon host with `BASE` set to the tier's | |
| 64 | + | /// `public_url`. Red on a non-zero exit; its stdout becomes the gate log. | |
| 65 | + | /// | |
| 66 | + | /// Optional because a page smoke is a *web product's* gate. pom is a | |
| 67 | + | /// service with no pages, so leaving this unset is how it says so, and the | |
| 68 | + | /// gate reports blocked rather than inventing a pass. | |
| 69 | + | /// | |
| 70 | + | /// Not derived from the worktree: post-deploy gates have no checkout (the | |
| 71 | + | /// artifact may have been built elsewhere entirely), so the script has to | |
| 72 | + | /// live somewhere stable on the host and be named here. | |
| 73 | + | #[serde(default)] | |
| 74 | + | pub page_smoke_cmd: Option<String>, | |
| 75 | + | ||
| 61 | 76 | /// Which product this is. Not read from the file: it is the key the daemon | |
| 62 | 77 | /// filed this config under, so a config cannot disagree with its own name. | |
| 63 | 78 | #[serde(skip)] | |
| @@ -635,6 +650,7 @@ | |||
| 635 | 650 | #[cfg(test)] | |
| 636 | 651 | pub fn for_tests() -> Self { | |
| 637 | 652 | Self { | |
| 653 | + | page_smoke_cmd: None, | |
| 638 | 654 | platform: None, | |
| 639 | 655 | id: crate::domain::AppId::default(), | |
| 640 | 656 | topology_path: PathBuf::from("/tmp/sando-test-topology.toml"), |
| @@ -472,6 +472,35 @@ | |||
| 472 | 472 | /// service over its executor — the gate that actually proves a tier's nodes | |
| 473 | 473 | /// are serving before the next promote. | |
| 474 | 474 | NodeHealth, | |
| 475 | + | /// Post-deploy, and the only gate that reads the site the way a visitor | |
| 476 | + | /// receives it: a real browser, over the tier's **public hostname**, with | |
| 477 | + | /// the CDN in front. | |
| 478 | + | /// | |
| 479 | + | /// Every other gate here is build-host or origin-side. `BootSmoke` runs on | |
| 480 | + | /// fw13 and proves nothing about a node; `NodeHealth` reaches the node over | |
| 481 | + | /// its executor and so never crosses the edge. That left a whole layer | |
| 482 | + | /// unwatched, and on 2026-08-14 it shipped: testnot served a landing page | |
| 483 | + | /// whose JavaScript did not run for hours behind nine green gates. A stale | |
| 484 | + | /// `dispatch.js` held at the CDN failed to link against a freshly deployed | |
| 485 | + | /// `index.js`, and since `core/index.ts` side-effect-imports every common | |
| 486 | + | /// island, one bad link killed all of them plus the legacy page scripts | |
| 487 | + | /// that read globals it publishes. | |
| 488 | + | /// | |
| 489 | + | /// Nothing could have caught it earlier. Each artifact was individually | |
| 490 | + | /// correct — right markup, right stylesheets, every module answering 200 | |
| 491 | + | /// with current bytes. Only the composition was broken, and composition is | |
| 492 | + | /// observable in a browser and nowhere else. | |
| 493 | + | /// | |
| 494 | + | /// Progressive enhancement is what makes this a gate rather than a nicety. | |
| 495 | + | /// Every island enhances server-rendered markup that stands on its own, so | |
| 496 | + | /// a dead bundle renders the unenhanced page — a state the design | |
| 497 | + | /// deliberately supports. "Broken" and "working as intended" are the same | |
| 498 | + | /// screenshot, and the difference is only visible as behaviour. | |
| 499 | + | /// | |
| 500 | + | /// Runs `scripts/page-smoke.mjs` with `BASE` set to the tier's public URL. | |
| 501 | + | /// Red on any uncaught exception, any console error, any island that did | |
| 502 | + | /// not run, or any failed page expectation. | |
| 503 | + | PageSmoke, | |
| 475 | 504 | BurnIn, | |
| 476 | 505 | ManualConfirm, | |
| 477 | 506 | } | |
| @@ -489,6 +518,7 @@ | |||
| 489 | 518 | GateKind::CodeSmoke => "code_smoke", | |
| 490 | 519 | GateKind::BootSmoke => "boot_smoke", | |
| 491 | 520 | GateKind::NodeHealth => "node_health", | |
| 521 | + | GateKind::PageSmoke => "page_smoke", | |
| 492 | 522 | GateKind::BurnIn => "burn_in", | |
| 493 | 523 | GateKind::ManualConfirm => "manual_confirm", | |
| 494 | 524 | } | |
| @@ -513,6 +543,7 @@ | |||
| 513 | 543 | "code_smoke" => Ok(GateKind::CodeSmoke), | |
| 514 | 544 | "boot_smoke" => Ok(GateKind::BootSmoke), | |
| 515 | 545 | "node_health" => Ok(GateKind::NodeHealth), | |
| 546 | + | "page_smoke" => Ok(GateKind::PageSmoke), | |
| 516 | 547 | "burn_in" => Ok(GateKind::BurnIn), | |
| 517 | 548 | "manual_confirm" => Ok(GateKind::ManualConfirm), | |
| 518 | 549 | other => Err(GateKindParseError(other.to_owned())), |
| @@ -73,6 +73,11 @@ | |||
| 73 | 73 | /// topology at build time; empty at promote time, where the only gate that | |
| 74 | 74 | /// runs is `node_health` and there is no checkout at all. | |
| 75 | 75 | pub aux_dirs: HashMap<String, PathBuf>, | |
| 76 | + | /// The tier's public URL, for [`Gate::PageSmoke`]. `None` on every | |
| 77 | + | /// build-time run and on any tier that declares none. | |
| 78 | + | /// | |
| 79 | + | /// [`Gate::PageSmoke`]: crate::topology::Gate::PageSmoke | |
| 80 | + | pub public_url: Option<String>, | |
| 76 | 81 | } | |
| 77 | 82 | ||
| 78 | 83 | impl GateCtx { | |
| @@ -219,6 +224,7 @@ | |||
| 219 | 224 | } | |
| 220 | 225 | Gate::BootSmoke => boot_smoke(ctx, run_id).await, | |
| 221 | 226 | Gate::NodeHealth => node_health(ctx).await, | |
| 227 | + | Gate::PageSmoke => page_smoke(ctx).await, | |
| 222 | 228 | Gate::BurnIn { hours } => burn_in(ctx, *hours).await, | |
| 223 | 229 | Gate::ManualConfirm => manual_confirm(ctx).await, | |
| 224 | 230 | }; | |
| @@ -2112,6 +2118,93 @@ | |||
| 2112 | 2118 | /// a node that took a corrupt artifact, wrong-arch binary, or failed restart is | |
| 2113 | 2119 | /// caught here rather than waved through (Run-2 SERIOUS-3). Fails closed: any | |
| 2114 | 2120 | /// unhealthy node fails the gate, and an empty node set is `Blocked`. | |
| 2121 | + | /// Load the tier's public pages in a real browser and fail if the JavaScript | |
| 2122 | + | /// did not run. | |
| 2123 | + | /// | |
| 2124 | + | /// The only gate here that crosses the CDN. `boot_smoke` runs on the build host | |
| 2125 | + | /// and `node_health` reaches a node over its executor, so between them nothing | |
| 2126 | + | /// ever requested the site the way a visitor does. On 2026-08-14 that gap | |
| 2127 | + | /// shipped: testnot served a landing page whose JavaScript did not run, for | |
| 2128 | + | /// hours, behind nine green gates. Cloudflare was holding a module from an | |
| 2129 | + | /// earlier deploy; it failed to link against the fresh one beside it, and since | |
| 2130 | + | /// the bundle's entry point side-effect-imports every island, one bad link took | |
| 2131 | + | /// all of them down together. | |
| 2132 | + | /// | |
| 2133 | + | /// It could not have been caught earlier. Each artifact was individually | |
| 2134 | + | /// correct -- right markup, right stylesheets, every module answering 200 with | |
| 2135 | + | /// current bytes. Only their composition was broken, and composition is | |
| 2136 | + | /// observable in a browser and nowhere else. | |
| 2137 | + | /// | |
| 2138 | + | /// Runs on the daemon host rather than on a node, because it is a *client*: it | |
| 2139 | + | /// should reach the site through whatever the public reaches it through, and a | |
| 2140 | + | /// probe that ran on the origin would inherit the blind spot this exists to | |
| 2141 | + | /// close. | |
| 2142 | + | async fn page_smoke(ctx: &GateCtx) -> Result<GateOutcome> { | |
| 2143 | + | let Some(cmd) = ctx.cfg.page_smoke_cmd.as_deref() else { | |
| 2144 | + | // A service with no pages says so by configuring no command. Blocked | |
| 2145 | + | // rather than passed: a gate that proves nothing must not read green. | |
| 2146 | + | return Ok(GateOutcome::blocked(GateBlocker::NotConfigured { | |
| 2147 | + | what: "page_smoke_cmd".into(), | |
| 2148 | + | })); | |
| 2149 | + | }; | |
| 2150 | + | let Some(base) = ctx.public_url.as_deref() else { | |
| 2151 | + | return Ok(GateOutcome::blocked(GateBlocker::NotConfigured { | |
| 2152 | + | what: "public_url".into(), | |
| 2153 | + | })); | |
| 2154 | + | }; | |
| 2155 | + | ||
| 2156 | + | let ceiling = std::time::Duration::from_secs(ctx.cfg.gate_timeout_secs); | |
| 2157 | + | let mut child = tokio::process::Command::new("sh") | |
| 2158 | + | .arg("-c") | |
| 2159 | + | .arg(cmd) | |
| 2160 | + | .env("BASE", base) | |
| 2161 | + | .stdout(std::process::Stdio::piped()) | |
| 2162 | + | .stderr(std::process::Stdio::piped()) | |
| 2163 | + | .kill_on_drop(true) | |
| 2164 | + | .spawn()?; | |
| 2165 | + | ||
| 2166 | + | let out = match tokio::time::timeout(ceiling, child.wait_with_output()).await { | |
| 2167 | + | Ok(res) => res?, | |
| 2168 | + | Err(_elapsed) => { | |
| 2169 | + | return Ok(GateOutcome::failed(GateFailure::Timeout { | |
| 2170 | + | gate: GateKind::PageSmoke, | |
| 2171 | + | after_s: ctx.cfg.gate_timeout_secs as u32, | |
| 2172 | + | }) | |
| 2173 | + | .with_log_ref(LogRef::new(&ctx.version, GateKind::PageSmoke))); | |
| 2174 | + | } | |
| 2175 | + | }; | |
| 2176 | + | ||
| 2177 | + | let log = format!( | |
| 2178 | + | "{}{}", | |
| 2179 | + | String::from_utf8_lossy(&out.stdout), | |
| 2180 | + | String::from_utf8_lossy(&out.stderr) | |
| 2181 | + | ); | |
| 2182 | + | append_to_log(&gate_log_path(ctx, GateKind::PageSmoke), log.as_bytes()).await; | |
| 2183 | + | ||
| 2184 | + | if out.status.success() { | |
| 2185 | + | return Ok( | |
| 2186 | + | GateOutcome::passed(PassNote::PagesClean { base: base.into() }) | |
| 2187 | + | .with_log_ref(LogRef::new(&ctx.version, GateKind::PageSmoke)), | |
| 2188 | + | ); | |
| 2189 | + | } | |
| 2190 | + | ||
| 2191 | + | // The script prints one `FAIL <url>` line per bad page and indents the | |
| 2192 | + | // reasons under it. Lift the first reason into the summary so a red gate | |
| 2193 | + | // says what broke without anyone opening the log. | |
| 2194 | + | let first = log | |
| 2195 | + | .lines() | |
| 2196 | + | .skip_while(|l| !l.starts_with("FAIL")) | |
| 2197 | + | .nth(1) | |
| 2198 | + | .map(str::trim) | |
| 2199 | + | .filter(|l| !l.is_empty()) | |
| 2200 | + | .unwrap_or("see log"); | |
| 2201 | + | Ok(GateOutcome::failed(GateFailure::PagesBroken { | |
| 2202 | + | base: base.into(), | |
| 2203 | + | detail: first.to_string(), | |
| 2204 | + | }) | |
| 2205 | + | .with_log_ref(LogRef::new(&ctx.version, GateKind::PageSmoke))) | |
| 2206 | + | } | |
| 2207 | + | ||
| 2115 | 2208 | async fn node_health(ctx: &GateCtx) -> Result<GateOutcome> { | |
| 2116 | 2209 | if ctx.nodes.is_empty() { | |
| 2117 | 2210 | return Ok(GateOutcome::blocked(GateBlocker::NoNodesToProbe)); | |
| @@ -2531,6 +2624,7 @@ | |||
| 2531 | 2624 | ||
| 2532 | 2625 | fn resolving_ctx(worktree: &str, aux: &[(&str, &str)]) -> GateCtx { | |
| 2533 | 2626 | GateCtx { | |
| 2627 | + | public_url: None, | |
| 2534 | 2628 | pool: SqlitePool::connect_lazy("sqlite::memory:").unwrap(), | |
| 2535 | 2629 | cfg: std::sync::Arc::new(crate::config::AppConfig::for_tests()), | |
| 2536 | 2630 | tier: TierId::new("host"), | |
| @@ -2605,6 +2699,7 @@ | |||
| 2605 | 2699 | .collect(); | |
| 2606 | 2700 | cfg.logs_root = worktree.join("logs"); | |
| 2607 | 2701 | GateCtx { | |
| 2702 | + | public_url: None, | |
| 2608 | 2703 | pool: SqlitePoolOptions::new() | |
| 2609 | 2704 | .max_connections(1) | |
| 2610 | 2705 | .connect("sqlite::memory:") | |
| @@ -2638,6 +2733,7 @@ | |||
| 2638 | 2733 | .unwrap(); | |
| 2639 | 2734 | crate::db::migrate(&pool).await.unwrap(); | |
| 2640 | 2735 | GateCtx { | |
| 2736 | + | public_url: None, | |
| 2641 | 2737 | pool, | |
| 2642 | 2738 | cfg: std::sync::Arc::new(cfg), | |
| 2643 | 2739 | tier: TierId::new("host"), | |
| @@ -2965,6 +3061,7 @@ | |||
| 2965 | 3061 | .await | |
| 2966 | 3062 | .unwrap(); | |
| 2967 | 3063 | let ctx = GateCtx { | |
| 3064 | + | public_url: None, | |
| 2968 | 3065 | pool, | |
| 2969 | 3066 | cfg: std::sync::Arc::new(cfg), | |
| 2970 | 3067 | tier: TierId::new("host"), | |
| @@ -2999,6 +3096,7 @@ | |||
| 2999 | 3096 | let mut cfg = crate::config::AppConfig::for_tests(); | |
| 3000 | 3097 | cfg.scratch_db_url = Some("postgres://sando@127.0.0.1/sando_scratch".into()); | |
| 3001 | 3098 | let ctx = GateCtx { | |
| 3099 | + | public_url: None, | |
| 3002 | 3100 | pool: SqlitePoolOptions::new() | |
| 3003 | 3101 | .max_connections(1) | |
| 3004 | 3102 | .connect_lazy("sqlite::memory:") | |
| @@ -3034,6 +3132,7 @@ | |||
| 3034 | 3132 | #[tokio::test] | |
| 3035 | 3133 | async fn all_features_replaces_the_feature_list() { | |
| 3036 | 3134 | let ctx = GateCtx { | |
| 3135 | + | public_url: None, | |
| 3037 | 3136 | pool: SqlitePoolOptions::new() | |
| 3038 | 3137 | .max_connections(1) | |
| 3039 | 3138 | .connect_lazy("sqlite::memory:") | |
| @@ -3123,6 +3222,7 @@ | |||
| 3123 | 3222 | cfg.test_targets = vec![target("server")]; | |
| 3124 | 3223 | cfg.logs_root = tmp.path().join("logs"); | |
| 3125 | 3224 | let ctx = GateCtx { | |
| 3225 | + | public_url: None, | |
| 3126 | 3226 | pool: SqlitePoolOptions::new() | |
| 3127 | 3227 | .max_connections(1) | |
| 3128 | 3228 | .connect("sqlite::memory:") | |
| @@ -3201,6 +3301,7 @@ | |||
| 3201 | 3301 | // The entire point of the gate: production constants, which means no | |
| 3202 | 3302 | // `fast-tests`. A stray feature here silently restores the blind spot. | |
| 3203 | 3303 | let ctx = GateCtx { | |
| 3304 | + | public_url: None, | |
| 3204 | 3305 | pool: SqlitePoolOptions::new() | |
| 3205 | 3306 | .max_connections(1) | |
| 3206 | 3307 | .connect_lazy("sqlite::memory:") | |
| @@ -3340,6 +3441,7 @@ | |||
| 3340 | 3441 | ||
| 3341 | 3442 | let cfg = std::sync::Arc::new(crate::config::AppConfig::for_tests()); | |
| 3342 | 3443 | let ctx = GateCtx { | |
| 3444 | + | public_url: None, | |
| 3343 | 3445 | pool: pool.clone(), | |
| 3344 | 3446 | cfg, | |
| 3345 | 3447 | tier: TierId::new("host"), | |
| @@ -3396,6 +3498,7 @@ | |||
| 3396 | 3498 | ||
| 3397 | 3499 | let cfg = std::sync::Arc::new(crate::config::AppConfig::for_tests()); | |
| 3398 | 3500 | let ctx = GateCtx { | |
| 3501 | + | public_url: None, | |
| 3399 | 3502 | pool: pool.clone(), | |
| 3400 | 3503 | cfg, | |
| 3401 | 3504 | tier: TierId::new("b"), | |
| @@ -3763,6 +3866,7 @@ | |||
| 3763 | 3866 | ||
| 3764 | 3867 | let cfg = std::sync::Arc::new(crate::config::AppConfig::for_tests()); // scratch_db_url: None | |
| 3765 | 3868 | let ctx = GateCtx { | |
| 3869 | + | public_url: None, | |
| 3766 | 3870 | pool: pool.clone(), | |
| 3767 | 3871 | cfg, | |
| 3768 | 3872 | tier: TierId::new("host"), |
| @@ -114,6 +114,8 @@ | |||
| 114 | 114 | /// `health_url` is configured) served its readiness probe. `nodes` is how | |
| 115 | 115 | /// many nodes were verified. | |
| 116 | 116 | NodesHealthy { nodes: u32 }, | |
| 117 | + | /// Every checked page loaded with no exception and every island ran. | |
| 118 | + | PagesClean { base: String }, | |
| 117 | 119 | /// Legacy rows backfilled from the pre-typed schema. Carries the | |
| 118 | 120 | /// original `detail` string so nothing is lost. | |
| 119 | 121 | Legacy { text: String }, | |
| @@ -136,6 +138,7 @@ | |||
| 136 | 138 | PassNote::TestsPassed { duration_s } => format!("tests passed in {duration_s}s"), | |
| 137 | 139 | PassNote::OperatorConfirmed { at } => format!("operator confirmed at {at}"), | |
| 138 | 140 | PassNote::NodesHealthy { nodes } => format!("{nodes} node(s) healthy"), | |
| 141 | + | PassNote::PagesClean { base } => format!("pages clean at {base}"), | |
| 139 | 142 | PassNote::Legacy { text } => text.clone(), | |
| 140 | 143 | } | |
| 141 | 144 | } | |
| @@ -181,6 +184,8 @@ | |||
| 181 | 184 | /// `node_health`: the gate ran with no nodes to probe (a serving tier should | |
| 182 | 185 | /// always have nodes; this fails closed so a misconfigured tier can't pass). | |
| 183 | 186 | NoNodesToProbe, | |
| 187 | + | /// A gate needs a config value the product did not set. | |
| 188 | + | NotConfigured { what: String }, | |
| 184 | 189 | } | |
| 185 | 190 | ||
| 186 | 191 | impl GateBlocker { | |
| @@ -220,6 +225,7 @@ | |||
| 220 | 225 | format!("no artifact for version {version}") | |
| 221 | 226 | } | |
| 222 | 227 | GateBlocker::NoNodesToProbe => "node_health has no nodes to probe".into(), | |
| 228 | + | GateBlocker::NotConfigured { what } => format!("{what} is not configured"), | |
| 223 | 229 | } | |
| 224 | 230 | } | |
| 225 | 231 | } | |
| @@ -276,6 +282,8 @@ | |||
| 276 | 282 | /// its `health_url`, within the probe window. `node` is the failing node; | |
| 277 | 283 | /// `detail` carries the probe's stderr/reason. | |
| 278 | 284 | NodeUnhealthy { node: String, detail: String }, | |
| 285 | + | /// A page loaded but its JavaScript did not run, or an expectation failed. | |
| 286 | + | PagesBroken { base: String, detail: String }, | |
| 279 | 287 | /// `code_smoke`: creating or dropping the throwaway smoke DB failed (a | |
| 280 | 288 | /// postgres/cluster problem, not the built code). `reason` carries the DB | |
| 281 | 289 | /// error. Kept distinct from the boot/seed failures so an environment issue | |
| @@ -376,6 +384,9 @@ | |||
| 376 | 384 | GateFailure::NodeUnhealthy { node, detail } => { | |
| 377 | 385 | format!("node {node} unhealthy: {detail}") | |
| 378 | 386 | } | |
| 387 | + | GateFailure::PagesBroken { base, detail } => { | |
| 388 | + | format!("page smoke failed at {base}: {detail}") | |
| 389 | + | } | |
| 379 | 390 | GateFailure::CodeSmokeSetup { reason } => format!("code smoke db setup: {reason}"), | |
| 380 | 391 | GateFailure::CodeSmokeSeed { exit_code: Some(c) } => { | |
| 381 | 392 | format!("migrate+seed run failed: exit {c}") |
| @@ -169,6 +169,7 @@ | |||
| 169 | 169 | ||
| 170 | 170 | fn tier(name: &str, provisioned: bool, nodes: Vec<Node>) -> Tier { | |
| 171 | 171 | Tier { | |
| 172 | + | public_url: None, | |
| 172 | 173 | name: name.into(), | |
| 173 | 174 | provisioned, | |
| 174 | 175 | gates: vec![Gate::BootSmoke], |
| @@ -132,6 +132,16 @@ | |||
| 132 | 132 | pub canary: CanaryPolicy, | |
| 133 | 133 | #[serde(default, rename = "node")] | |
| 134 | 134 | pub nodes: Vec<Node>, | |
| 135 | + | /// Where the public reaches this tier, CDN included. | |
| 136 | + | /// | |
| 137 | + | /// Deliberately not derivable from a node's `ssh_target`. The whole value of | |
| 138 | + | /// [`Gate::PageSmoke`] is that it goes the way a visitor goes -- through | |
| 139 | + | /// Cloudflare, against the hostname in the browser's address bar -- and a | |
| 140 | + | /// URL computed from the node would reach the origin and inherit exactly the | |
| 141 | + | /// blind spot the gate exists to close. So it is stated, or the gate is not | |
| 142 | + | /// available. | |
| 143 | + | #[serde(default)] | |
| 144 | + | pub public_url: Option<String>, | |
| 135 | 145 | } | |
| 136 | 146 | ||
| 137 | 147 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| @@ -246,7 +256,20 @@ | |||
| 246 | 256 | CodeSmoke, | |
| 247 | 257 | BootSmoke, | |
| 248 | 258 | NodeHealth, | |
| 249 | - | BurnIn { hours: u32 }, | |
| 259 | + | /// Post-deploy, in a real browser, over the tier's public URL. | |
| 260 | + | /// | |
| 261 | + | /// The one gate that crosses the CDN. `boot_smoke` runs on the build host | |
| 262 | + | /// and `node_health` reaches a node over its executor, so between them the | |
| 263 | + | /// edge was never watched -- and on 2026-08-14 testnot served a page whose | |
| 264 | + | /// JavaScript did not run for hours behind nine green gates, because the | |
| 265 | + | /// CDN held a stale module that failed to link against a fresh one. | |
| 266 | + | /// | |
| 267 | + | /// Needs `public_url` on the tier. A tier without one cannot run it, which | |
| 268 | + | /// `validate` refuses at load rather than at promote time. | |
| 269 | + | PageSmoke, | |
| 270 | + | BurnIn { | |
| 271 | + | hours: u32, | |
| 272 | + | }, | |
| 250 | 273 | ManualConfirm, | |
| 251 | 274 | } | |
| 252 | 275 | ||
| @@ -266,6 +289,7 @@ | |||
| 266 | 289 | Gate::CodeSmoke => GateKind::CodeSmoke, | |
| 267 | 290 | Gate::BootSmoke => GateKind::BootSmoke, | |
| 268 | 291 | Gate::NodeHealth => GateKind::NodeHealth, | |
| 292 | + | Gate::PageSmoke => GateKind::PageSmoke, | |
| 269 | 293 | Gate::BurnIn { .. } => GateKind::BurnIn, | |
| 270 | 294 | Gate::ManualConfirm => GateKind::ManualConfirm, | |
| 271 | 295 | } | |
| @@ -280,7 +304,7 @@ | |||
| 280 | 304 | /// boot_smoke used to re-run locally at promote time and the next promote | |
| 281 | 305 | /// trusted a binary that never touched the node). | |
| 282 | 306 | pub fn runs_post_deploy(&self) -> bool { | |
| 283 | - | matches!(self, Gate::NodeHealth) | |
| 307 | + | matches!(self, Gate::NodeHealth | Gate::PageSmoke) | |
| 284 | 308 | } | |
| 285 | 309 | ||
| 286 | 310 | /// Gates evaluated at promote time against the deployed nodes or the | |
| @@ -294,7 +318,7 @@ | |||
| 294 | 318 | pub fn guards_promotion(&self) -> bool { | |
| 295 | 319 | matches!( | |
| 296 | 320 | self, | |
| 297 | - | Gate::NodeHealth | Gate::BurnIn { .. } | Gate::ManualConfirm | |
| 321 | + | Gate::NodeHealth | Gate::PageSmoke | Gate::BurnIn { .. } | Gate::ManualConfirm | |
| 298 | 322 | ) | |
| 299 | 323 | } | |
| 300 | 324 | } | |
| @@ -475,6 +499,20 @@ | |||
| 475 | 499 | "a tier configures migration_dry_run but the topology declares no [backup]; \ | |
| 476 | 500 | the gate would have nothing to restore" | |
| 477 | 501 | ); | |
| 502 | + | // page_smoke has one input and it is not optional. Caught here rather | |
| 503 | + | // than at promote time, because a gate that cannot run is a gate that | |
| 504 | + | // would be discovered red halfway through a deploy, which is the worst | |
| 505 | + | // moment to learn a URL is missing. | |
| 506 | + | for t in &self.tiers { | |
| 507 | + | let smokes = t.gates.iter().any(|g| g.kind() == GateKind::PageSmoke); | |
| 508 | + | anyhow::ensure!( | |
| 509 | + | !smokes || t.public_url.is_some(), | |
| 510 | + | "tier {} configures page_smoke but declares no public_url; the gate has to \ | |
| 511 | + | request the site the way a visitor does, and a URL derived from a node would \ | |
| 512 | + | reach the origin and miss the CDN it exists to watch", | |
| 513 | + | t.name, | |
| 514 | + | ); | |
| 515 | + | } | |
| 478 | 516 | for (i, b) in self.backup.iter().enumerate() { | |
| 479 | 517 | anyhow::ensure!( | |
| 480 | 518 | !b.name.is_empty() | |
| @@ -603,6 +641,52 @@ | |||
| 603 | 641 | toml::from_str(&raw).expect("parse test topology") | |
| 604 | 642 | } | |
| 605 | 643 | ||
| 644 | + | #[test] | |
| 645 | + | fn page_smoke_without_a_public_url_is_rejected_at_load() { | |
| 646 | + | // The gate's whole value is that it requests the site the way a visitor | |
| 647 | + | // does. Without a URL it cannot, and a gate that cannot run must not be | |
| 648 | + | // discovered halfway through a promote. | |
| 649 | + | let topo = topo_with_serving_gates(true, r#"{ kind = "page_smoke" }"#); | |
| 650 | + | let err = topo.validate_for_test().expect_err("must refuse"); | |
| 651 | + | assert!( | |
| 652 | + | err.to_string().contains("public_url"), | |
| 653 | + | "error should name the missing field: {err}" | |
| 654 | + | ); | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | #[test] | |
| 658 | + | fn page_smoke_with_a_public_url_loads() { | |
| 659 | + | let raw = r#" | |
| 660 | + | [repo] | |
| 661 | + | bare_path = "/tmp/repo.git" | |
| 662 | + | branch = "main" | |
| 663 | + | ||
| 664 | + | [backup] | |
| 665 | + | source = "ssh://prod/dump.sql.gz" | |
| 666 | + | local_path = "/tmp/dump.sql.gz" | |
| 667 | + | ||
| 668 | + | [[tier]] | |
| 669 | + | name = "a" | |
| 670 | + | provisioned = true | |
| 671 | + | public_url = "https://testnot.work" | |
| 672 | + | gates = [{ kind = "page_smoke" }] | |
| 673 | + | [[tier.node]] | |
| 674 | + | name = "testnot-1" | |
| 675 | + | ssh_target = "testnot-1" | |
| 676 | + | release_root = "/srv/mnw" | |
| 677 | + | "#; | |
| 678 | + | let topo: Topology = toml::from_str(raw).expect("parse"); | |
| 679 | + | topo.validate_for_test().expect("valid"); | |
| 680 | + | assert_eq!( | |
| 681 | + | topo.tiers[0].public_url.as_deref(), | |
| 682 | + | Some("https://testnot.work") | |
| 683 | + | ); | |
| 684 | + | // It guards promotion out of its tier, and it runs after the deploy | |
| 685 | + | // rather than on the build host -- both are the point of it. | |
| 686 | + | assert!(topo.tiers[0].gates[0].guards_promotion()); | |
| 687 | + | assert!(topo.tiers[0].gates[0].runs_post_deploy()); | |
| 688 | + | } | |
| 689 | + | ||
| 606 | 690 | #[test] | |
| 607 | 691 | fn provisioned_serving_tier_with_no_gates_is_rejected() { | |
| 608 | 692 | let topo = topo_with_serving_gates(true, ""); |
| @@ -1127,6 +1127,7 @@ | |||
| 1127 | 1127 | }], | |
| 1128 | 1128 | tiers: vec![ | |
| 1129 | 1129 | Tier { | |
| 1130 | + | public_url: None, | |
| 1130 | 1131 | name: "host".into(), | |
| 1131 | 1132 | provisioned: true, | |
| 1132 | 1133 | gates: vec![], | |
| @@ -1134,6 +1135,7 @@ | |||
| 1134 | 1135 | nodes: vec![], | |
| 1135 | 1136 | }, | |
| 1136 | 1137 | Tier { | |
| 1138 | + | public_url: None, | |
| 1137 | 1139 | name: "a".into(), | |
| 1138 | 1140 | provisioned: true, | |
| 1139 | 1141 | gates: vec![Gate::BootSmoke], | |
| @@ -1158,6 +1160,7 @@ | |||
| 1158 | 1160 | ||
| 1159 | 1161 | fn test_cfg() -> AppConfig { | |
| 1160 | 1162 | AppConfig { | |
| 1163 | + | page_smoke_cmd: None, | |
| 1161 | 1164 | platform: None, | |
| 1162 | 1165 | id: crate::domain::AppId::default(), | |
| 1163 | 1166 | topology_path: PathBuf::from("/tmp/test-sando.toml"), | |
| @@ -1219,6 +1222,7 @@ | |||
| 1219 | 1222 | let mnw_topo = Arc::new(test_topo()); | |
| 1220 | 1223 | let mut pom_topo = test_topo(); | |
| 1221 | 1224 | pom_topo.tiers = vec![crate::topology::Tier { | |
| 1225 | + | public_url: None, | |
| 1222 | 1226 | name: "pom-host".into(), | |
| 1223 | 1227 | provisioned: true, | |
| 1224 | 1228 | gates: vec![], |
| @@ -470,6 +470,11 @@ | |||
| 470 | 470 | // tier -> the following one) resolves through, so they must carry the | |
| 471 | 471 | // build they vouch for. | |
| 472 | 472 | build_id, | |
| 473 | + | // The hostname the public uses, straight from the tier. page_smoke | |
| 474 | + | // has to request the site the way a visitor does; anything derived | |
| 475 | + | // from a node would reach the origin and miss the CDN, which is the | |
| 476 | + | // layer the gate exists to watch. | |
| 477 | + | public_url: target.public_url.clone(), | |
| 473 | 478 | // No checkout at promote time, so nothing to resolve against. | |
| 474 | 479 | aux_dirs: std::collections::HashMap::new(), | |
| 475 | 480 | }; | |
| @@ -1030,7 +1035,10 @@ | |||
| 1030 | 1035 | | Gate::MigrationDryRun | |
| 1031 | 1036 | | Gate::CodeSmoke | |
| 1032 | 1037 | | Gate::BootSmoke | |
| 1033 | - | | Gate::NodeHealth => { | |
| 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 => { | |
| 1034 | 1042 | // Latest row for this configured gate kind; NULL/missing/any | |
| 1035 | 1043 | // non-'passed' status all count as unsatisfied (fail closed). | |
| 1036 | 1044 | // Every build this promote ships has to show its own passed row. |