| 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 |
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 |
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).
|