Skip to main content

max / makenotwork

Add code_smoke host gate: migrate-from-scratch + seed + serve /health Runs first on the host tier, before cargo_test and migration_dry_run. Boots the freshly-built binary against a throwaway empty DB it migrates from scratch and seeds the example catalog into, then asserts the server logs 'listening' and serves GET /health 200 against the 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 mode: --seed-examples migrates+seeds+exits, a plain boot serves. Both run with CWD at the server crate root (so assumptions.toml/site-docs resolve) and a loopback HOST_URL (so config stays in dev mode, no CDN/S3/signing enforcement). The throwaway DB is created from the scratch cluster and dropped on every exit path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-17 18:58 UTC
Commit: bb06d0e2665a8b13bc65640e84d405d8e2a8bc39
Parent: 98c28a5
9 files changed, +474 insertions, -5 deletions
@@ -38,6 +38,13 @@ pub struct Config {
38 38 /// gate knows where to probe; builds are serialized so there's no contention.
39 39 #[serde(default = "default_boot_smoke_port")]
40 40 pub boot_smoke_port: u16,
41 + /// Loopback port the `code_smoke` gate tells the freshly-built binary to
42 + /// bind (`HOST=127.0.0.1 PORT=<this>`), then probes `GET /health` on after
43 + /// migrating + seeding a throwaway DB. Separate from `boot_smoke_port` only
44 + /// for clarity — the two gates never run concurrently (builds are
45 + /// serialized).
46 + #[serde(default = "default_code_smoke_port")]
47 + pub code_smoke_port: u16,
41 48 /// Names of cargo bin targets the server crate produces (files under
42 49 /// `target/release/`). First entry is the primary unit (referenced from
43 50 /// the systemd unit's ExecStart). Defaults to `["server"]`; MNW ships
@@ -118,6 +125,7 @@ fn default_bin_names() -> Vec<String> { vec!["server".into()] }
118 125 fn default_scratch_owner_role() -> String { "makenotwork".into() }
119 126 fn default_logs_root() -> PathBuf { PathBuf::from("/srv/sando/logs") }
120 127 fn default_boot_smoke_port() -> u16 { 18181 }
128 + fn default_code_smoke_port() -> u16 { 18182 }
121 129 fn default_gate_timeout_secs() -> u64 { 2400 }
122 130
123 131 impl Config {
@@ -164,6 +172,7 @@ impl Config {
164 172 scratch_db_url: None,
165 173 scratch_owner_role: default_scratch_owner_role(),
166 174 boot_smoke_port: default_boot_smoke_port(),
175 + code_smoke_port: default_code_smoke_port(),
167 176 bin_names: vec!["server".into()],
168 177 logs_root: PathBuf::from("/tmp/sando-test-logs"),
169 178 release_contents: Vec::new(),
@@ -238,6 +238,13 @@ impl<'r> sqlx::Decode<'r, Sqlite> for GitSha {
238 238 pub enum GateKind {
239 239 CargoTest,
240 240 MigrationDryRun,
241 + /// Boots the freshly-built binary against a throwaway *empty* DB it
242 + /// migrates from scratch, seeds the example catalog into, and probes
243 + /// `GET /health` on — a fast, infra-light "is the code sound" check that
244 + /// runs first, so a later `cargo_test`/`migration_dry_run` red reads as an
245 + /// environment problem, not a code one. Distinct from `BootSmoke`, which
246 + /// boots the staged artifact in a minimal no-DB mode.
247 + CodeSmoke,
241 248 BootSmoke,
242 249 /// Post-deploy readiness of the *deployed nodes* (distinct from `BootSmoke`,
243 250 /// which boots the staged artifact on the build host). Probes each node's
@@ -253,6 +260,7 @@ impl GateKind {
253 260 match self {
254 261 GateKind::CargoTest => "cargo_test",
255 262 GateKind::MigrationDryRun => "migration_dry_run",
263 + GateKind::CodeSmoke => "code_smoke",
256 264 GateKind::BootSmoke => "boot_smoke",
257 265 GateKind::NodeHealth => "node_health",
258 266 GateKind::BurnIn => "burn_in",
@@ -271,6 +279,7 @@ impl FromStr for GateKind {
271 279 match s {
272 280 "cargo_test" => Ok(GateKind::CargoTest),
273 281 "migration_dry_run" => Ok(GateKind::MigrationDryRun),
282 + "code_smoke" => Ok(GateKind::CodeSmoke),
274 283 "boot_smoke" => Ok(GateKind::BootSmoke),
275 284 "node_health" => Ok(GateKind::NodeHealth),
276 285 "burn_in" => Ok(GateKind::BurnIn),
@@ -419,6 +428,7 @@ mod tests {
419 428 for k in [
420 429 GateKind::CargoTest,
421 430 GateKind::MigrationDryRun,
431 + GateKind::CodeSmoke,
422 432 GateKind::BootSmoke,
423 433 GateKind::BurnIn,
424 434 GateKind::ManualConfirm,
@@ -88,6 +88,22 @@ pub async fn run(ctx: &GateCtx, gate: &Gate) -> Result<GateOutcome> {
88 88 .with_log_ref(LogRef::new(&ctx.version, GateKind::MigrationDryRun))),
89 89 }
90 90 }
91 + // code_smoke boots the real binary (migrate-from-scratch + seed + serve),
92 + // any step of which could wedge; bound the whole gate here. Both child
93 + // processes set kill_on_drop, so a timeout-drop can't orphan them. A
94 + // timeout leaves the throwaway DB behind; the next run's createdb drops
95 + // it first (DROP IF EXISTS), same as migration_dry_run's scratch reset.
96 + Gate::CodeSmoke => {
97 + let ceiling = std::time::Duration::from_secs(ctx.cfg.gate_timeout_secs);
98 + match tokio::time::timeout(ceiling, code_smoke(ctx)).await {
99 + Ok(res) => res,
100 + Err(_elapsed) => Ok(GateOutcome::failed(GateFailure::Timeout {
101 + gate: GateKind::CodeSmoke,
102 + after_s: ctx.cfg.gate_timeout_secs as u32,
103 + })
104 + .with_log_ref(LogRef::new(&ctx.version, GateKind::CodeSmoke))),
105 + }
106 + }
91 107 Gate::BootSmoke => boot_smoke(ctx, run_id).await,
92 108 Gate::NodeHealth => node_health(ctx).await,
93 109 Gate::BurnIn { hours } => burn_in(ctx, *hours).await,
@@ -613,6 +629,315 @@ fn shell_escape(s: &str) -> String {
613 629 format!("'{}'", s.replace('\'', "'\\''"))
614 630 }
615 631
632 + // ---- code_smoke ----
633 +
634 + /// A 32+ char throwaway signing secret for the `code_smoke` boot. The server
635 + /// only enforces length (>= 32) outside production, and code_smoke's loopback
636 + /// `HOST_URL` keeps it in dev mode, so the value is irrelevant beyond that.
637 + const CODE_SMOKE_SIGNING_SECRET: &str = "sando-code-smoke-dummy-signing-secret-0000000000";
638 +
639 + /// Seconds to wait for the real server to come up and serve `GET /health`
640 + /// during `code_smoke`. Longer than `boot_smoke`'s 3s: this boots the *full*
641 + /// server (config, pool, session store, webauthn, doc load, app build), not the
642 + /// minimal no-DB smoke server. The whole gate is also bounded by
643 + /// `gate_timeout_secs` at the dispatcher.
644 + const CODE_SMOKE_READY_SECS: u64 = 30;
645 +
646 + /// `code_smoke` — the first host gate. Boots the freshly-built binary against a
647 + /// throwaway *empty* DB it migrates from scratch and seeds the example catalog
648 + /// into, then proves the real server serves `GET /health` against that
649 + /// nonempty DB. Fast and infra-light (one local Postgres, no prod-dump restore,
650 + /// no scratch-role reset, no external services), so a green here proves the
651 + /// code is sound and isolates a later `cargo_test`/`migration_dry_run` red as an
652 + /// environment problem rather than a code one.
653 + ///
654 + /// Reuses existing binary entrypoints, so the server needs no smoke-specific
655 + /// mode: `<bin> --seed-examples` loads config, connects, migrates from scratch,
656 + /// seeds, and exits; a plain `<bin>` then serves the real app. Both run with CWD
657 + /// at the server crate root so `docs/business/assumptions.toml` + `site-docs/`
658 + /// resolve (a missing assumptions file panics real startup), and with a loopback
659 + /// `HOST_URL` so config stays in dev mode (no CDN/S3/signing-secret prod
660 + /// enforcement). The seed's host allowlist already admits `127.0.0.1`, and the
661 + /// fresh DB trivially satisfies its no-real-users guard.
662 + async fn code_smoke(ctx: &GateCtx) -> Result<GateOutcome> {
663 + let log_ref = LogRef::new(&ctx.version, GateKind::CodeSmoke);
664 + let mut log_buf: Vec<u8> = Vec::new();
665 + let finish = |outcome: GateOutcome, buf: Vec<u8>| async move {
666 + persist_gate_log(ctx, GateKind::CodeSmoke, &buf, &[]).await;
667 + outcome
668 + };
669 +
670 + let Some(scratch_url) = ctx.cfg.scratch_db_url.as_deref() else {
671 + log_buf.extend_from_slice(b"scratch_db_url unset in daemon config\n");
672 + return Ok(finish(
673 + GateOutcome::blocked(GateBlocker::ScratchDbUrlUnset).with_log_ref(log_ref),
674 + log_buf,
675 + ).await);
676 + };
677 +
678 + // The staged binary (set by build_and_run_host before gating). code_smoke
679 + // runs first among the host gates, but staging precedes all gating, so the
680 + // artifact path is already recorded.
681 + let bin: Option<(String,)> = sqlx::query_as(
682 + "SELECT artifact_path FROM versions WHERE version = ?",
683 + )
684 + .bind(&ctx.version)
685 + .fetch_optional(&ctx.pool)
686 + .await?;
687 + let Some((bin,)) = bin else {
688 + return Ok(finish(
689 + GateOutcome::blocked(GateBlocker::ArtifactMissing { version: ctx.version.clone() })
690 + .with_log_ref(log_ref),
691 + log_buf,
692 + ).await);
693 + };
694 +
695 + let dbname = code_smoke_db_name(&ctx.version);
696 + let maintenance_url = pg_url_with_dbname(scratch_url, "postgres");
697 + let throwaway_url = pg_url_with_dbname(scratch_url, &dbname);
698 +
699 + // Create the throwaway DB (dropping any stale one from a killed prior run).
700 + log_buf.extend_from_slice(format!("---- createdb {dbname} ----\n").as_bytes());
701 + if let Err(e) = pg_create_db(&maintenance_url, &dbname).await {
702 + let reason = format!("createdb {dbname}: {e}");
703 + log_buf.extend_from_slice(reason.as_bytes());
704 + return Ok(finish(
705 + GateOutcome::failed(GateFailure::CodeSmokeSetup { reason }).with_log_ref(log_ref),
706 + log_buf,
707 + ).await);
708 + }
709 +
710 + // Everything past createdb must drop the DB on the way out, pass or fail.
711 + let outcome = code_smoke_body(ctx, &bin, &throwaway_url, &mut log_buf).await;
712 +
713 + log_buf.extend_from_slice(format!("\n---- dropdb {dbname} ----\n").as_bytes());
714 + if let Err(e) = pg_drop_db(&maintenance_url, &dbname).await {
715 + // A teardown miss must not turn a passing gate red — log it and move on.
716 + // The next run's createdb drops it first anyway.
717 + tracing::warn!(error = %e, db = %dbname, "code_smoke: dropdb failed; next run will reclaim it");
718 + log_buf.extend_from_slice(format!("dropdb warning (non-fatal): {e}").as_bytes());
719 + }
720 +
721 + Ok(finish(outcome.with_log_ref(log_ref), log_buf).await)
722 + }
723 +
724 + /// The createdb-to-dropdb interior of `code_smoke`: migrate+seed, then boot and
725 + /// probe. Returns the outcome without a `log_ref` (the caller attaches it after
726 + /// teardown). Never returns `Err` — spawn/child failures map to typed outcomes.
727 + async fn code_smoke_body(
728 + ctx: &GateCtx,
729 + bin: &str,
730 + db_url: &str,
731 + log_buf: &mut Vec<u8>,
732 + ) -> GateOutcome {
733 + let server_dir = ctx.worktree.join("server");
734 +
735 + // Phase 1: migrate-from-scratch + seed. `--seed-examples` loads config,
736 + // connects, runs migrations against the empty DB, seeds the catalog, exits.
737 + // A non-zero exit here is the "code is unsound" signal (broken migration,
738 + // seed error, or config-load failure).
739 + log_buf.extend_from_slice(b"---- migrate + seed (--seed-examples) ----\n");
740 + let mut seed_cmd = tokio::process::Command::new(bin);
741 + seed_cmd.arg("--seed-examples").current_dir(&server_dir);
742 + code_smoke_env(&mut seed_cmd, ctx, db_url);
743 + seed_cmd
744 + .env("ALLOW_EXAMPLE_SEED", "1")
745 + .stdout(std::process::Stdio::piped())
746 + .stderr(std::process::Stdio::piped())
747 + .kill_on_drop(true);
748 + let seed_out = match seed_cmd.output().await {
749 + Ok(o) => o,
750 + Err(e) => return GateOutcome::failed(GateFailure::SpawnFailed { message: e.to_string() }),
751 + };
752 + log_buf.extend_from_slice(&seed_out.stdout);
753 + log_buf.extend_from_slice(&seed_out.stderr);
754 + if !seed_out.status.success() {
755 + return GateOutcome::failed(GateFailure::CodeSmokeSeed {
756 + exit_code: seed_out.status.code(),
757 + });
758 + }
759 +
760 + // Phase 2: boot the real server against the now-migrated + seeded DB and
761 + // assert both startup signals: it logs `listening` (emitted just before the
762 + // socket bind) AND serves GET /health with a 200. The full stdout/stderr is
763 + // persisted for the operator either way.
764 + log_buf.extend_from_slice(b"\n---- boot + probe /health ----\n");
765 + let mut serve_cmd = tokio::process::Command::new(bin);
766 + serve_cmd.current_dir(&server_dir);
767 + code_smoke_env(&mut serve_cmd, ctx, db_url);
768 + serve_cmd
769 + .stdout(std::process::Stdio::piped())
770 + .stderr(std::process::Stdio::piped())
771 + .kill_on_drop(true);
772 + let mut child = match serve_cmd.spawn() {
773 + Ok(c) => c,
774 + Err(e) => return GateOutcome::failed(GateFailure::SpawnFailed { message: e.to_string() }),
775 + };
776 +
777 + // Drain stdout/stderr into buffers concurrently; the tasks finish when the
778 + // pipes close (child exits or is killed).
779 + let stdout_task = tokio::spawn(drain_to_end(child.stdout.take()));
780 + let stderr_task = tokio::spawn(drain_to_end(child.stderr.take()));
781 +
782 + let probe_timeout = std::time::Duration::from_millis(500);
783 + let started = std::time::Instant::now();
784 + let window = std::time::Duration::from_secs(CODE_SMOKE_READY_SECS);
785 + let mut probe_ok_after: Option<u32> = None;
786 + let mut last_probe_err = "never responded".to_string();
787 + let mut early_exit = None;
788 + while started.elapsed() < window {
789 + if let Ok(Some(status)) = child.try_wait() {
790 + early_exit = Some(status);
791 + break;
792 + }
793 + match tokio::time::timeout(probe_timeout, probe_health(ctx.cfg.code_smoke_port)).await {
794 + Ok(Ok(())) => {
795 + probe_ok_after = Some(started.elapsed().as_millis() as u32);
796 + break;
797 + }
798 + Ok(Err(e)) => last_probe_err = e,
799 + Err(_) => last_probe_err = "probe timed out".to_string(),
800 + }
801 + tokio::time::sleep(std::time::Duration::from_millis(250)).await;
802 + }
803 +
804 + let exit = match early_exit {
805 + Some(status) => Some(status),
806 + None => {
807 + let e = child.try_wait().ok().flatten();
808 + if e.is_none() {
809 + let _ = child.kill().await;
810 + }
811 + e
812 + }
813 + };
814 + // Keep the serve run's own output so the `listening`-log assertion checks it
815 + // (not the seed run's, which exits before binding) — then fold it into the
816 + // persisted gate log.
817 + let serve_stdout = stdout_task.await.unwrap_or_default();
818 + let serve_stderr = stderr_task.await.unwrap_or_default();
819 + let logged_listening = bytes_contain(&serve_stdout, b"listening")
820 + || bytes_contain(&serve_stderr, b"listening");
821 + log_buf.extend_from_slice(&serve_stdout);
822 + log_buf.extend_from_slice(&serve_stderr);
823 +
824 + match (exit, probe_ok_after) {
825 + // Exited on its own within the window — panic / config error / bind fail.
826 + (Some(status), _) => GateOutcome::failed(classify::classify_boot_smoke(status.code())),
827 + // Stayed up and served /health. Assert both required startup signals:
828 + // the `listening` bind log AND the /health 200.
829 + (None, Some(after_ms)) if logged_listening => {
830 + GateOutcome::passed(PassNote::HealthyProbe { after_ms })
831 + }
832 + // Served /health but the `listening` log never appeared.
833 + (None, Some(_)) => GateOutcome::failed(GateFailure::CodeSmokeNoListeningLog),
834 + // Stayed up but never served /health — started, not ready.
835 + (None, None) => GateOutcome::failed(GateFailure::BootHealthProbeFailed {
836 + last_error: last_probe_err,
837 + }),
838 + }
839 + }
840 +
841 + /// Substring search over raw bytes (the server's log output), for the
842 + /// `code_smoke` startup-log assertion. Avoids a lossy UTF-8 conversion of the
843 + /// whole buffer just to run `str::contains`.
844 + fn bytes_contain(haystack: &[u8], needle: &[u8]) -> bool {
845 + if needle.is_empty() || haystack.len() < needle.len() {
846 + return needle.is_empty();
847 + }
848 + haystack.windows(needle.len()).any(|w| w == needle)
849 + }
850 +
851 + /// Apply the minimal env every `code_smoke` invocation shares: point the binary
852 + /// at the throwaway DB, force loopback (dev-mode config, no prod enforcement),
853 + /// hand it a dummy signing secret, and disable file scanning (no AV/YARA on the
854 + /// build host). The worktree is a clean git checkout, so no stray `.env` shadows
855 + /// these (and dotenvy never overrides already-set vars).
856 + fn code_smoke_env(cmd: &mut tokio::process::Command, ctx: &GateCtx, db_url: &str) {
857 + cmd.env("DATABASE_URL", db_url)
858 + .env("HOST", "127.0.0.1")
859 + .env("PORT", ctx.cfg.code_smoke_port.to_string())
860 + .env("HOST_URL", format!("http://127.0.0.1:{}", ctx.cfg.code_smoke_port))
861 + .env("SIGNING_SECRET", CODE_SMOKE_SIGNING_SECRET)
862 + .env("SCAN_ENABLED", "false")
863 + .env("INSECURE_COOKIES", "1");
864 + }
865 +
866 + /// Read a child pipe to EOF into a buffer, swallowing IO errors (a truncated
867 + /// read just yields a shorter log). Used to collect `code_smoke`'s server output
868 + /// while it runs, without a `LiveLog`/run-id (this gate persists once at the end).
869 + async fn drain_to_end<R>(stream: Option<R>) -> Vec<u8>
870 + where
871 + R: tokio::io::AsyncRead + Unpin + Send + 'static,
872 + {
873 + let mut out = Vec::new();
874 + if let Some(mut s) = stream {
875 + let _ = s.read_to_end(&mut out).await;
876 + }
877 + out
878 + }
879 +
880 + /// The throwaway smoke DB name for a version: `sando_code_smoke_<version>` with
881 + /// every non-alphanumeric char folded to `_` and lowercased, capped at Postgres'
882 + /// 63-byte identifier limit. Sanitized to `[a-z0-9_]` so it's safe to quote into
883 + /// DDL. Deterministic per version, so a stale DB from a killed run is reclaimed
884 + /// by the next run's `DROP DATABASE IF EXISTS` rather than accumulating.
885 + fn code_smoke_db_name(version: &Version) -> String {
886 + let mut name = String::from("sando_code_smoke_");
887 + for c in version.to_string().chars() {
888 + name.push(if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' });
889 + }
890 + name.truncate(63);
891 + name
892 + }
893 +
894 + /// Rewrite a `postgres://` URL to point at database `dbname`, preserving scheme,
895 + /// userinfo, host/port, and any query (e.g. the socket `?host=/var/run/postgresql`
896 + /// form) + fragment. Used to derive the maintenance connection (`postgres`) and
897 + /// the throwaway smoke DB URL from the configured `scratch_db_url`.
898 + fn pg_url_with_dbname(url: &str, dbname: &str) -> String {
899 + let Some(after_scheme) = url.find("://").map(|i| i + 3) else {
900 + return url.to_string();
901 + };
902 + let rest = &url[after_scheme..];
903 + // Authority ends at the first '/', '?' or '#'; whatever follows is the
904 + // path (the old dbname) plus an optional query/fragment we must keep.
905 + let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
906 + let authority = &rest[..auth_end];
907 + let tail = &rest[auth_end..];
908 + let query_and_frag = match tail.find(['?', '#']) {
909 + Some(i) => &tail[i..],
910 + None => "",
911 + };
912 + format!("{}{}/{}{}", &url[..after_scheme], authority, dbname, query_and_frag)
913 + }
914 +
915 + /// Create the throwaway smoke DB on the cluster `maintenance_url` points at,
916 + /// dropping any stale one first. `dbname` is sanitized to `[a-z0-9_]` by
917 + /// `code_smoke_db_name`, so quoting it is sufficient. `CREATE DATABASE` cannot
918 + /// run inside a transaction, so these go through the simple-query protocol (a
919 + /// raw `&str` execute), matching `reset_scratch`.
920 + async fn pg_create_db(maintenance_url: &str, dbname: &str) -> Result<()> {
921 + use sqlx::postgres::PgPoolOptions;
922 + use sqlx::Executor;
923 + let pool = PgPoolOptions::new().max_connections(1).connect(maintenance_url).await?;
924 + pool.execute(format!("DROP DATABASE IF EXISTS \"{dbname}\" WITH (FORCE)").as_str()).await?;
925 + pool.execute(format!("CREATE DATABASE \"{dbname}\"").as_str()).await?;
926 + pool.close().await;
927 + Ok(())
928 + }
929 +
930 + /// Drop the throwaway smoke DB, forcing off any lingering connection (the killed
931 + /// server's pool). Best-effort at the call site — a failure is logged, not fatal.
932 + async fn pg_drop_db(maintenance_url: &str, dbname: &str) -> Result<()> {
933 + use sqlx::postgres::PgPoolOptions;
934 + use sqlx::Executor;
935 + let pool = PgPoolOptions::new().max_connections(1).connect(maintenance_url).await?;
936 + pool.execute(format!("DROP DATABASE IF EXISTS \"{dbname}\" WITH (FORCE)").as_str()).await?;
937 + pool.close().await;
938 + Ok(())
939 + }
940 +
616 941 async fn boot_smoke(ctx: &GateCtx, run_id: GateRunId) -> Result<GateOutcome> {
617 942 let bin: Option<(String,)> = sqlx::query_as(
618 943 "SELECT artifact_path FROM versions WHERE version = ?",
@@ -1305,6 +1630,92 @@ mod tests {
1305 1630 assert_eq!(percent_decode("ab%zz"), "ab%zz");
1306 1631 }
1307 1632
1633 + #[test]
1634 + fn pg_url_with_dbname_rewrites_the_database() {
1635 + // user:pass@host:port/db?query — swap db, keep everything else.
1636 + assert_eq!(
1637 + pg_url_with_dbname("postgres://sando:pw@db.host:5432/sando_scratch?sslmode=require", "postgres"),
1638 + "postgres://sando:pw@db.host:5432/postgres?sslmode=require",
1639 + );
1640 + // Socket form: the query carries `host=/var/run/postgresql` and must survive.
1641 + assert_eq!(
1642 + pg_url_with_dbname("postgres:///sando_scratch?host=/var/run/postgresql", "sando_code_smoke_0_9_6"),
1643 + "postgres:///sando_code_smoke_0_9_6?host=/var/run/postgresql",
1644 + );
1645 + // Plain host/db, no query.
1646 + assert_eq!(
1647 + pg_url_with_dbname("postgres://localhost/scratch", "postgres"),
1648 + "postgres://localhost/scratch".replace("scratch", "postgres"),
1649 + );
1650 + // No authority, no query (loopback socket, default db path).
1651 + assert_eq!(
1652 + pg_url_with_dbname("postgres:///scratch", "postgres"),
1653 + "postgres:///postgres",
1654 + );
1655 + }
1656 +
1657 + #[test]
1658 + fn bytes_contain_matches_listening_in_log_output() {
1659 + // JSON release log carries the message field verbatim.
1660 + assert!(bytes_contain(
1661 + br#"{"timestamp":"...","level":"INFO","fields":{"message":"listening","addr":"127.0.0.1:18182"}}"#,
1662 + b"listening",
1663 + ));
1664 + // Human-format dev log.
1665 + assert!(bytes_contain(b"2026-07-17 INFO makenotwork: listening addr=127.0.0.1:18182", b"listening"));
1666 + assert!(!bytes_contain(b"migrations complete; seeding catalog", b"listening"));
1667 + assert!(!bytes_contain(b"", b"listening"));
1668 + }
1669 +
1670 + #[test]
1671 + fn code_smoke_db_name_sanitizes_and_caps() {
1672 + assert_eq!(code_smoke_db_name(&"0.9.6".parse().unwrap()), "sando_code_smoke_0_9_6");
1673 + // Pre-release/build metadata folds to underscores; result stays [a-z0-9_].
1674 + let n = code_smoke_db_name(&"1.0.0-rc.1+build".parse().unwrap());
1675 + assert_eq!(n, "sando_code_smoke_1_0_0_rc_1_build");
1676 + assert!(n.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'));
1677 + assert!(n.len() <= 63);
1678 + }
1679 +
1680 + /// code_smoke is Blocked (not Failed) when the daemon has no scratch_db_url:
1681 + /// there's no cluster to create the throwaway DB in, and that's an operator
1682 + /// precondition, rendered yellow — the same shape as migration_dry_run.
1683 + #[tokio::test]
1684 + async fn code_smoke_blocks_without_scratch_db_url() {
1685 + let pool = SqlitePoolOptions::new()
1686 + .max_connections(1)
1687 + .connect("sqlite::memory:")
1688 + .await
1689 + .unwrap();
1690 + crate::db::migrate(&pool).await.unwrap();
1691 + sqlx::query("INSERT INTO tiers (name, ord, provisioned, canary) VALUES ('host', 0, 1, 'sequential')")
1692 + .execute(&pool).await.unwrap();
1693 + sqlx::query("INSERT INTO tier_state (tier) VALUES ('host')")
1694 + .execute(&pool).await.unwrap();
1695 + sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('0.1.0', 'abc1234', '2026-01-01T00:00:00Z', '/tmp/x')")
1696 + .execute(&pool).await.unwrap();
1697 +
1698 + let cfg = std::sync::Arc::new(crate::config::Config::for_tests()); // scratch_db_url: None
1699 + let ctx = GateCtx {
1700 + pool: pool.clone(),
1701 + cfg,
1702 + tier: TierId::new("host"),
1703 + version: "0.1.0".parse().unwrap(),
1704 + worktree: std::path::PathBuf::from("/tmp/unused"),
1705 + events: events::channel(),
1706 + nodes: Vec::new(),
1707 + };
1708 + let out = run(&ctx, &Gate::CodeSmoke).await.unwrap();
1709 + assert_eq!(out.status_str(), "blocked");
1710 + assert!(!out.is_passed());
1711 + let row: (Option<String>, Option<String>) = sqlx::query_as(
1712 + "SELECT status, outcome_json FROM gate_runs ORDER BY id DESC LIMIT 1",
1713 + ).fetch_one(&pool).await.unwrap();
1714 + assert_eq!(row.0.as_deref(), Some("blocked"));
1715 + let json: serde_json::Value = serde_json::from_str(row.1.as_deref().unwrap()).unwrap();
1716 + assert_eq!(json["status"]["blocker"]["kind"], "scratch_db_url_unset");
1717 + }
1718 +
1308 1719 /// Sanity: applying MNW migrations from a *non-existent* dir errors,
1309 1720 /// rather than silently no-op'ing. Cheap pure check, no postgres needed
1310 1721 /// (the sqlx::Migrator::new constructor itself reads the dir).
@@ -78,8 +78,11 @@ pub enum GateStatus {
78 78 #[derive(Debug, Clone, Serialize, Deserialize)]
79 79 #[serde(tag = "kind", rename_all = "snake_case")]
80 80 pub enum PassNote {
81 - /// `boot_smoke` — the binary came up and served `GET /health` (readiness,
82 - /// not just liveness). `after_ms` is how long until the first good probe.
81 + /// `boot_smoke` / `code_smoke` — the binary came up and served `GET /health`
82 + /// (readiness, not just liveness). `after_ms` is how long until the first
83 + /// good probe. For `code_smoke` this is a probe of the real server booted
84 + /// against a freshly migrated + seeded throwaway DB; for `boot_smoke` it is
85 + /// the minimal no-DB smoke server.
83 86 HealthyProbe { after_ms: u32 },
84 87 /// `burn_in` — the configured number of hours have elapsed since
85 88 /// the gate's clock started.
@@ -197,6 +200,21 @@ pub enum GateFailure {
197 200 /// its `health_url`, within the probe window. `node` is the failing node;
198 201 /// `detail` carries the probe's stderr/reason.
199 202 NodeUnhealthy { node: String, detail: String },
203 + /// `code_smoke`: creating or dropping the throwaway smoke DB failed (a
204 + /// postgres/cluster problem, not the built code). `reason` carries the DB
205 + /// error. Kept distinct from the boot/seed failures so an environment issue
206 + /// here isn't misread as unsound code.
207 + CodeSmokeSetup { reason: String },
208 + /// `code_smoke`: the `--seed-examples` run (which migrates the empty DB from
209 + /// scratch, then seeds the example catalog, then exits) returned non-zero —
210 + /// a broken migration, a seed error, or a config-load failure. The persisted
211 + /// gate log carries the process output; `exit_code` is the OS status.
212 + CodeSmokeSeed { exit_code: Option<i32> },
213 + /// `code_smoke`: the booted server answered `GET /health` but never emitted
214 + /// its `listening` startup log. The gate asserts both signals — the bind
215 + /// readiness log and the health probe — so a missing log line fails even
216 + /// when the probe passes.
217 + CodeSmokeNoListeningLog,
200 218 /// `cargo_test` / `boot_smoke`: tokio could not spawn the child.
201 219 SpawnFailed { message: String },
202 220 /// Gate took longer than the configured ceiling.
@@ -237,6 +255,12 @@ impl GateFailure {
237 255 format!("started but never served /health: {last_error}"),
238 256 GateFailure::NodeUnhealthy { node, detail } =>
239 257 format!("node {node} unhealthy: {detail}"),
258 + GateFailure::CodeSmokeSetup { reason } => format!("code smoke db setup: {reason}"),
259 + GateFailure::CodeSmokeSeed { exit_code: Some(c) } =>
260 + format!("migrate+seed run failed: exit {c}"),
261 + GateFailure::CodeSmokeSeed { exit_code: None } => "migrate+seed run failed".into(),
262 + GateFailure::CodeSmokeNoListeningLog =>
263 + "served /health but never logged 'listening'".into(),
240 264 GateFailure::SpawnFailed { message } => format!("spawn: {message}"),
241 265 GateFailure::Timeout { gate, after_s } => format!("{gate} timed out after {after_s}s"),
242 266 GateFailure::Unclassified { legacy_detail: Some(d) } => d.clone(),
@@ -795,6 +795,7 @@ mod tests {
795 795 scratch_db_url: None,
796 796 scratch_owner_role: "makenotwork".into(),
797 797 boot_smoke_port: 18181,
798 + code_smoke_port: 18182,
798 799 bin_names: vec!["makenotwork".into()],
799 800 logs_root: PathBuf::from("/tmp/sando-logs"),
800 801 release_contents: vec![],
@@ -448,7 +448,7 @@ pub(super) async fn unsatisfied_gates(
448 448 // compile error here until its promotion semantics are decided —
449 449 // a transient-`blocked` kind silently falling into "needs a passed
450 450 // row" would be permanently unsatisfiable.
451 - Gate::CargoTest | Gate::MigrationDryRun | Gate::BootSmoke | Gate::NodeHealth => {
451 + Gate::CargoTest | Gate::MigrationDryRun | Gate::CodeSmoke | Gate::BootSmoke | Gate::NodeHealth => {
452 452 // Latest row for this configured gate kind; NULL/missing/any
453 453 // non-'passed' status all count as unsatisfied (fail closed).
454 454 let status: Option<String> = sqlx::query_scalar(
@@ -130,6 +130,7 @@ impl CanaryPolicy {
130 130 pub enum Gate {
131 131 CargoTest,
132 132 MigrationDryRun,
133 + CodeSmoke,
133 134 BootSmoke,
134 135 NodeHealth,
135 136 BurnIn { hours: u32 },
@@ -144,6 +145,7 @@ impl Gate {
144 145 match self {
145 146 Gate::CargoTest => GateKind::CargoTest,
146 147 Gate::MigrationDryRun => GateKind::MigrationDryRun,
148 + Gate::CodeSmoke => GateKind::CodeSmoke,
147 149 Gate::BootSmoke => GateKind::BootSmoke,
148 150 Gate::NodeHealth => GateKind::NodeHealth,
149 151 Gate::BurnIn { .. } => GateKind::BurnIn,
@@ -165,7 +167,7 @@ impl Gate {
165 167
166 168 /// Gates evaluated at promote time against the deployed nodes or the
167 169 /// operator, as opposed to build-time gates (`cargo_test`,
168 - /// `migration_dry_run`, `boot_smoke`) that run once on the build host and
170 + /// `migration_dry_run`, `code_smoke`, `boot_smoke`) that run once on the build host and
169 171 /// prove nothing about a promote. A serving tier whose gate list contains
170 172 /// none of these would wave every promote straight through; `Topology
171 173 /// ::validate` refuses to load such a config (the structural form of CF1's
@@ -220,7 +222,7 @@ impl Topology {
220 222 anyhow::ensure!(!self.tiers.is_empty(), "topology must declare at least one tier");
221 223 for t in &self.tiers {
222 224 // The `host` tier is the build tier (cargo_test / migration_dry_run /
223 - // boot_smoke run once on the host); every other tier serves an
225 + // code_smoke / boot_smoke run once on the host); every other tier serves an
224 226 // artifact to nodes, so it is exempted from the node and
225 227 // promotion-gate checks the same way.
226 228 let is_build_tier = t.name.as_str() == "host";
@@ -33,7 +33,13 @@ local_path = "/srv/sando/backups/latest.sql.gz"
33 33 name = "host"
34 34 provisioned = true
35 35 canary = "sequential"
36 + # code_smoke runs FIRST: it boots the freshly-built binary against a throwaway
37 + # empty DB it migrates from scratch + seeds (the example catalog), then probes
38 + # /health. Fast + infra-light (no prod-dump restore), so a green here proves the
39 + # code is sound and isolates a later cargo_test / migration_dry_run red as an
40 + # environment problem rather than a code one.
36 41 gates = [
42 + { kind = "code_smoke" },
37 43 { kind = "cargo_test" },
38 44 { kind = "migration_dry_run" },
39 45 { kind = "boot_smoke" },
@@ -136,6 +136,12 @@ pub(crate) fn failure_short(f: &GateFailure) -> String {
136 136 GateFailure::NodeUnhealthy { node, detail } => {
137 137 format!("{node} unhealthy: {}", detail.chars().take(40).collect::<String>())
138 138 }
139 + GateFailure::CodeSmokeSetup { reason } => {
140 + format!("db setup: {}", reason.chars().take(40).collect::<String>())
141 + }
142 + GateFailure::CodeSmokeSeed { exit_code: Some(c) } => format!("migrate+seed exit {c}"),
143 + GateFailure::CodeSmokeSeed { .. } => "migrate+seed failed".into(),
144 + GateFailure::CodeSmokeNoListeningLog => "no 'listening' log".into(),
139 145 GateFailure::SpawnFailed { message } => format!("spawn: {message}"),
140 146 GateFailure::Timeout { gate, after_s } => format!("{gate} timeout {after_s}s"),
141 147 GateFailure::Unclassified { legacy_detail: Some(d) } => {