//! The code smoke gate: boot the real server against a throwaway database and //! prove it serves. //! //! This module holds the crate's concentration of MNW-specific knowledge, which //! is the reason it is named separately: the server crate's layout, its //! `--seed-examples` flag and `ALLOW_EXAMPLE_SEED` guard, which npm projects //! exist, and how `check-docs` reports a broken link. use super::GateCtx; use super::log::GateLog; use super::pg::{pg_create_db, pg_drop_db, pg_url_with_dbname}; use super::probes::probe_health; use crate::classify; use crate::domain::{GateKind, GateRunId, Version}; use crate::outcome::{GateBlocker, GateFailure, GateOutcome, PassNote}; use anyhow::Result; /// A 32+ char throwaway signing secret for the `code_smoke` boot. The server /// only enforces length (>= 32) outside production, and code_smoke's loopback /// `HOST_URL` keeps it in dev mode, so the value is irrelevant beyond that. const CODE_SMOKE_SIGNING_SECRET: &str = "sando-code-smoke-dummy-signing-secret-0000000000"; /// Seconds to wait for the real server to come up and serve `GET /health` /// during `code_smoke`. Longer than `boot_smoke`'s 3s: this boots the *full* /// server (config, pool, session store, webauthn, doc load, app build), not the /// minimal no-DB smoke server. The whole gate is also bounded by /// `gate_timeout_secs` at the dispatcher. const CODE_SMOKE_READY_SECS: u64 = 30; /// `code_smoke` — the first host gate. Boots the freshly-built binary against a /// throwaway *empty* DB it migrates from scratch and seeds the example catalog /// into, then proves the real server serves `GET /health` against that /// nonempty DB. Fast and infra-light (one local Postgres, no prod-dump restore, /// no scratch-role reset, no external services), so a green here proves the /// code is sound and isolates a later `cargo_test`/`migration_dry_run` red as an /// environment problem rather than a code one. /// /// Reuses existing binary entrypoints, so the server needs no smoke-specific /// mode: ` --seed-examples` loads config, connects, migrates from scratch, /// seeds, and exits; a plain `` then serves the real app. Both run with CWD /// at the server crate root so `docs/business/assumptions.toml` + `site-docs/` /// resolve (a missing assumptions file panics real startup), and with a loopback /// `HOST_URL` so config stays in dev mode (no CDN/S3/signing-secret prod /// enforcement). The seed's host allowlist already admits `127.0.0.1`, and the /// fresh DB trivially satisfies its no-real-users guard. pub(super) async fn code_smoke(ctx: &GateCtx, run_id: GateRunId) -> Result { let log = GateLog::open(ctx, run_id, GateKind::CodeSmoke).await; let outcome = code_smoke_inner(ctx, &log).await; log.close().await; outcome.map(|o| o.with_log_ref(ctx.log_ref(GateKind::CodeSmoke))) } /// The staged interior of [`code_smoke`], writing every step through the gate's /// live log. Same split as `migration_dry_run_inner`: the caller owns the sink /// and attaches the `log_ref`. async fn code_smoke_inner(ctx: &GateCtx, log: &GateLog) -> Result { let Some(scratch_url) = ctx.cfg.scratch_db_url.as_deref() else { log.line("scratch_db_url unset in daemon config\n").await; return Ok(GateOutcome::blocked(GateBlocker::ScratchDbUrlUnset)); }; // The staged binary (set by build_and_run_host before gating). code_smoke // runs first among the host gates, but staging precedes all gating, so the // artifact path is already recorded. let bin: Option<(String,)> = sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?") .bind(&ctx.cfg.id) .bind(&ctx.version) .fetch_optional(&ctx.pool) .await?; let Some((bin,)) = bin else { return Ok(GateOutcome::blocked(GateBlocker::ArtifactMissing { version: ctx.version.clone(), })); }; // Frontend builds, before anything else: they need no DB and no staged // binary, and a `tsc` error is the one failure the Rust build deliberately // swallows (both MNW build scripts emit `cargo::warning` and succeed against // a stale `static/dist/`). Failing here is what stops the deploy rsyncing // the previous build's bundle. if let Some(outcome) = code_smoke_frontends(ctx, log).await { return Ok(outcome); } // Docs integrity, first and cheapest: run the staged binary's DB-free // `MNW_CHECK_DOCS` mode before creating the throwaway DB. A broken internal // docs link (a `[..](x.md)` resolving to a slug no page serves) fails here // in well under a second instead of after a full migrate+seed+boot, and a // rotted link never reaches prod as a live 404. Collisions are reported by // the check but do not fail it; only broken links do. if let Some(outcome) = code_smoke_docs_check(ctx, &bin, log).await { return Ok(outcome); } let dbname = code_smoke_db_name(&ctx.version); let maintenance_url = pg_url_with_dbname(scratch_url, "postgres"); let throwaway_url = pg_url_with_dbname(scratch_url, &dbname); // Create the throwaway DB (dropping any stale one from a killed prior run). log.line(&format!("---- createdb {dbname} ----\n")).await; if let Err(e) = pg_create_db(&maintenance_url, &dbname).await { let reason = format!("createdb {dbname}: {e}"); log.line(&reason).await; return Ok(GateOutcome::failed(GateFailure::CodeSmokeSetup { reason })); } // Everything past createdb must drop the DB on the way out, pass or fail. let outcome = code_smoke_body(ctx, &bin, &throwaway_url, log).await; log.line(&format!("\n---- dropdb {dbname} ----\n")).await; if let Err(e) = pg_drop_db(&maintenance_url, &dbname).await { // A teardown miss must not turn a passing gate red — log it and move on. // The next run's createdb drops it first anyway. tracing::warn!(error = %e, db = %dbname, "code_smoke: dropdb failed; next run will reclaim it"); log.line(&format!("dropdb warning (non-fatal): {e}")).await; } Ok(outcome) } /// Compile every configured `frontend_build` in the worktree. /// /// Returns `Some(failed)` on the first project that does not build; `None` when /// all of them do (or none are configured). Output streams to `log` either way. /// /// `npm ci` runs only when `node_modules` is absent. Usually it is not: the app /// build script installed it during the `cargo build` that produced the artifact /// this gate is about to smoke, so the common path here is just `npm run build` /// against a warm install — seconds. The install branch covers the gate running /// against a worktree whose build script was skipped or failed at `npm ci`, and /// it is as fatal as a compile failure, because the alternative is compiling /// against whatever some earlier sha installed. /// /// Unlike the app build scripts, nothing here is best-effort. That asymmetry is /// the point of the gate. async fn code_smoke_frontends(ctx: &GateCtx, log: &GateLog) -> Option { if ctx.cfg.frontend_builds.is_empty() { return None; } let worktree = match ctx.worktree_for(GateKind::CodeSmoke) { Ok(w) => w.to_path_buf(), Err(outcome) => return Some(outcome), }; for fe in &ctx.cfg.frontend_builds { let dir = worktree.join(&fe.dir); let label = fe.dir.display().to_string(); log.line(&format!("---- frontend build ({label}) ----\n")) .await; if !dir.is_dir() { // An older sha predating the frontend, mid-bisect. Skipping keeps // sando able to rebuild history; the log says so out loud. log.line(&format!("{label} absent from this worktree; skipping\n")) .await; continue; } if !dir.join("node_modules").is_dir() && let Some(outcome) = run_npm(&dir, &label, &["ci"], "npm ci", ctx, log).await { return Some(outcome); } if let Some(outcome) = run_npm( &dir, &label, &["run", &fe.script], &format!("npm run {}", fe.script), ctx, log, ) .await { return Some(outcome); } } None } /// One `npm` invocation for [`code_smoke_frontends`], bounded by the gate /// timeout so a wedged install cannot hold the whole pipeline (the enclosing /// `code_smoke` ceiling would catch it eventually, but this attributes the /// failure to the project that hung). async fn run_npm( dir: &std::path::Path, label: &str, args: &[&str], what: &str, ctx: &GateCtx, log: &GateLog, ) -> Option { log.line(&format!("$ {what}\n")).await; let mut cmd = tokio::process::Command::new("npm"); cmd.args(args).current_dir(dir).kill_on_drop(true); let ceiling = std::time::Duration::from_secs(ctx.cfg.gate_timeout_secs); // On the timeout branch the whole `run` future is dropped, which drops the // child; `kill_on_drop` is what turns that into an actual kill. let status = match tokio::time::timeout(ceiling, log.run(&mut cmd)).await { Ok(Ok((_stdout, _stderr, status))) => status, Ok(Err(e)) => { // A missing `npm` lands here. Fatal, not skipped: a build host // without Node cannot produce the bundle the release serves, and // silently passing is how the stale bundle shipped in the first place. log.line(&format!("{what} could not be spawned: {e}\n")) .await; return Some(GateOutcome::failed(GateFailure::SpawnFailed { message: format!("{what} in {label}: {e}"), })); } Err(_elapsed) => { log.line(&format!( "{what} timed out after {}s\n", ctx.cfg.gate_timeout_secs )) .await; return Some(GateOutcome::failed(GateFailure::CodeSmokeFrontend { dir: label.to_string(), exit_code: None, })); } }; if status.success() { return None; } Some(GateOutcome::failed(GateFailure::CodeSmokeFrontend { dir: label.to_string(), exit_code: status.code(), })) } /// Run the staged binary's DB-free docs integrity check (`MNW_CHECK_DOCS=1`). /// /// Returns `Some(failed)` if the check reports broken links, cannot be spawned, /// or overruns its ceiling; `None` when the docs are clean. Output streams to /// `log` either way. The 60s ceiling backstops the case where the staged binary /// predates the flag and would fall through to a normal (DB-needing) boot and /// hang. async fn code_smoke_docs_check(ctx: &GateCtx, bin: &str, log: &GateLog) -> Option { let server_dir = match ctx.worktree_for(GateKind::CodeSmoke) { Ok(w) => w.join("server"), Err(outcome) => return Some(outcome), }; log.line("---- docs check (MNW_CHECK_DOCS) ----\n").await; let mut cmd = tokio::process::Command::new(bin); cmd.env("MNW_CHECK_DOCS", "1") .current_dir(&server_dir) .kill_on_drop(true); let (stdout, _stderr, status) = match tokio::time::timeout(std::time::Duration::from_mins(1), log.run(&mut cmd)).await { Ok(Ok(out)) => out, Ok(Err(e)) => { log.line(&format!("docs check spawn failed: {e}\n")).await; return Some(GateOutcome::failed(GateFailure::SpawnFailed { message: e.to_string(), })); } Err(_elapsed) => { let reason = "docs check timed out after 60s (staged binary may predate MNW_CHECK_DOCS)" .to_string(); log.line(&format!("{reason}\n")).await; return Some(GateOutcome::failed(GateFailure::CodeSmokeSetup { reason })); } }; if status.success() { return None; } Some(GateOutcome::failed(GateFailure::CodeSmokeDocs { broken: parse_check_docs_broken_count(&stdout), })) } /// Best-effort parse of the broken-link count from the `MNW_CHECK_DOCS` sentinel /// line (`MNW_CHECK_DOCS: N broken link(s)`). Returns 0 if absent — the failure /// still stands, only the summary count is unknown. fn parse_check_docs_broken_count(stdout: &[u8]) -> u32 { let text = String::from_utf8_lossy(stdout); for line in text.lines() { if let Some(rest) = line.strip_prefix("MNW_CHECK_DOCS:") { for tok in rest.split_whitespace() { if let Ok(n) = tok.parse::() { return n; } } } } 0 } /// The createdb-to-dropdb interior of `code_smoke`: migrate+seed, then boot and /// probe. Returns the outcome without a `log_ref` (the caller attaches it after /// teardown). Never returns `Err` — spawn/child failures map to typed outcomes. async fn code_smoke_body(ctx: &GateCtx, bin: &str, db_url: &str, log: &GateLog) -> GateOutcome { let server_dir = match ctx.worktree_for(GateKind::CodeSmoke) { Ok(w) => w.join("server"), Err(outcome) => return outcome, }; // Phase 1: migrate-from-scratch + seed. `--seed-examples` loads config, // connects, runs migrations against the empty DB, seeds the catalog, exits. // A non-zero exit here is the "code is unsound" signal (broken migration, // seed error, or config-load failure). log.line("---- migrate + seed (--seed-examples) ----\n") .await; let mut seed_cmd = tokio::process::Command::new(bin); seed_cmd.arg("--seed-examples").current_dir(&server_dir); code_smoke_env(&mut seed_cmd, ctx, db_url); seed_cmd.env("ALLOW_EXAMPLE_SEED", "1").kill_on_drop(true); let seed_status = match log.run(&mut seed_cmd).await { Ok((_stdout, _stderr, status)) => status, Err(e) => { return GateOutcome::failed(GateFailure::SpawnFailed { message: e.to_string(), }); } }; if !seed_status.success() { return GateOutcome::failed(GateFailure::CodeSmokeSeed { exit_code: seed_status.code(), }); } // Phase 2: boot the real server against the now-migrated + seeded DB and // assert both startup signals: it logs `listening` (emitted just before the // socket bind) AND serves GET /health with a 200. The full stdout/stderr is // persisted for the operator either way. log.line("\n---- boot + probe /health ----\n").await; let mut serve_cmd = tokio::process::Command::new(bin); serve_cmd.current_dir(&server_dir); code_smoke_env(&mut serve_cmd, ctx, db_url); serve_cmd .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true); let mut child = match serve_cmd.spawn() { Ok(c) => c, Err(e) => { return GateOutcome::failed(GateFailure::SpawnFailed { message: e.to_string(), }); } }; // Stream stdout/stderr into the gate log (and out as chunk events) while // the probe loop runs below; the tasks finish when the pipes close (child // exits or is killed). The buffers they return are what the `listening` // assertion reads. let (stdout_task, stderr_task) = log.drain_pipes(&mut child); let probe_timeout = std::time::Duration::from_millis(500); let started = std::time::Instant::now(); let window = std::time::Duration::from_secs(CODE_SMOKE_READY_SECS); let mut probe_ok_after: Option = None; let mut last_probe_err = "never responded".to_string(); let mut early_exit = None; while started.elapsed() < window { if let Ok(Some(status)) = child.try_wait() { early_exit = Some(status); break; } match tokio::time::timeout(probe_timeout, probe_health(ctx.cfg.code_smoke_port)).await { Ok(Ok(())) => { probe_ok_after = Some(started.elapsed().as_millis() as u32); break; } Ok(Err(e)) => last_probe_err = e, Err(_) => last_probe_err = "probe timed out".to_string(), } tokio::time::sleep(std::time::Duration::from_millis(250)).await; } let exit = match early_exit { Some(status) => Some(status), None => { let e = child.try_wait().ok().flatten(); if e.is_none() { let _ = child.kill().await; } e } }; // Keep the serve run's own output so the `listening`-log assertion checks it // (not the seed run's, which exits before binding). The bytes are already in // the gate log; these buffers exist only for the assertion. let serve_stdout = stdout_task.await.unwrap_or_default(); let serve_stderr = stderr_task.await.unwrap_or_default(); let logged_listening = bytes_contain(&serve_stdout, b"listening") || bytes_contain(&serve_stderr, b"listening"); match (exit, probe_ok_after) { // Exited on its own within the window — panic / config error / bind fail. (Some(status), _) => GateOutcome::failed(classify::classify_boot_smoke(status.code())), // Stayed up and served /health. Assert both required startup signals: // the `listening` bind log AND the /health 200. (None, Some(after_ms)) if logged_listening => { GateOutcome::passed(PassNote::HealthyProbe { after_ms }) } // Served /health but the `listening` log never appeared. (None, Some(_)) => GateOutcome::failed(GateFailure::CodeSmokeNoListeningLog), // Stayed up but never served /health — started, not ready. (None, None) => GateOutcome::failed(GateFailure::BootHealthProbeFailed { last_error: last_probe_err, }), } } /// Substring search over raw bytes (the server's log output), for the /// `code_smoke` startup-log assertion. Avoids a lossy UTF-8 conversion of the /// whole buffer just to run `str::contains`. fn bytes_contain(haystack: &[u8], needle: &[u8]) -> bool { if needle.is_empty() || haystack.len() < needle.len() { return needle.is_empty(); } haystack.windows(needle.len()).any(|w| w == needle) } /// Apply the minimal env every `code_smoke` invocation shares: point the binary /// at the throwaway DB, force loopback (dev-mode config, no prod enforcement), /// hand it a dummy signing secret, and disable file scanning (no AV/YARA on the /// build host). The worktree is a clean git checkout, so no stray `.env` shadows /// these (and dotenvy never overrides already-set vars). /// /// This list has to carry EVERY var the server's `Config::from_env` treats as /// mandatory, because `code_smoke` is the only gate that reaches that function /// at all: `boot_smoke` and the docs check both short-circuit in `main` before /// config is loaded. So when the server makes a new var required, this is where /// it has to be answered, and nothing connects the two lists automatically. /// /// The values are deliberately throwaway. The gate asks whether this code can /// migrate, seed, boot and serve; whether a given deployment's env is complete /// is the `config_check_env_file` guard's job, on the node, against that node's /// real env file. /// /// `app.code_smoke_env` is prepended to all of this, for vars a product needs /// that sando has no business knowing about. The fixed set overwrites it on a /// collision, so nothing in a config file can redirect the gate off its own /// throwaway DB. fn code_smoke_env(cmd: &mut tokio::process::Command, ctx: &GateCtx, db_url: &str) { // `localhost`, not `127.0.0.1`, and the distinction is load-bearing. The // server derives its WebAuthn relying-party id from HOST_URL's host, and // `WebauthnBuilder::new` validates that id against `Url::domain()` — which // is `None` for an IP literal, so an origin of `http://127.0.0.1:` // fails with WebauthnError::Configuration before the server ever binds. It // is a real ceiling on the gate, not a preference: no IP-literal origin can // boot this binary. `localhost` is a domain, and matches the derived rp_id. // // HOST stays 127.0.0.1: that is the bind address, and the gate probes the // loopback address directly, so only the advertised origin changes. let origin = format!("http://localhost:{}", ctx.cfg.code_smoke_port); // Project-supplied extras go on first so the fixed set below overwrites any // key they collide on: what points this run at its throwaway DB and its // loopback port is not negotiable from a config file. cmd.envs(&ctx.cfg.code_smoke_env); cmd.env("DATABASE_URL", db_url) .env("HOST", "127.0.0.1") .env("PORT", ctx.cfg.code_smoke_port.to_string()) .env("HOST_URL", &origin) // Required unconditionally by Config::from_env. Pointing it at the smoke // server's own origin keeps every rendered media URL resolvable within // the gate; no request is ever made to it. .env("CDN_BASE_URL", &origin) .env("SIGNING_SECRET", CODE_SMOKE_SIGNING_SECRET) .env("SCAN_ENABLED", "false") .env("INSECURE_COOKIES", "1"); } /// The throwaway smoke DB name for a version: `sando_code_smoke_` with /// every non-alphanumeric char folded to `_` and lowercased, capped at Postgres' /// 63-byte identifier limit. Sanitized to `[a-z0-9_]` so it's safe to quote into /// DDL. Deterministic per version, so a stale DB from a killed run is reclaimed /// by the next run's `DROP DATABASE IF EXISTS` rather than accumulating. fn code_smoke_db_name(version: &Version) -> String { let mut name = String::from("sando_code_smoke_"); for c in version.to_string().chars() { name.push(if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' }); } name.truncate(63); name } #[cfg(test)] mod tests { use super::super::run; use super::*; use crate::domain::TierId; use crate::events::{self, Event}; use crate::gates::testkit::{ frontend_ctx, read_gate_log, resolving_ctx, test_gate_log, url_host_is_a_domain, }; use crate::topology::Gate; use sqlx::sqlite::SqlitePoolOptions; use std::collections::HashMap; #[test] fn parse_check_docs_broken_count_reads_the_sentinel() { // The failing sentinel carries the count. assert_eq!( parse_check_docs_broken_count(b"some log\nMNW_CHECK_DOCS: 3 broken link(s)\n"), 3 ); // Count with a preceding log line still parses (first bare int wins). assert_eq!( parse_check_docs_broken_count( b" broken link: a -> b\nMNW_CHECK_DOCS: 1 broken link(s)\n" ), 1 ); // The parser only runs on failure; the "ok" sentinel is never fed to it, // and its "(2" token is not a bare int, so it yields 0 harmlessly. assert_eq!( parse_check_docs_broken_count(b"MNW_CHECK_DOCS: ok (2 collision(s) reported)\n"), 0 ); // Absent sentinel -> 0; the failure still stands, only the count is lost. assert_eq!(parse_check_docs_broken_count(b"unrelated output"), 0); } /// Write a minimal npm project at `worktree/` whose `build` script /// exits with `exit_code`. Pre-creates `node_modules` so the gate skips /// `npm ci` — these tests are about the build step, not the network. fn fake_npm_project(worktree: &std::path::Path, dir: &str, exit_code: u8) { let root = worktree.join(dir); std::fs::create_dir_all(root.join("node_modules")).unwrap(); std::fs::write( root.join("package.json"), format!( r#"{{"name":"fake","version":"0.0.0","private":true, "scripts":{{"build":"exit {exit_code}"}}}}"# ), ) .unwrap(); } #[tokio::test] async fn frontend_gate_fails_on_a_build_error_and_names_the_project() { // The whole point of the gate: the app build scripts downgrade this to a // cargo::warning, so if it passes here nothing stops a stale bundle. let tmp = tempfile::tempdir().unwrap(); fake_npm_project(tmp.path(), "server/frontend", 0); fake_npm_project(tmp.path(), "multithreaded/frontend", 2); let ctx = frontend_ctx(tmp.path(), &["server/frontend", "multithreaded/frontend"]).await; let log = test_gate_log(&ctx).await; let outcome = code_smoke_frontends(&ctx, &log) .await .expect("a failing tsc must fail the gate"); let crate::outcome::GateStatus::Failed { failure } = &outcome.status else { panic!("expected a failure, got {:?}", outcome.status) }; assert!( matches!( failure, GateFailure::CodeSmokeFrontend { dir, exit_code: Some(2) } if dir == "multithreaded/frontend" ), "got: {failure:?}" ); // The passing project ran first; its output belongs in the log too. let text = read_gate_log(&ctx, log).await; assert!(text.contains("server/frontend"), "log: {text}"); } #[tokio::test] async fn frontend_gate_passes_when_every_project_builds() { let tmp = tempfile::tempdir().unwrap(); fake_npm_project(tmp.path(), "server/frontend", 0); let ctx = frontend_ctx(tmp.path(), &["server/frontend"]).await; let log = test_gate_log(&ctx).await; assert!( code_smoke_frontends(&ctx, &log).await.is_none(), "a clean build must not fail the gate" ); } /// The point of routing `code_smoke` through `LiveLog`: its output reaches /// the operator *while* the gate runs, as `GateLogChunk` events, instead of /// appearing all at once when the gate finishes. #[tokio::test] async fn code_smoke_streams_chunks_as_it_runs() { let tmp = tempfile::tempdir().unwrap(); fake_npm_project(tmp.path(), "server/frontend", 0); let ctx = frontend_ctx(tmp.path(), &["server/frontend"]).await; let mut rx = ctx.events.subscribe_logs(); let log = GateLog::open(&ctx, GateRunId(7), GateKind::CodeSmoke).await; assert!(code_smoke_frontends(&ctx, &log).await.is_none()); log.close().await; let mut chunks = Vec::new(); while let Ok(envelope) = rx.try_recv() { if let Event::GateLogChunk { run_id, seq, text } = envelope.event { assert_eq!(run_id, GateRunId(7)); chunks.push((seq, text)); } } assert!(!chunks.is_empty(), "no chunk ever reached the bus"); // Sequence numbers are per-run and monotonic across every step of the // gate, which is why the whole gate shares one sink. let seqs: Vec = chunks.iter().map(|(seq, _)| *seq).collect(); assert!( seqs.windows(2).all(|w| w[0] < w[1]), "chunk seq must be monotonic, got {seqs:?}" ); let joined: String = chunks.into_iter().map(|(_, text)| text).collect(); assert!(joined.contains("server/frontend"), "chunks: {joined}"); } #[tokio::test] async fn frontend_gate_skips_a_project_absent_from_the_worktree() { // Rebuilding an older sha that predates the frontend must stay possible. let tmp = tempfile::tempdir().unwrap(); let ctx = frontend_ctx(tmp.path(), &["multithreaded/frontend"]).await; let log = test_gate_log(&ctx).await; assert!(code_smoke_frontends(&ctx, &log).await.is_none()); assert!( read_gate_log(&ctx, log).await.contains("skipping"), "the skip must be visible in the log" ); } #[test] fn bytes_contain_matches_listening_in_log_output() { // JSON release log carries the message field verbatim. assert!(bytes_contain( br#"{"timestamp":"...","level":"INFO","fields":{"message":"listening","addr":"127.0.0.1:18182"}}"#, b"listening", )); // Human-format dev log. assert!(bytes_contain( b"2026-07-17 INFO makenotwork: listening addr=127.0.0.1:18182", b"listening" )); assert!(!bytes_contain( b"migrations complete; seeding catalog", b"listening" )); assert!(!bytes_contain(b"", b"listening")); } #[tokio::test] async fn code_smoke_env_supplies_every_mandatory_server_var() { // code_smoke is the only gate that reaches the server's Config::from_env // (boot_smoke and the docs check short-circuit before it), so anything // that function requires has to be answered here. CDN_BASE_URL became // mandatory 13 days after this env was written and went unnoticed until // the gate first ran on the host; this test is what makes the next one // fail here instead of in a promote. let ctx = resolving_ctx("/w/abc", &[]); let mut cmd = tokio::process::Command::new("true"); code_smoke_env(&mut cmd, &ctx, "postgres:///throwaway"); let set: std::collections::HashMap = cmd .as_std() .get_envs() .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string()))) .collect(); for key in [ "DATABASE_URL", "HOST", "PORT", "HOST_URL", "CDN_BASE_URL", "SIGNING_SECRET", ] { assert!(set.contains_key(key), "code_smoke_env must set {key}"); assert!(!set[key].is_empty(), "{key} must not be empty"); } // Loopback, so Config::from_env's is_production branch stays false and // the gate never trips MissingPublicBucket for want of an S3 bucket. assert!(set["HOST_URL"].starts_with("http://localhost")); // Not an IP literal: the server derives its WebAuthn rp_id from this // host, and WebauthnBuilder rejects an origin whose Url::domain() is // None, which is every IP address. An IP here cannot boot the server. assert!( url_host_is_a_domain(&set["HOST_URL"]), "HOST_URL host must be a domain, not an IP literal: {}", set["HOST_URL"], ); assert_eq!(set["HOST"], "127.0.0.1"); // The signing secret has to clear the server's 32-char floor, or the // gate fails with WeakSigningSecret instead of testing anything. assert!(set["SIGNING_SECRET"].len() >= 32); } #[tokio::test] async fn code_smoke_env_passes_extras_through_but_never_lets_them_win() { // The pass-through exists so a product can hand its own binary a var // sando has no business knowing about (MNW points SEED_MEDIA_CACHE at a // persistent dir, because PrivateTmp=true made the seed's media cache // cold on every build). What it must never become is a way to aim a // smoke run at a real database. let mut cfg = crate::config::AppConfig::for_tests(); cfg.code_smoke_env = [ ( "SEED_MEDIA_CACHE".to_string(), "/srv/sando/seed".to_string(), ), ( "DATABASE_URL".to_string(), "postgres://prod-1/makenotwork".to_string(), ), ] .into_iter() .collect(); let mut ctx = resolving_ctx("/w/abc", &[]); ctx.cfg = std::sync::Arc::new(cfg); let mut cmd = tokio::process::Command::new("true"); code_smoke_env(&mut cmd, &ctx, "postgres:///throwaway"); let set: std::collections::HashMap = cmd .as_std() .get_envs() .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string()))) .collect(); assert_eq!(set["SEED_MEDIA_CACHE"], "/srv/sando/seed"); assert_eq!( set["DATABASE_URL"], "postgres:///throwaway", "the fixed set must overwrite a colliding extra, or a config typo \ could point code_smoke at a real database", ); } #[test] fn code_smoke_db_name_sanitizes_and_caps() { assert_eq!( code_smoke_db_name(&"0.9.6".parse().unwrap()), "sando_code_smoke_0_9_6" ); // Pre-release/build metadata folds to underscores; result stays [a-z0-9_]. let n = code_smoke_db_name(&"1.0.0-rc.1+build".parse().unwrap()); assert_eq!(n, "sando_code_smoke_1_0_0_rc_1_build"); assert!( n.bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') ); assert!(n.len() <= 63); } /// code_smoke is Blocked (not Failed) when the daemon has no scratch_db_url: /// there's no cluster to create the throwaway DB in, and that's an operator /// precondition, rendered yellow — the same shape as migration_dry_run. #[tokio::test] async fn code_smoke_blocks_without_scratch_db_url() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); crate::db::migrate(&pool).await.unwrap(); sqlx::query("INSERT INTO tiers (name, ord, provisioned, canary) VALUES ('host', 0, 1, 'sequential')") .execute(&pool).await.unwrap(); sqlx::query("INSERT INTO tier_state (tier) VALUES ('host')") .execute(&pool) .await .unwrap(); sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('0.1.0', 'abc1234', '2026-01-01T00:00:00Z', '/tmp/x')") .execute(&pool).await.unwrap(); let cfg = std::sync::Arc::new(crate::config::AppConfig::for_tests()); // scratch_db_url: None let ctx = GateCtx { public_url: None, pool: pool.clone(), cfg, tier: TierId::new("host"), version: "0.1.0".parse().unwrap(), worktree: Some(std::path::PathBuf::from("/tmp/unused")), bundle: None, events: events::channel(), nodes: Vec::new(), build_id: None, aux_dirs: HashMap::new(), }; let out = run(&ctx, &Gate::CodeSmoke).await.unwrap(); assert_eq!(out.status_str(), "blocked"); assert!(!out.is_passed()); let row: (Option, Option) = sqlx::query_as("SELECT status, outcome_json FROM gate_runs ORDER BY id DESC LIMIT 1") .fetch_one(&pool) .await .unwrap(); assert_eq!(row.0.as_deref(), Some("blocked")); let json: serde_json::Value = serde_json::from_str(row.1.as_deref().unwrap()).unwrap(); assert_eq!(json["status"]["blocker"]["kind"], "scratch_db_url_unset"); } }