Skip to main content

max / makenotwork

34.5 KB · 791 lines History Blame Raw
1 //! The code smoke gate: boot the real server against a throwaway database and
2 //! prove it serves.
3 //!
4 //! This module holds the crate's concentration of MNW-specific knowledge, which
5 //! is the reason it is named separately: the server crate's layout, its
6 //! `--seed-examples` flag and `ALLOW_EXAMPLE_SEED` guard, which npm projects
7 //! exist, and how `check-docs` reports a broken link.
8
9 use super::GateCtx;
10 use super::log::GateLog;
11 use super::pg::{pg_create_db, pg_drop_db, pg_url_with_dbname};
12 use super::probes::probe_health;
13 use crate::classify;
14 use crate::domain::{GateKind, GateRunId, Version};
15 use crate::outcome::{GateBlocker, GateFailure, GateOutcome, PassNote};
16 use anyhow::Result;
17
18 /// A 32+ char throwaway signing secret for the `code_smoke` boot. The server
19 /// only enforces length (>= 32) outside production, and code_smoke's loopback
20 /// `HOST_URL` keeps it in dev mode, so the value is irrelevant beyond that.
21 const CODE_SMOKE_SIGNING_SECRET: &str = "sando-code-smoke-dummy-signing-secret-0000000000";
22
23 /// Seconds to wait for the real server to come up and serve `GET /health`
24 /// during `code_smoke`. Longer than `boot_smoke`'s 3s: this boots the *full*
25 /// server (config, pool, session store, webauthn, doc load, app build), not the
26 /// minimal no-DB smoke server. The whole gate is also bounded by
27 /// `gate_timeout_secs` at the dispatcher.
28 const CODE_SMOKE_READY_SECS: u64 = 30;
29
30 /// `code_smoke` — the first host gate. Boots the freshly-built binary against a
31 /// throwaway *empty* DB it migrates from scratch and seeds the example catalog
32 /// into, then proves the real server serves `GET /health` against that
33 /// nonempty DB. Fast and infra-light (one local Postgres, no prod-dump restore,
34 /// no scratch-role reset, no external services), so a green here proves the
35 /// code is sound and isolates a later `cargo_test`/`migration_dry_run` red as an
36 /// environment problem rather than a code one.
37 ///
38 /// Reuses existing binary entrypoints, so the server needs no smoke-specific
39 /// mode: `<bin> --seed-examples` loads config, connects, migrates from scratch,
40 /// seeds, and exits; a plain `<bin>` then serves the real app. Both run with CWD
41 /// at the server crate root so `docs/business/assumptions.toml` + `site-docs/`
42 /// resolve (a missing assumptions file panics real startup), and with a loopback
43 /// `HOST_URL` so config stays in dev mode (no CDN/S3/signing-secret prod
44 /// enforcement). The seed's host allowlist already admits `127.0.0.1`, and the
45 /// fresh DB trivially satisfies its no-real-users guard.
46 pub(super) async fn code_smoke(ctx: &GateCtx, run_id: GateRunId) -> Result<GateOutcome> {
47 let log = GateLog::open(ctx, run_id, GateKind::CodeSmoke).await;
48 let outcome = code_smoke_inner(ctx, &log).await;
49 log.close().await;
50 outcome.map(|o| o.with_log_ref(ctx.log_ref(GateKind::CodeSmoke)))
51 }
52
53 /// The staged interior of [`code_smoke`], writing every step through the gate's
54 /// live log. Same split as `migration_dry_run_inner`: the caller owns the sink
55 /// and attaches the `log_ref`.
56 async fn code_smoke_inner(ctx: &GateCtx, log: &GateLog) -> Result<GateOutcome> {
57 let Some(scratch_url) = ctx.cfg.scratch_db_url.as_deref() else {
58 log.line("scratch_db_url unset in daemon config\n").await;
59 return Ok(GateOutcome::blocked(GateBlocker::ScratchDbUrlUnset));
60 };
61
62 // The staged binary (set by build_and_run_host before gating). code_smoke
63 // runs first among the host gates, but staging precedes all gating, so the
64 // artifact path is already recorded.
65 let bin: Option<(String,)> =
66 sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?")
67 .bind(&ctx.cfg.id)
68 .bind(&ctx.version)
69 .fetch_optional(&ctx.pool)
70 .await?;
71 let Some((bin,)) = bin else {
72 return Ok(GateOutcome::blocked(GateBlocker::ArtifactMissing {
73 version: ctx.version.clone(),
74 }));
75 };
76
77 // Frontend builds, before anything else: they need no DB and no staged
78 // binary, and a `tsc` error is the one failure the Rust build deliberately
79 // swallows (both MNW build scripts emit `cargo::warning` and succeed against
80 // a stale `static/dist/`). Failing here is what stops the deploy rsyncing
81 // the previous build's bundle.
82 if let Some(outcome) = code_smoke_frontends(ctx, log).await {
83 return Ok(outcome);
84 }
85
86 // Docs integrity, first and cheapest: run the staged binary's DB-free
87 // `MNW_CHECK_DOCS` mode before creating the throwaway DB. A broken internal
88 // docs link (a `[..](x.md)` resolving to a slug no page serves) fails here
89 // in well under a second instead of after a full migrate+seed+boot, and a
90 // rotted link never reaches prod as a live 404. Collisions are reported by
91 // the check but do not fail it; only broken links do.
92 if let Some(outcome) = code_smoke_docs_check(ctx, &bin, log).await {
93 return Ok(outcome);
94 }
95
96 let dbname = code_smoke_db_name(&ctx.version);
97 let maintenance_url = pg_url_with_dbname(scratch_url, "postgres");
98 let throwaway_url = pg_url_with_dbname(scratch_url, &dbname);
99
100 // Create the throwaway DB (dropping any stale one from a killed prior run).
101 log.line(&format!("---- createdb {dbname} ----\n")).await;
102 if let Err(e) = pg_create_db(&maintenance_url, &dbname).await {
103 let reason = format!("createdb {dbname}: {e}");
104 log.line(&reason).await;
105 return Ok(GateOutcome::failed(GateFailure::CodeSmokeSetup { reason }));
106 }
107
108 // Everything past createdb must drop the DB on the way out, pass or fail.
109 let outcome = code_smoke_body(ctx, &bin, &throwaway_url, log).await;
110
111 log.line(&format!("\n---- dropdb {dbname} ----\n")).await;
112 if let Err(e) = pg_drop_db(&maintenance_url, &dbname).await {
113 // A teardown miss must not turn a passing gate red — log it and move on.
114 // The next run's createdb drops it first anyway.
115 tracing::warn!(error = %e, db = %dbname, "code_smoke: dropdb failed; next run will reclaim it");
116 log.line(&format!("dropdb warning (non-fatal): {e}")).await;
117 }
118
119 Ok(outcome)
120 }
121
122 /// Compile every configured `frontend_build` in the worktree.
123 ///
124 /// Returns `Some(failed)` on the first project that does not build; `None` when
125 /// all of them do (or none are configured). Output streams to `log` either way.
126 ///
127 /// `npm ci` runs only when `node_modules` is absent. Usually it is not: the app
128 /// build script installed it during the `cargo build` that produced the artifact
129 /// this gate is about to smoke, so the common path here is just `npm run build`
130 /// against a warm install — seconds. The install branch covers the gate running
131 /// against a worktree whose build script was skipped or failed at `npm ci`, and
132 /// it is as fatal as a compile failure, because the alternative is compiling
133 /// against whatever some earlier sha installed.
134 ///
135 /// Unlike the app build scripts, nothing here is best-effort. That asymmetry is
136 /// the point of the gate.
137 async fn code_smoke_frontends(ctx: &GateCtx, log: &GateLog) -> Option<GateOutcome> {
138 if ctx.cfg.frontend_builds.is_empty() {
139 return None;
140 }
141 let worktree = match ctx.worktree_for(GateKind::CodeSmoke) {
142 Ok(w) => w.to_path_buf(),
143 Err(outcome) => return Some(outcome),
144 };
145 for fe in &ctx.cfg.frontend_builds {
146 let dir = worktree.join(&fe.dir);
147 let label = fe.dir.display().to_string();
148 log.line(&format!("---- frontend build ({label}) ----\n"))
149 .await;
150
151 if !dir.is_dir() {
152 // An older sha predating the frontend, mid-bisect. Skipping keeps
153 // sando able to rebuild history; the log says so out loud.
154 log.line(&format!("{label} absent from this worktree; skipping\n"))
155 .await;
156 continue;
157 }
158
159 if !dir.join("node_modules").is_dir()
160 && let Some(outcome) = run_npm(&dir, &label, &["ci"], "npm ci", ctx, log).await
161 {
162 return Some(outcome);
163 }
164
165 if let Some(outcome) = run_npm(
166 &dir,
167 &label,
168 &["run", &fe.script],
169 &format!("npm run {}", fe.script),
170 ctx,
171 log,
172 )
173 .await
174 {
175 return Some(outcome);
176 }
177 }
178 None
179 }
180
181 /// One `npm` invocation for [`code_smoke_frontends`], bounded by the gate
182 /// timeout so a wedged install cannot hold the whole pipeline (the enclosing
183 /// `code_smoke` ceiling would catch it eventually, but this attributes the
184 /// failure to the project that hung).
185 async fn run_npm(
186 dir: &std::path::Path,
187 label: &str,
188 args: &[&str],
189 what: &str,
190 ctx: &GateCtx,
191 log: &GateLog,
192 ) -> Option<GateOutcome> {
193 log.line(&format!("$ {what}\n")).await;
194 let mut cmd = tokio::process::Command::new("npm");
195 cmd.args(args).current_dir(dir).kill_on_drop(true);
196 let ceiling = std::time::Duration::from_secs(ctx.cfg.gate_timeout_secs);
197 // On the timeout branch the whole `run` future is dropped, which drops the
198 // child; `kill_on_drop` is what turns that into an actual kill.
199 let status = match tokio::time::timeout(ceiling, log.run(&mut cmd)).await {
200 Ok(Ok((_stdout, _stderr, status))) => status,
201 Ok(Err(e)) => {
202 // A missing `npm` lands here. Fatal, not skipped: a build host
203 // without Node cannot produce the bundle the release serves, and
204 // silently passing is how the stale bundle shipped in the first place.
205 log.line(&format!("{what} could not be spawned: {e}\n"))
206 .await;
207 return Some(GateOutcome::failed(GateFailure::SpawnFailed {
208 message: format!("{what} in {label}: {e}"),
209 }));
210 }
211 Err(_elapsed) => {
212 log.line(&format!(
213 "{what} timed out after {}s\n",
214 ctx.cfg.gate_timeout_secs
215 ))
216 .await;
217 return Some(GateOutcome::failed(GateFailure::CodeSmokeFrontend {
218 dir: label.to_string(),
219 exit_code: None,
220 }));
221 }
222 };
223 if status.success() {
224 return None;
225 }
226 Some(GateOutcome::failed(GateFailure::CodeSmokeFrontend {
227 dir: label.to_string(),
228 exit_code: status.code(),
229 }))
230 }
231
232 /// Run the staged binary's DB-free docs integrity check (`MNW_CHECK_DOCS=1`).
233 ///
234 /// Returns `Some(failed)` if the check reports broken links, cannot be spawned,
235 /// or overruns its ceiling; `None` when the docs are clean. Output streams to
236 /// `log` either way. The 60s ceiling backstops the case where the staged binary
237 /// predates the flag and would fall through to a normal (DB-needing) boot and
238 /// hang.
239 async fn code_smoke_docs_check(ctx: &GateCtx, bin: &str, log: &GateLog) -> Option<GateOutcome> {
240 let server_dir = match ctx.worktree_for(GateKind::CodeSmoke) {
241 Ok(w) => w.join("server"),
242 Err(outcome) => return Some(outcome),
243 };
244 log.line("---- docs check (MNW_CHECK_DOCS) ----\n").await;
245 let mut cmd = tokio::process::Command::new(bin);
246 cmd.env("MNW_CHECK_DOCS", "1")
247 .current_dir(&server_dir)
248 .kill_on_drop(true);
249 let (stdout, _stderr, status) =
250 match tokio::time::timeout(std::time::Duration::from_mins(1), log.run(&mut cmd)).await {
251 Ok(Ok(out)) => out,
252 Ok(Err(e)) => {
253 log.line(&format!("docs check spawn failed: {e}\n")).await;
254 return Some(GateOutcome::failed(GateFailure::SpawnFailed {
255 message: e.to_string(),
256 }));
257 }
258 Err(_elapsed) => {
259 let reason =
260 "docs check timed out after 60s (staged binary may predate MNW_CHECK_DOCS)"
261 .to_string();
262 log.line(&format!("{reason}\n")).await;
263 return Some(GateOutcome::failed(GateFailure::CodeSmokeSetup { reason }));
264 }
265 };
266 if status.success() {
267 return None;
268 }
269 Some(GateOutcome::failed(GateFailure::CodeSmokeDocs {
270 broken: parse_check_docs_broken_count(&stdout),
271 }))
272 }
273
274 /// Best-effort parse of the broken-link count from the `MNW_CHECK_DOCS` sentinel
275 /// line (`MNW_CHECK_DOCS: N broken link(s)`). Returns 0 if absent — the failure
276 /// still stands, only the summary count is unknown.
277 fn parse_check_docs_broken_count(stdout: &[u8]) -> u32 {
278 let text = String::from_utf8_lossy(stdout);
279 for line in text.lines() {
280 if let Some(rest) = line.strip_prefix("MNW_CHECK_DOCS:") {
281 for tok in rest.split_whitespace() {
282 if let Ok(n) = tok.parse::<u32>() {
283 return n;
284 }
285 }
286 }
287 }
288 0
289 }
290
291 /// The createdb-to-dropdb interior of `code_smoke`: migrate+seed, then boot and
292 /// probe. Returns the outcome without a `log_ref` (the caller attaches it after
293 /// teardown). Never returns `Err` — spawn/child failures map to typed outcomes.
294 async fn code_smoke_body(ctx: &GateCtx, bin: &str, db_url: &str, log: &GateLog) -> GateOutcome {
295 let server_dir = match ctx.worktree_for(GateKind::CodeSmoke) {
296 Ok(w) => w.join("server"),
297 Err(outcome) => return outcome,
298 };
299
300 // Phase 1: migrate-from-scratch + seed. `--seed-examples` loads config,
301 // connects, runs migrations against the empty DB, seeds the catalog, exits.
302 // A non-zero exit here is the "code is unsound" signal (broken migration,
303 // seed error, or config-load failure).
304 log.line("---- migrate + seed (--seed-examples) ----\n")
305 .await;
306 let mut seed_cmd = tokio::process::Command::new(bin);
307 seed_cmd.arg("--seed-examples").current_dir(&server_dir);
308 code_smoke_env(&mut seed_cmd, ctx, db_url);
309 seed_cmd.env("ALLOW_EXAMPLE_SEED", "1").kill_on_drop(true);
310 let seed_status = match log.run(&mut seed_cmd).await {
311 Ok((_stdout, _stderr, status)) => status,
312 Err(e) => {
313 return GateOutcome::failed(GateFailure::SpawnFailed {
314 message: e.to_string(),
315 });
316 }
317 };
318 if !seed_status.success() {
319 return GateOutcome::failed(GateFailure::CodeSmokeSeed {
320 exit_code: seed_status.code(),
321 });
322 }
323
324 // Phase 2: boot the real server against the now-migrated + seeded DB and
325 // assert both startup signals: it logs `listening` (emitted just before the
326 // socket bind) AND serves GET /health with a 200. The full stdout/stderr is
327 // persisted for the operator either way.
328 log.line("\n---- boot + probe /health ----\n").await;
329 let mut serve_cmd = tokio::process::Command::new(bin);
330 serve_cmd.current_dir(&server_dir);
331 code_smoke_env(&mut serve_cmd, ctx, db_url);
332 serve_cmd
333 .stdout(std::process::Stdio::piped())
334 .stderr(std::process::Stdio::piped())
335 .kill_on_drop(true);
336 let mut child = match serve_cmd.spawn() {
337 Ok(c) => c,
338 Err(e) => {
339 return GateOutcome::failed(GateFailure::SpawnFailed {
340 message: e.to_string(),
341 });
342 }
343 };
344
345 // Stream stdout/stderr into the gate log (and out as chunk events) while
346 // the probe loop runs below; the tasks finish when the pipes close (child
347 // exits or is killed). The buffers they return are what the `listening`
348 // assertion reads.
349 let (stdout_task, stderr_task) = log.drain_pipes(&mut child);
350
351 let probe_timeout = std::time::Duration::from_millis(500);
352 let started = std::time::Instant::now();
353 let window = std::time::Duration::from_secs(CODE_SMOKE_READY_SECS);
354 let mut probe_ok_after: Option<u32> = None;
355 let mut last_probe_err = "never responded".to_string();
356 let mut early_exit = None;
357 while started.elapsed() < window {
358 if let Ok(Some(status)) = child.try_wait() {
359 early_exit = Some(status);
360 break;
361 }
362 match tokio::time::timeout(probe_timeout, probe_health(ctx.cfg.code_smoke_port)).await {
363 Ok(Ok(())) => {
364 probe_ok_after = Some(started.elapsed().as_millis() as u32);
365 break;
366 }
367 Ok(Err(e)) => last_probe_err = e,
368 Err(_) => last_probe_err = "probe timed out".to_string(),
369 }
370 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
371 }
372
373 let exit = match early_exit {
374 Some(status) => Some(status),
375 None => {
376 let e = child.try_wait().ok().flatten();
377 if e.is_none() {
378 let _ = child.kill().await;
379 }
380 e
381 }
382 };
383 // Keep the serve run's own output so the `listening`-log assertion checks it
384 // (not the seed run's, which exits before binding). The bytes are already in
385 // the gate log; these buffers exist only for the assertion.
386 let serve_stdout = stdout_task.await.unwrap_or_default();
387 let serve_stderr = stderr_task.await.unwrap_or_default();
388 let logged_listening =
389 bytes_contain(&serve_stdout, b"listening") || bytes_contain(&serve_stderr, b"listening");
390
391 match (exit, probe_ok_after) {
392 // Exited on its own within the window — panic / config error / bind fail.
393 (Some(status), _) => GateOutcome::failed(classify::classify_boot_smoke(status.code())),
394 // Stayed up and served /health. Assert both required startup signals:
395 // the `listening` bind log AND the /health 200.
396 (None, Some(after_ms)) if logged_listening => {
397 GateOutcome::passed(PassNote::HealthyProbe { after_ms })
398 }
399 // Served /health but the `listening` log never appeared.
400 (None, Some(_)) => GateOutcome::failed(GateFailure::CodeSmokeNoListeningLog),
401 // Stayed up but never served /health — started, not ready.
402 (None, None) => GateOutcome::failed(GateFailure::BootHealthProbeFailed {
403 last_error: last_probe_err,
404 }),
405 }
406 }
407
408 /// Substring search over raw bytes (the server's log output), for the
409 /// `code_smoke` startup-log assertion. Avoids a lossy UTF-8 conversion of the
410 /// whole buffer just to run `str::contains`.
411 fn bytes_contain(haystack: &[u8], needle: &[u8]) -> bool {
412 if needle.is_empty() || haystack.len() < needle.len() {
413 return needle.is_empty();
414 }
415 haystack.windows(needle.len()).any(|w| w == needle)
416 }
417
418 /// Apply the minimal env every `code_smoke` invocation shares: point the binary
419 /// at the throwaway DB, force loopback (dev-mode config, no prod enforcement),
420 /// hand it a dummy signing secret, and disable file scanning (no AV/YARA on the
421 /// build host). The worktree is a clean git checkout, so no stray `.env` shadows
422 /// these (and dotenvy never overrides already-set vars).
423 ///
424 /// This list has to carry EVERY var the server's `Config::from_env` treats as
425 /// mandatory, because `code_smoke` is the only gate that reaches that function
426 /// at all: `boot_smoke` and the docs check both short-circuit in `main` before
427 /// config is loaded. So when the server makes a new var required, this is where
428 /// it has to be answered, and nothing connects the two lists automatically.
429 ///
430 /// The values are deliberately throwaway. The gate asks whether this code can
431 /// migrate, seed, boot and serve; whether a given deployment's env is complete
432 /// is the `config_check_env_file` guard's job, on the node, against that node's
433 /// real env file.
434 ///
435 /// `app.code_smoke_env` is prepended to all of this, for vars a product needs
436 /// that sando has no business knowing about. The fixed set overwrites it on a
437 /// collision, so nothing in a config file can redirect the gate off its own
438 /// throwaway DB.
439 fn code_smoke_env(cmd: &mut tokio::process::Command, ctx: &GateCtx, db_url: &str) {
440 // `localhost`, not `127.0.0.1`, and the distinction is load-bearing. The
441 // server derives its WebAuthn relying-party id from HOST_URL's host, and
442 // `WebauthnBuilder::new` validates that id against `Url::domain()` — which
443 // is `None` for an IP literal, so an origin of `http://127.0.0.1:<port>`
444 // fails with WebauthnError::Configuration before the server ever binds. It
445 // is a real ceiling on the gate, not a preference: no IP-literal origin can
446 // boot this binary. `localhost` is a domain, and matches the derived rp_id.
447 //
448 // HOST stays 127.0.0.1: that is the bind address, and the gate probes the
449 // loopback address directly, so only the advertised origin changes.
450 let origin = format!("http://localhost:{}", ctx.cfg.code_smoke_port);
451 // Project-supplied extras go on first so the fixed set below overwrites any
452 // key they collide on: what points this run at its throwaway DB and its
453 // loopback port is not negotiable from a config file.
454 cmd.envs(&ctx.cfg.code_smoke_env);
455 cmd.env("DATABASE_URL", db_url)
456 .env("HOST", "127.0.0.1")
457 .env("PORT", ctx.cfg.code_smoke_port.to_string())
458 .env("HOST_URL", &origin)
459 // Required unconditionally by Config::from_env. Pointing it at the smoke
460 // server's own origin keeps every rendered media URL resolvable within
461 // the gate; no request is ever made to it.
462 .env("CDN_BASE_URL", &origin)
463 .env("SIGNING_SECRET", CODE_SMOKE_SIGNING_SECRET)
464 .env("SCAN_ENABLED", "false")
465 .env("INSECURE_COOKIES", "1");
466 }
467
468 /// The throwaway smoke DB name for a version: `sando_code_smoke_<version>` with
469 /// every non-alphanumeric char folded to `_` and lowercased, capped at Postgres'
470 /// 63-byte identifier limit. Sanitized to `[a-z0-9_]` so it's safe to quote into
471 /// DDL. Deterministic per version, so a stale DB from a killed run is reclaimed
472 /// by the next run's `DROP DATABASE IF EXISTS` rather than accumulating.
473 fn code_smoke_db_name(version: &Version) -> String {
474 let mut name = String::from("sando_code_smoke_");
475 for c in version.to_string().chars() {
476 name.push(if c.is_ascii_alphanumeric() {
477 c.to_ascii_lowercase()
478 } else {
479 '_'
480 });
481 }
482 name.truncate(63);
483 name
484 }
485
486 #[cfg(test)]
487 mod tests {
488 use super::super::run;
489 use super::*;
490 use crate::domain::TierId;
491 use crate::events::{self, Event};
492 use crate::gates::testkit::{
493 frontend_ctx, read_gate_log, resolving_ctx, test_gate_log, url_host_is_a_domain,
494 };
495 use crate::topology::Gate;
496 use sqlx::sqlite::SqlitePoolOptions;
497 use std::collections::HashMap;
498
499 #[test]
500 fn parse_check_docs_broken_count_reads_the_sentinel() {
501 // The failing sentinel carries the count.
502 assert_eq!(
503 parse_check_docs_broken_count(b"some log\nMNW_CHECK_DOCS: 3 broken link(s)\n"),
504 3
505 );
506 // Count with a preceding log line still parses (first bare int wins).
507 assert_eq!(
508 parse_check_docs_broken_count(
509 b" broken link: a -> b\nMNW_CHECK_DOCS: 1 broken link(s)\n"
510 ),
511 1
512 );
513 // The parser only runs on failure; the "ok" sentinel is never fed to it,
514 // and its "(2" token is not a bare int, so it yields 0 harmlessly.
515 assert_eq!(
516 parse_check_docs_broken_count(b"MNW_CHECK_DOCS: ok (2 collision(s) reported)\n"),
517 0
518 );
519 // Absent sentinel -> 0; the failure still stands, only the count is lost.
520 assert_eq!(parse_check_docs_broken_count(b"unrelated output"), 0);
521 }
522
523 /// Write a minimal npm project at `worktree/<dir>` whose `build` script
524 /// exits with `exit_code`. Pre-creates `node_modules` so the gate skips
525 /// `npm ci` — these tests are about the build step, not the network.
526 fn fake_npm_project(worktree: &std::path::Path, dir: &str, exit_code: u8) {
527 let root = worktree.join(dir);
528 std::fs::create_dir_all(root.join("node_modules")).unwrap();
529 std::fs::write(
530 root.join("package.json"),
531 format!(
532 r#"{{"name":"fake","version":"0.0.0","private":true,
533 "scripts":{{"build":"exit {exit_code}"}}}}"#
534 ),
535 )
536 .unwrap();
537 }
538
539 #[tokio::test]
540 async fn frontend_gate_fails_on_a_build_error_and_names_the_project() {
541 // The whole point of the gate: the app build scripts downgrade this to a
542 // cargo::warning, so if it passes here nothing stops a stale bundle.
543 let tmp = tempfile::tempdir().unwrap();
544 fake_npm_project(tmp.path(), "server/frontend", 0);
545 fake_npm_project(tmp.path(), "multithreaded/frontend", 2);
546 let ctx = frontend_ctx(tmp.path(), &["server/frontend", "multithreaded/frontend"]).await;
547
548 let log = test_gate_log(&ctx).await;
549 let outcome = code_smoke_frontends(&ctx, &log)
550 .await
551 .expect("a failing tsc must fail the gate");
552 let crate::outcome::GateStatus::Failed { failure } = &outcome.status else {
553 panic!("expected a failure, got {:?}", outcome.status)
554 };
555 assert!(
556 matches!(
557 failure,
558 GateFailure::CodeSmokeFrontend { dir, exit_code: Some(2) }
559 if dir == "multithreaded/frontend"
560 ),
561 "got: {failure:?}"
562 );
563 // The passing project ran first; its output belongs in the log too.
564 let text = read_gate_log(&ctx, log).await;
565 assert!(text.contains("server/frontend"), "log: {text}");
566 }
567
568 #[tokio::test]
569 async fn frontend_gate_passes_when_every_project_builds() {
570 let tmp = tempfile::tempdir().unwrap();
571 fake_npm_project(tmp.path(), "server/frontend", 0);
572 let ctx = frontend_ctx(tmp.path(), &["server/frontend"]).await;
573 let log = test_gate_log(&ctx).await;
574 assert!(
575 code_smoke_frontends(&ctx, &log).await.is_none(),
576 "a clean build must not fail the gate"
577 );
578 }
579
580 /// The point of routing `code_smoke` through `LiveLog`: its output reaches
581 /// the operator *while* the gate runs, as `GateLogChunk` events, instead of
582 /// appearing all at once when the gate finishes.
583 #[tokio::test]
584 async fn code_smoke_streams_chunks_as_it_runs() {
585 let tmp = tempfile::tempdir().unwrap();
586 fake_npm_project(tmp.path(), "server/frontend", 0);
587 let ctx = frontend_ctx(tmp.path(), &["server/frontend"]).await;
588 let mut rx = ctx.events.subscribe_logs();
589
590 let log = GateLog::open(&ctx, GateRunId(7), GateKind::CodeSmoke).await;
591 assert!(code_smoke_frontends(&ctx, &log).await.is_none());
592 log.close().await;
593
594 let mut chunks = Vec::new();
595 while let Ok(envelope) = rx.try_recv() {
596 if let Event::GateLogChunk { run_id, seq, text } = envelope.event {
597 assert_eq!(run_id, GateRunId(7));
598 chunks.push((seq, text));
599 }
600 }
601 assert!(!chunks.is_empty(), "no chunk ever reached the bus");
602 // Sequence numbers are per-run and monotonic across every step of the
603 // gate, which is why the whole gate shares one sink.
604 let seqs: Vec<u32> = chunks.iter().map(|(seq, _)| *seq).collect();
605 assert!(
606 seqs.windows(2).all(|w| w[0] < w[1]),
607 "chunk seq must be monotonic, got {seqs:?}"
608 );
609 let joined: String = chunks.into_iter().map(|(_, text)| text).collect();
610 assert!(joined.contains("server/frontend"), "chunks: {joined}");
611 }
612
613 #[tokio::test]
614 async fn frontend_gate_skips_a_project_absent_from_the_worktree() {
615 // Rebuilding an older sha that predates the frontend must stay possible.
616 let tmp = tempfile::tempdir().unwrap();
617 let ctx = frontend_ctx(tmp.path(), &["multithreaded/frontend"]).await;
618 let log = test_gate_log(&ctx).await;
619 assert!(code_smoke_frontends(&ctx, &log).await.is_none());
620 assert!(
621 read_gate_log(&ctx, log).await.contains("skipping"),
622 "the skip must be visible in the log"
623 );
624 }
625
626 #[test]
627 fn bytes_contain_matches_listening_in_log_output() {
628 // JSON release log carries the message field verbatim.
629 assert!(bytes_contain(
630 br#"{"timestamp":"...","level":"INFO","fields":{"message":"listening","addr":"127.0.0.1:18182"}}"#,
631 b"listening",
632 ));
633 // Human-format dev log.
634 assert!(bytes_contain(
635 b"2026-07-17 INFO makenotwork: listening addr=127.0.0.1:18182",
636 b"listening"
637 ));
638 assert!(!bytes_contain(
639 b"migrations complete; seeding catalog",
640 b"listening"
641 ));
642 assert!(!bytes_contain(b"", b"listening"));
643 }
644
645 #[tokio::test]
646 async fn code_smoke_env_supplies_every_mandatory_server_var() {
647 // code_smoke is the only gate that reaches the server's Config::from_env
648 // (boot_smoke and the docs check short-circuit before it), so anything
649 // that function requires has to be answered here. CDN_BASE_URL became
650 // mandatory 13 days after this env was written and went unnoticed until
651 // the gate first ran on the host; this test is what makes the next one
652 // fail here instead of in a promote.
653 let ctx = resolving_ctx("/w/abc", &[]);
654 let mut cmd = tokio::process::Command::new("true");
655 code_smoke_env(&mut cmd, &ctx, "postgres:///throwaway");
656 let set: std::collections::HashMap<String, String> = cmd
657 .as_std()
658 .get_envs()
659 .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))
660 .collect();
661 for key in [
662 "DATABASE_URL",
663 "HOST",
664 "PORT",
665 "HOST_URL",
666 "CDN_BASE_URL",
667 "SIGNING_SECRET",
668 ] {
669 assert!(set.contains_key(key), "code_smoke_env must set {key}");
670 assert!(!set[key].is_empty(), "{key} must not be empty");
671 }
672 // Loopback, so Config::from_env's is_production branch stays false and
673 // the gate never trips MissingPublicBucket for want of an S3 bucket.
674 assert!(set["HOST_URL"].starts_with("http://localhost"));
675 // Not an IP literal: the server derives its WebAuthn rp_id from this
676 // host, and WebauthnBuilder rejects an origin whose Url::domain() is
677 // None, which is every IP address. An IP here cannot boot the server.
678 assert!(
679 url_host_is_a_domain(&set["HOST_URL"]),
680 "HOST_URL host must be a domain, not an IP literal: {}",
681 set["HOST_URL"],
682 );
683 assert_eq!(set["HOST"], "127.0.0.1");
684 // The signing secret has to clear the server's 32-char floor, or the
685 // gate fails with WeakSigningSecret instead of testing anything.
686 assert!(set["SIGNING_SECRET"].len() >= 32);
687 }
688
689 #[tokio::test]
690 async fn code_smoke_env_passes_extras_through_but_never_lets_them_win() {
691 // The pass-through exists so a product can hand its own binary a var
692 // sando has no business knowing about (MNW points SEED_MEDIA_CACHE at a
693 // persistent dir, because PrivateTmp=true made the seed's media cache
694 // cold on every build). What it must never become is a way to aim a
695 // smoke run at a real database.
696 let mut cfg = crate::config::AppConfig::for_tests();
697 cfg.code_smoke_env = [
698 (
699 "SEED_MEDIA_CACHE".to_string(),
700 "/srv/sando/seed".to_string(),
701 ),
702 (
703 "DATABASE_URL".to_string(),
704 "postgres://prod-1/makenotwork".to_string(),
705 ),
706 ]
707 .into_iter()
708 .collect();
709 let mut ctx = resolving_ctx("/w/abc", &[]);
710 ctx.cfg = std::sync::Arc::new(cfg);
711
712 let mut cmd = tokio::process::Command::new("true");
713 code_smoke_env(&mut cmd, &ctx, "postgres:///throwaway");
714 let set: std::collections::HashMap<String, String> = cmd
715 .as_std()
716 .get_envs()
717 .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))
718 .collect();
719
720 assert_eq!(set["SEED_MEDIA_CACHE"], "/srv/sando/seed");
721 assert_eq!(
722 set["DATABASE_URL"], "postgres:///throwaway",
723 "the fixed set must overwrite a colliding extra, or a config typo \
724 could point code_smoke at a real database",
725 );
726 }
727
728 #[test]
729 fn code_smoke_db_name_sanitizes_and_caps() {
730 assert_eq!(
731 code_smoke_db_name(&"0.9.6".parse().unwrap()),
732 "sando_code_smoke_0_9_6"
733 );
734 // Pre-release/build metadata folds to underscores; result stays [a-z0-9_].
735 let n = code_smoke_db_name(&"1.0.0-rc.1+build".parse().unwrap());
736 assert_eq!(n, "sando_code_smoke_1_0_0_rc_1_build");
737 assert!(
738 n.bytes()
739 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
740 );
741 assert!(n.len() <= 63);
742 }
743
744 /// code_smoke is Blocked (not Failed) when the daemon has no scratch_db_url:
745 /// there's no cluster to create the throwaway DB in, and that's an operator
746 /// precondition, rendered yellow — the same shape as migration_dry_run.
747 #[tokio::test]
748 async fn code_smoke_blocks_without_scratch_db_url() {
749 let pool = SqlitePoolOptions::new()
750 .max_connections(1)
751 .connect("sqlite::memory:")
752 .await
753 .unwrap();
754 crate::db::migrate(&pool).await.unwrap();
755 sqlx::query("INSERT INTO tiers (name, ord, provisioned, canary) VALUES ('host', 0, 1, 'sequential')")
756 .execute(&pool).await.unwrap();
757 sqlx::query("INSERT INTO tier_state (tier) VALUES ('host')")
758 .execute(&pool)
759 .await
760 .unwrap();
761 sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('0.1.0', 'abc1234', '2026-01-01T00:00:00Z', '/tmp/x')")
762 .execute(&pool).await.unwrap();
763
764 let cfg = std::sync::Arc::new(crate::config::AppConfig::for_tests()); // scratch_db_url: None
765 let ctx = GateCtx {
766 public_url: None,
767 pool: pool.clone(),
768 cfg,
769 tier: TierId::new("host"),
770 version: "0.1.0".parse().unwrap(),
771 worktree: Some(std::path::PathBuf::from("/tmp/unused")),
772 bundle: None,
773 events: events::channel(),
774 nodes: Vec::new(),
775 build_id: None,
776 aux_dirs: HashMap::new(),
777 };
778 let out = run(&ctx, &Gate::CodeSmoke).await.unwrap();
779 assert_eq!(out.status_str(), "blocked");
780 assert!(!out.is_passed());
781 let row: (Option<String>, Option<String>) =
782 sqlx::query_as("SELECT status, outcome_json FROM gate_runs ORDER BY id DESC LIMIT 1")
783 .fetch_one(&pool)
784 .await
785 .unwrap();
786 assert_eq!(row.0.as_deref(), Some("blocked"));
787 let json: serde_json::Value = serde_json::from_str(row.1.as_deref().unwrap()).unwrap();
788 assert_eq!(json["status"]["blocker"]["kind"], "scratch_db_url_unset");
789 }
790 }
791