Skip to main content

max / makenotwork

page_smoke: a gate that reads the site the way a visitor does Sando's gates are all build-host or origin-side. boot_smoke runs on fw13 and sando.toml already says it "proves nothing about testnot-1"; node_health reaches a node over its executor and so never crosses the CDN. Between them a whole layer went unwatched, and on 2026-08-14 it shipped: testnot served a landing page whose JavaScript did not run, for hours, behind nine green gates. Cloudflare held a module from an earlier deploy, it failed to link against the fresh one beside it, and because the bundle's entry point side-effect-imports every island, one bad link took all of them down along with the legacy page scripts that read globals it publishes. Nothing could have caught it earlier. Every artifact was individually correct -- right markup, right stylesheets, every module answering 200 with current bytes. Only the composition was broken, and composition is observable in a browser and nowhere else. So: a post-deploy gate that runs a headless browser against the tier's public_url. Red on an uncaught exception, a console error, an island that did not run, or a failed page expectation. public_url is stated on the tier rather than derived from a node, and validate refuses page_smoke without one. A URL computed from a node would reach the origin and inherit the exact blind spot the gate exists to close, which is the kind of mistake that would leave it green forever. The command is per-product config. A page smoke is a web product's gate; pom has no pages, and leaving page_smoke_cmd unset is how it says so -- reported blocked rather than passed, because a gate proving nothing must not read green.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 20:50 UTC
Signed with PGP, not checked
Commit: 9f66ee2fbd59cd76e1b2e10eaab7a01a4d882cd0
Parent: 32efbeb
13 files changed, +617 insertions, -23 deletions
M sando/Cargo.lock +19 -7
@@ -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"
M server/Cargo.lock +12 -12
@@ -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.