| 115 |
115 |
|
// doesn't orphan it.
|
| 116 |
116 |
|
Gate::MigrationDryRun => {
|
| 117 |
117 |
|
let ceiling = std::time::Duration::from_secs(ctx.cfg.gate_timeout_secs);
|
| 118 |
|
- |
match tokio::time::timeout(ceiling, migration_dry_run(ctx)).await {
|
|
118 |
+ |
match tokio::time::timeout(ceiling, migration_dry_run(ctx, run_id)).await {
|
| 119 |
119 |
|
Ok(res) => res,
|
| 120 |
120 |
|
Err(_elapsed) => Ok(GateOutcome::failed(GateFailure::Timeout {
|
| 121 |
121 |
|
gate: GateKind::MigrationDryRun,
|
| 131 |
131 |
|
// it first (DROP IF EXISTS), same as migration_dry_run's scratch reset.
|
| 132 |
132 |
|
Gate::CodeSmoke => {
|
| 133 |
133 |
|
let ceiling = std::time::Duration::from_secs(ctx.cfg.gate_timeout_secs);
|
| 134 |
|
- |
match tokio::time::timeout(ceiling, code_smoke(ctx)).await {
|
|
134 |
+ |
match tokio::time::timeout(ceiling, code_smoke(ctx, run_id)).await {
|
| 135 |
135 |
|
Ok(res) => res,
|
| 136 |
136 |
|
Err(_elapsed) => Ok(GateOutcome::failed(GateFailure::Timeout {
|
| 137 |
137 |
|
gate: GateKind::CodeSmoke,
|
| 828 |
828 |
|
cmd
|
| 829 |
829 |
|
}
|
| 830 |
830 |
|
|
| 831 |
|
- |
async fn migration_dry_run(ctx: &GateCtx) -> Result<GateOutcome> {
|
| 832 |
|
- |
let mut log_buf: Vec<u8> = Vec::new();
|
| 833 |
|
- |
let log_ref = LogRef::new(&ctx.version, GateKind::MigrationDryRun);
|
| 834 |
|
- |
let finish = |outcome: GateOutcome, buf: Vec<u8>| async move {
|
| 835 |
|
- |
persist_gate_log(ctx, GateKind::MigrationDryRun, &buf, &[]).await;
|
| 836 |
|
- |
outcome
|
| 837 |
|
- |
};
|
|
831 |
+ |
async fn migration_dry_run(ctx: &GateCtx, run_id: GateRunId) -> Result<GateOutcome> {
|
|
832 |
+ |
let log = GateLog::open(ctx, run_id, GateKind::MigrationDryRun).await;
|
|
833 |
+ |
let outcome = migration_dry_run_inner(ctx, &log).await;
|
|
834 |
+ |
log.close().await;
|
|
835 |
+ |
outcome.map(|o| o.with_log_ref(LogRef::new(&ctx.version, GateKind::MigrationDryRun)))
|
|
836 |
+ |
}
|
| 838 |
837 |
|
|
|
838 |
+ |
/// The staged interior of [`migration_dry_run`], writing every step through the
|
|
839 |
+ |
/// gate's live log. The caller owns the sink so it can flush it on every exit
|
|
840 |
+ |
/// path, and attaches the `log_ref` once instead of at each return.
|
|
841 |
+ |
async fn migration_dry_run_inner(ctx: &GateCtx, log: &GateLog) -> Result<GateOutcome> {
|
| 839 |
842 |
|
let Some(db_url) = ctx.cfg.scratch_db_url.as_deref() else {
|
| 840 |
|
- |
log_buf.extend_from_slice(b"scratch_db_url unset in daemon config\n");
|
| 841 |
|
- |
return Ok(finish(
|
| 842 |
|
- |
GateOutcome::blocked(GateBlocker::ScratchDbUrlUnset).with_log_ref(log_ref),
|
| 843 |
|
- |
log_buf,
|
| 844 |
|
- |
)
|
| 845 |
|
- |
.await);
|
|
843 |
+ |
log.line("scratch_db_url unset in daemon config\n").await;
|
|
844 |
+ |
return Ok(GateOutcome::blocked(GateBlocker::ScratchDbUrlUnset));
|
| 846 |
845 |
|
};
|
| 847 |
846 |
|
|
| 848 |
|
- |
let backup: Option<(String,)> =
|
| 849 |
|
- |
sqlx::query_as("SELECT local_path FROM backups ORDER BY id DESC LIMIT 1")
|
|
847 |
+ |
let backup: Option<(String, String)> =
|
|
848 |
+ |
sqlx::query_as("SELECT local_path, fetched_at FROM backups ORDER BY id DESC LIMIT 1")
|
| 850 |
849 |
|
.fetch_optional(&ctx.pool)
|
| 851 |
850 |
|
.await?;
|
| 852 |
|
- |
let Some((backup_path,)) = backup else {
|
| 853 |
|
- |
log_buf.extend_from_slice(b"no backup fetched; call /backup/fetch first\n");
|
| 854 |
|
- |
return Ok(finish(
|
| 855 |
|
- |
GateOutcome::blocked(GateBlocker::NoBackupAvailable).with_log_ref(log_ref),
|
| 856 |
|
- |
log_buf,
|
| 857 |
|
- |
)
|
| 858 |
|
- |
.await);
|
|
851 |
+ |
let Some((backup_path, fetched_at)) = backup else {
|
|
852 |
+ |
log.line("no backup fetched; call /backup/fetch first\n")
|
|
853 |
+ |
.await;
|
|
854 |
+ |
return Ok(GateOutcome::blocked(GateBlocker::NoBackupAvailable));
|
| 859 |
855 |
|
};
|
| 860 |
856 |
|
|
| 861 |
|
- |
log_buf.extend_from_slice(b"---- reset_scratch ----\n");
|
|
857 |
+ |
// Presence is not freshness. A fetch that quietly stopped working leaves this
|
|
858 |
+ |
// row in place, and restoring it dry-runs the migrations against a schema prod
|
|
859 |
+ |
// has moved past — green, and worthless. Block on age instead. An unparsable
|
|
860 |
+ |
// timestamp is treated as stale: this row is daemon-written RFC 3339, so a
|
|
861 |
+ |
// value that will not parse means something is wrong, and failing closed on a
|
|
862 |
+ |
// freshness check is the whole point.
|
|
863 |
+ |
let age_hours = chrono::DateTime::parse_from_rfc3339(&fetched_at).map_or(i64::MAX, |t| {
|
|
864 |
+ |
(Utc::now() - t.with_timezone(&Utc)).num_hours()
|
|
865 |
+ |
});
|
|
866 |
+ |
let max_age_hours = ctx.cfg.backup_max_age_hours;
|
|
867 |
+ |
if age_hours > i64::from(max_age_hours) {
|
|
868 |
+ |
let msg = format!(
|
|
869 |
+ |
"backup {backup_path} was fetched {fetched_at} ({age_hours}h ago, max \
|
|
870 |
+ |
{max_age_hours}h); re-run /backup/fetch\n"
|
|
871 |
+ |
);
|
|
872 |
+ |
log.line(&msg).await;
|
|
873 |
+ |
return Ok(GateOutcome::blocked(GateBlocker::BackupStale {
|
|
874 |
+ |
age_hours,
|
|
875 |
+ |
max_age_hours,
|
|
876 |
+ |
}));
|
|
877 |
+ |
}
|
|
878 |
+ |
|
|
879 |
+ |
log.line("---- reset_scratch ----\n").await;
|
| 862 |
880 |
|
if let Err(e) = reset_scratch(db_url, &ctx.cfg.scratch_owner_role).await {
|
| 863 |
881 |
|
let msg = format!("scratch reset: {e}");
|
| 864 |
|
- |
log_buf.extend_from_slice(msg.as_bytes());
|
| 865 |
|
- |
return Ok(finish(
|
| 866 |
|
- |
GateOutcome::failed(GateFailure::RestoreFailed { reason: msg }).with_log_ref(log_ref),
|
| 867 |
|
- |
log_buf,
|
| 868 |
|
- |
)
|
| 869 |
|
- |
.await);
|
|
882 |
+ |
log.line(&msg).await;
|
|
883 |
+ |
return Ok(GateOutcome::failed(GateFailure::RestoreFailed {
|
|
884 |
+ |
reason: msg,
|
|
885 |
+ |
}));
|
| 870 |
886 |
|
}
|
| 871 |
|
- |
log_buf.extend_from_slice(format!("---- restore_dump ({backup_path}) ----\n").as_bytes());
|
| 872 |
|
- |
if let Err(e) = restore_dump(db_url, &backup_path, &mut log_buf).await {
|
|
887 |
+ |
log.line(&format!("---- restore_dump ({backup_path}) ----\n"))
|
|
888 |
+ |
.await;
|
|
889 |
+ |
if let Err(e) = restore_dump(db_url, &backup_path, log).await {
|
| 873 |
890 |
|
let msg = format!("restore: {e}");
|
| 874 |
|
- |
log_buf.extend_from_slice(msg.as_bytes());
|
| 875 |
|
- |
return Ok(finish(
|
| 876 |
|
- |
GateOutcome::failed(GateFailure::RestoreFailed { reason: msg }).with_log_ref(log_ref),
|
| 877 |
|
- |
log_buf,
|
| 878 |
|
- |
)
|
| 879 |
|
- |
.await);
|
|
891 |
+ |
log.line(&msg).await;
|
|
892 |
+ |
return Ok(GateOutcome::failed(GateFailure::RestoreFailed {
|
|
893 |
+ |
reason: msg,
|
|
894 |
+ |
}));
|
| 880 |
895 |
|
}
|
| 881 |
896 |
|
|
| 882 |
897 |
|
let migrations_dir = ctx.worktree.join("server").join("migrations");
|
| 883 |
|
- |
log_buf.extend_from_slice(b"---- run_migrator ----\n");
|
|
898 |
+ |
log.line("---- run_migrator ----\n").await;
|
| 884 |
899 |
|
match run_migrator(db_url, &migrations_dir).await {
|
| 885 |
900 |
|
Ok(()) => {
|
| 886 |
|
- |
let detail = format!("restored {backup_path} + migrated");
|
| 887 |
|
- |
log_buf.extend_from_slice(detail.as_bytes());
|
| 888 |
|
- |
Ok(finish(
|
| 889 |
|
- |
GateOutcome::passed(PassNote::Migrated {
|
| 890 |
|
- |
backup_path: backup_path.clone(),
|
| 891 |
|
- |
})
|
| 892 |
|
- |
.with_log_ref(log_ref),
|
| 893 |
|
- |
log_buf,
|
| 894 |
|
- |
)
|
| 895 |
|
- |
.await)
|
|
901 |
+ |
log.line(&format!("restored {backup_path} + migrated"))
|
|
902 |
+ |
.await;
|
|
903 |
+ |
Ok(GateOutcome::passed(PassNote::Migrated { backup_path }))
|
| 896 |
904 |
|
}
|
| 897 |
905 |
|
Err(e) => {
|
| 898 |
906 |
|
let err_s = e.to_string();
|
| 899 |
|
- |
log_buf.extend_from_slice(err_s.as_bytes());
|
| 900 |
|
- |
let failure = classify::classify_migration_error(&err_s, None);
|
| 901 |
|
- |
Ok(finish(GateOutcome::failed(failure).with_log_ref(log_ref), log_buf).await)
|
|
907 |
+ |
log.line(&err_s).await;
|
|
908 |
+ |
Ok(GateOutcome::failed(classify::classify_migration_error(
|
|
909 |
+ |
&err_s, None,
|
|
910 |
+ |
)))
|
| 902 |
911 |
|
}
|
| 903 |
912 |
|
}
|
| 904 |
913 |
|
}
|
| 1098 |
1107 |
|
}
|
| 1099 |
1108 |
|
}
|
| 1100 |
1109 |
|
|
| 1101 |
|
- |
async fn restore_dump(db_url: &str, dump: &str, log_buf: &mut Vec<u8>) -> Result<()> {
|
|
1110 |
+ |
async fn restore_dump(db_url: &str, dump: &str, log: &GateLog) -> Result<()> {
|
| 1102 |
1111 |
|
// Split the password out of the URL and hand it to psql via PGPASSWORD, so it
|
| 1103 |
1112 |
|
// never lands in argv (visible in /proc/<pid>/cmdline to any local user).
|
| 1104 |
1113 |
|
// The sanitized URL — user/host/db, no secret — goes on the command line.
|
| 1114 |
1123 |
|
if let Some(pw) = password {
|
| 1115 |
1124 |
|
cmd.env("PGPASSWORD", pw);
|
| 1116 |
1125 |
|
}
|
| 1117 |
|
- |
let out = cmd.output().await?;
|
| 1118 |
|
- |
log_buf.extend_from_slice(&out.stdout);
|
| 1119 |
|
- |
log_buf.extend_from_slice(&out.stderr);
|
|
1126 |
+ |
// Streamed, not `.output()`: a prod-sized restore runs for minutes, and
|
|
1127 |
+ |
// psql's progress is the only thing an operator has to watch during it.
|
|
1128 |
+ |
let (_stdout, stderr, status) = log.run(&mut cmd).await?;
|
| 1120 |
1129 |
|
anyhow::ensure!(
|
| 1121 |
|
- |
out.status.success(),
|
|
1130 |
+ |
status.success(),
|
| 1122 |
1131 |
|
"restore failed: {}",
|
| 1123 |
|
- |
String::from_utf8_lossy(&out.stderr),
|
|
1132 |
+ |
String::from_utf8_lossy(&stderr),
|
| 1124 |
1133 |
|
);
|
| 1125 |
1134 |
|
Ok(())
|
| 1126 |
1135 |
|
}
|
| 1231 |
1240 |
|
/// `HOST_URL` so config stays in dev mode (no CDN/S3/signing-secret prod
|
| 1232 |
1241 |
|
/// enforcement). The seed's host allowlist already admits `127.0.0.1`, and the
|
| 1233 |
1242 |
|
/// fresh DB trivially satisfies its no-real-users guard.
|
| 1234 |
|
- |
async fn code_smoke(ctx: &GateCtx) -> Result<GateOutcome> {
|
| 1235 |
|
- |
let log_ref = LogRef::new(&ctx.version, GateKind::CodeSmoke);
|
| 1236 |
|
- |
let mut log_buf: Vec<u8> = Vec::new();
|
| 1237 |
|
- |
let finish = |outcome: GateOutcome, buf: Vec<u8>| async move {
|
| 1238 |
|
- |
persist_gate_log(ctx, GateKind::CodeSmoke, &buf, &[]).await;
|
| 1239 |
|
- |
outcome
|
| 1240 |
|
- |
};
|
|
1243 |
+ |
async fn code_smoke(ctx: &GateCtx, run_id: GateRunId) -> Result<GateOutcome> {
|
|
1244 |
+ |
let log = GateLog::open(ctx, run_id, GateKind::CodeSmoke).await;
|
|
1245 |
+ |
let outcome = code_smoke_inner(ctx, &log).await;
|
|
1246 |
+ |
log.close().await;
|
|
1247 |
+ |
outcome.map(|o| o.with_log_ref(LogRef::new(&ctx.version, GateKind::CodeSmoke)))
|
|
1248 |
+ |
}
|
| 1241 |
1249 |
|
|
|
1250 |
+ |
/// The staged interior of [`code_smoke`], writing every step through the gate's
|
|
1251 |
+ |
/// live log. Same split as [`migration_dry_run_inner`]: the caller owns the sink
|
|
1252 |
+ |
/// and attaches the `log_ref`.
|
|
1253 |
+ |
async fn code_smoke_inner(ctx: &GateCtx, log: &GateLog) -> Result<GateOutcome> {
|
| 1242 |
1254 |
|
let Some(scratch_url) = ctx.cfg.scratch_db_url.as_deref() else {
|
| 1243 |
|
- |
log_buf.extend_from_slice(b"scratch_db_url unset in daemon config\n");
|
| 1244 |
|
- |
return Ok(finish(
|
| 1245 |
|
- |
GateOutcome::blocked(GateBlocker::ScratchDbUrlUnset).with_log_ref(log_ref),
|
| 1246 |
|
- |
log_buf,
|
| 1247 |
|
- |
)
|
| 1248 |
|
- |
.await);
|
|
1255 |
+ |
log.line("scratch_db_url unset in daemon config\n").await;
|
|
1256 |
+ |
return Ok(GateOutcome::blocked(GateBlocker::ScratchDbUrlUnset));
|
| 1249 |
1257 |
|
};
|
| 1250 |
1258 |
|
|
| 1251 |
1259 |
|
// The staged binary (set by build_and_run_host before gating). code_smoke
|
| 1257 |
1265 |
|
.fetch_optional(&ctx.pool)
|
| 1258 |
1266 |
|
.await?;
|
| 1259 |
1267 |
|
let Some((bin,)) = bin else {
|
| 1260 |
|
- |
return Ok(finish(
|
| 1261 |
|
- |
GateOutcome::blocked(GateBlocker::ArtifactMissing {
|
| 1262 |
|
- |
version: ctx.version.clone(),
|
| 1263 |
|
- |
})
|
| 1264 |
|
- |
.with_log_ref(log_ref),
|
| 1265 |
|
- |
log_buf,
|
| 1266 |
|
- |
)
|
| 1267 |
|
- |
.await);
|
|
1268 |
+ |
return Ok(GateOutcome::blocked(GateBlocker::ArtifactMissing {
|
|
1269 |
+ |
version: ctx.version.clone(),
|
|
1270 |
+ |
}));
|
| 1268 |
1271 |
|
};
|
| 1269 |
1272 |
|
|
| 1270 |
1273 |
|
// Frontend builds, before anything else: they need no DB and no staged
|
| 1272 |
1275 |
|
// swallows (both MNW build scripts emit `cargo::warning` and succeed against
|
| 1273 |
1276 |
|
// a stale `static/dist/`). Failing here is what stops the deploy rsyncing
|
| 1274 |
1277 |
|
// the previous build's bundle.
|
| 1275 |
|
- |
if let Some(outcome) = code_smoke_frontends(ctx, &mut log_buf).await {
|
| 1276 |
|
- |
return Ok(finish(outcome.with_log_ref(log_ref), log_buf).await);
|
|
1278 |
+ |
if let Some(outcome) = code_smoke_frontends(ctx, log).await {
|
|
1279 |
+ |
return Ok(outcome);
|
| 1277 |
1280 |
|
}
|
| 1278 |
1281 |
|
|
| 1279 |
1282 |
|
// Docs integrity, first and cheapest: run the staged binary's DB-free
|
| 1282 |
1285 |
|
// in well under a second instead of after a full migrate+seed+boot, and a
|
| 1283 |
1286 |
|
// rotted link never reaches prod as a live 404. Collisions are reported by
|
| 1284 |
1287 |
|
// the check but do not fail it; only broken links do.
|
| 1285 |
|
- |
if let Some(outcome) = code_smoke_docs_check(ctx, &bin, &mut log_buf).await {
|
| 1286 |
|
- |
return Ok(finish(outcome.with_log_ref(log_ref), log_buf).await);
|
|
1288 |
+ |
if let Some(outcome) = code_smoke_docs_check(ctx, &bin, log).await {
|
|
1289 |
+ |
return Ok(outcome);
|
| 1287 |
1290 |
|
}
|
| 1288 |
1291 |
|
|
| 1289 |
1292 |
|
let dbname = code_smoke_db_name(&ctx.version);
|
| 1291 |
1294 |
|
let throwaway_url = pg_url_with_dbname(scratch_url, &dbname);
|
| 1292 |
1295 |
|
|
| 1293 |
1296 |
|
// Create the throwaway DB (dropping any stale one from a killed prior run).
|
| 1294 |
|
- |
log_buf.extend_from_slice(format!("---- createdb {dbname} ----\n").as_bytes());
|
|
1297 |
+ |
log.line(&format!("---- createdb {dbname} ----\n")).await;
|
| 1295 |
1298 |
|
if let Err(e) = pg_create_db(&maintenance_url, &dbname).await {
|
| 1296 |
1299 |
|
let reason = format!("createdb {dbname}: {e}");
|
| 1297 |
|
- |
log_buf.extend_from_slice(reason.as_bytes());
|
| 1298 |
|
- |
return Ok(finish(
|
| 1299 |
|
- |
GateOutcome::failed(GateFailure::CodeSmokeSetup { reason }).with_log_ref(log_ref),
|
| 1300 |
|
- |
log_buf,
|
| 1301 |
|
- |
)
|
| 1302 |
|
- |
.await);
|
|
1300 |
+ |
log.line(&reason).await;
|
|
1301 |
+ |
return Ok(GateOutcome::failed(GateFailure::CodeSmokeSetup { reason }));
|
| 1303 |
1302 |
|
}
|
| 1304 |
1303 |
|
|
| 1305 |
1304 |
|
// Everything past createdb must drop the DB on the way out, pass or fail.
|
| 1306 |
|
- |
let outcome = code_smoke_body(ctx, &bin, &throwaway_url, &mut log_buf).await;
|
|
1305 |
+ |
let outcome = code_smoke_body(ctx, &bin, &throwaway_url, log).await;
|
| 1307 |
1306 |
|
|
| 1308 |
|
- |
log_buf.extend_from_slice(format!("\n---- dropdb {dbname} ----\n").as_bytes());
|
|
1307 |
+ |
log.line(&format!("\n---- dropdb {dbname} ----\n")).await;
|
| 1309 |
1308 |
|
if let Err(e) = pg_drop_db(&maintenance_url, &dbname).await {
|
| 1310 |
1309 |
|
// A teardown miss must not turn a passing gate red — log it and move on.
|
| 1311 |
1310 |
|
// The next run's createdb drops it first anyway.
|
| 1312 |
1311 |
|
tracing::warn!(error = %e, db = %dbname, "code_smoke: dropdb failed; next run will reclaim it");
|
| 1313 |
|
- |
log_buf.extend_from_slice(format!("dropdb warning (non-fatal): {e}").as_bytes());
|
|
1312 |
+ |
log.line(&format!("dropdb warning (non-fatal): {e}")).await;
|
| 1314 |
1313 |
|
}
|
| 1315 |
1314 |
|
|
| 1316 |
|
- |
Ok(finish(outcome.with_log_ref(log_ref), log_buf).await)
|
|
1315 |
+ |
Ok(outcome)
|
| 1317 |
1316 |
|
}
|
| 1318 |
1317 |
|
|
| 1319 |
1318 |
|
/// Compile every configured `frontend_build` in the worktree.
|
| 1320 |
1319 |
|
///
|
| 1321 |
1320 |
|
/// Returns `Some(failed)` on the first project that does not build; `None` when
|
| 1322 |
|
- |
/// all of them do (or none are configured). Output is appended to `log_buf`
|
| 1323 |
|
- |
/// either way.
|
|
1321 |
+ |
/// all of them do (or none are configured). Output streams to `log` either way.
|
| 1324 |
1322 |
|
///
|
| 1325 |
1323 |
|
/// `npm ci` runs only when `node_modules` is absent. Usually it is not: the app
|
| 1326 |
1324 |
|
/// build script installed it during the `cargo build` that produced the artifact
|
| 1332 |
1330 |
|
///
|
| 1333 |
1331 |
|
/// Unlike the app build scripts, nothing here is best-effort. That asymmetry is
|
| 1334 |
1332 |
|
/// the point of the gate.
|
| 1335 |
|
- |
async fn code_smoke_frontends(ctx: &GateCtx, log_buf: &mut Vec<u8>) -> Option<GateOutcome> {
|
|
1333 |
+ |
async fn code_smoke_frontends(ctx: &GateCtx, log: &GateLog) -> Option<GateOutcome> {
|
| 1336 |
1334 |
|
for fe in &ctx.cfg.frontend_builds {
|
| 1337 |
1335 |
|
let dir = ctx.worktree.join(&fe.dir);
|
| 1338 |
1336 |
|
let label = fe.dir.display().to_string();
|
| 1339 |
|
- |
log_buf.extend_from_slice(format!("---- frontend build ({label}) ----\n").as_bytes());
|
|
1337 |
+ |
log.line(&format!("---- frontend build ({label}) ----\n"))
|
|
1338 |
+ |
.await;
|
| 1340 |
1339 |
|
|
| 1341 |
1340 |
|
if !dir.is_dir() {
|
| 1342 |
1341 |
|
// An older sha predating the frontend, mid-bisect. Skipping keeps
|
| 1343 |
1342 |
|
// sando able to rebuild history; the log says so out loud.
|
| 1344 |
|
- |
log_buf.extend_from_slice(
|
| 1345 |
|
- |
format!("{label} absent from this worktree; skipping\n").as_bytes(),
|
| 1346 |
|
- |
);
|
|
1343 |
+ |
log.line(&format!("{label} absent from this worktree; skipping\n"))
|
|
1344 |
+ |
.await;
|
| 1347 |
1345 |
|
continue;
|
| 1348 |
1346 |
|
}
|
| 1349 |
1347 |
|
|
| 1350 |
1348 |
|
if !dir.join("node_modules").is_dir()
|
| 1351 |
|
- |
&& let Some(outcome) = run_npm(&dir, &label, &["ci"], "npm ci", ctx, log_buf).await
|
|
1349 |
+ |
&& let Some(outcome) = run_npm(&dir, &label, &["ci"], "npm ci", ctx, log).await
|
| 1352 |
1350 |
|
{
|
| 1353 |
1351 |
|
return Some(outcome);
|
| 1354 |
1352 |
|
}
|
| 1359 |
1357 |
|
&["run", &fe.script],
|
| 1360 |
1358 |
|
&format!("npm run {}", fe.script),
|
| 1361 |
1359 |
|
ctx,
|
| 1362 |
|
- |
log_buf,
|
|
1360 |
+ |
log,
|
| 1363 |
1361 |
|
)
|
| 1364 |
1362 |
|
.await
|
| 1365 |
1363 |
|
{
|
| 1379 |
1377 |
|
args: &[&str],
|
| 1380 |
1378 |
|
what: &str,
|
| 1381 |
1379 |
|
ctx: &GateCtx,
|
| 1382 |
|
- |
log_buf: &mut Vec<u8>,
|
|
1380 |
+ |
log: &GateLog,
|
| 1383 |
1381 |
|
) -> Option<GateOutcome> {
|
| 1384 |
|
- |
log_buf.extend_from_slice(format!("$ {what}\n").as_bytes());
|
|
1382 |
+ |
log.line(&format!("$ {what}\n")).await;
|
| 1385 |
1383 |
|
let mut cmd = tokio::process::Command::new("npm");
|
| 1386 |
|
- |
cmd.args(args)
|
| 1387 |
|
- |
.current_dir(dir)
|
| 1388 |
|
- |
.stdout(std::process::Stdio::piped())
|
| 1389 |
|
- |
.stderr(std::process::Stdio::piped())
|
| 1390 |
|
- |
.kill_on_drop(true);
|
|
1384 |
+ |
cmd.args(args).current_dir(dir).kill_on_drop(true);
|
| 1391 |
1385 |
|
let ceiling = std::time::Duration::from_secs(ctx.cfg.gate_timeout_secs);
|
| 1392 |
|
- |
let out = match tokio::time::timeout(ceiling, cmd.output()).await {
|
| 1393 |
|
- |
Ok(Ok(out)) => out,
|
|
1386 |
+ |
// On the timeout branch the whole `run` future is dropped, which drops the
|
|
1387 |
+ |
// child; `kill_on_drop` is what turns that into an actual kill.
|
|
1388 |
+ |
let status = match tokio::time::timeout(ceiling, log.run(&mut cmd)).await {
|
|
1389 |
+ |
Ok(Ok((_stdout, _stderr, status))) => status,
|
| 1394 |
1390 |
|
Ok(Err(e)) => {
|
| 1395 |
1391 |
|
// A missing `npm` lands here. Fatal, not skipped: a build host
|
| 1396 |
1392 |
|
// without Node cannot produce the bundle the release serves, and
|
| 1397 |
1393 |
|
// silently passing is how the stale bundle shipped in the first place.
|
| 1398 |
|
- |
log_buf.extend_from_slice(format!("{what} could not be spawned: {e}\n").as_bytes());
|
|
1394 |
+ |
log.line(&format!("{what} could not be spawned: {e}\n"))
|
|
1395 |
+ |
.await;
|
| 1399 |
1396 |
|
return Some(GateOutcome::failed(GateFailure::SpawnFailed {
|
| 1400 |
1397 |
|
message: format!("{what} in {label}: {e}"),
|
| 1401 |
1398 |
|
}));
|
| 1402 |
1399 |
|
}
|
| 1403 |
1400 |
|
Err(_elapsed) => {
|
| 1404 |
|
- |
log_buf.extend_from_slice(
|
| 1405 |
|
- |
format!("{what} timed out after {}s\n", ctx.cfg.gate_timeout_secs).as_bytes(),
|
| 1406 |
|
- |
);
|
|
1401 |
+ |
log.line(&format!(
|
|
1402 |
+ |
"{what} timed out after {}s\n",
|
|
1403 |
+ |
ctx.cfg.gate_timeout_secs
|
|
1404 |
+ |
))
|
|
1405 |
+ |
.await;
|
| 1407 |
1406 |
|
return Some(GateOutcome::failed(GateFailure::CodeSmokeFrontend {
|
| 1408 |
1407 |
|
dir: label.to_string(),
|
| 1409 |
1408 |
|
exit_code: None,
|
| 1410 |
1409 |
|
}));
|
| 1411 |
1410 |
|
}
|
| 1412 |
1411 |
|
};
|
| 1413 |
|
- |
log_buf.extend_from_slice(&out.stdout);
|
| 1414 |
|
- |
log_buf.extend_from_slice(&out.stderr);
|
| 1415 |
|
- |
if out.status.success() {
|
|
1412 |
+ |
if status.success() {
|
| 1416 |
1413 |
|
return None;
|
| 1417 |
1414 |
|
}
|
| 1418 |
1415 |
|
Some(GateOutcome::failed(GateFailure::CodeSmokeFrontend {
|
| 1419 |
1416 |
|
dir: label.to_string(),
|
| 1420 |
|
- |
exit_code: out.status.code(),
|
|
1417 |
+ |
exit_code: status.code(),
|
| 1421 |
1418 |
|
}))
|
| 1422 |
1419 |
|
}
|
| 1423 |
1420 |
|
|
| 1424 |
1421 |
|
/// Run the staged binary's DB-free docs integrity check (`MNW_CHECK_DOCS=1`).
|
| 1425 |
1422 |
|
///
|
| 1426 |
1423 |
|
/// Returns `Some(failed)` if the check reports broken links, cannot be spawned,
|
| 1427 |
|
- |
/// or overruns its ceiling; `None` when the docs are clean. Output is appended
|
| 1428 |
|
- |
/// to `log_buf` either way. The 60s ceiling backstops the case where the staged
|
| 1429 |
|
- |
/// binary predates the flag and would fall through to a normal (DB-needing)
|
| 1430 |
|
- |
/// boot and hang.
|
| 1431 |
|
- |
async fn code_smoke_docs_check(
|
| 1432 |
|
- |
ctx: &GateCtx,
|
| 1433 |
|
- |
bin: &str,
|
| 1434 |
|
- |
log_buf: &mut Vec<u8>,
|
| 1435 |
|
- |
) -> Option<GateOutcome> {
|
|
1424 |
+ |
/// or overruns its ceiling; `None` when the docs are clean. Output streams to
|
|
1425 |
+ |
/// `log` either way. The 60s ceiling backstops the case where the staged binary
|
|
1426 |
+ |
/// predates the flag and would fall through to a normal (DB-needing) boot and
|
|
1427 |
+ |
/// hang.
|
|
1428 |
+ |
async fn code_smoke_docs_check(ctx: &GateCtx, bin: &str, log: &GateLog) -> Option<GateOutcome> {
|
| 1436 |
1429 |
|
let server_dir = ctx.worktree.join("server");
|
| 1437 |
|
- |
log_buf.extend_from_slice(b"---- docs check (MNW_CHECK_DOCS) ----\n");
|
|
1430 |
+ |
log.line("---- docs check (MNW_CHECK_DOCS) ----\n").await;
|
| 1438 |
1431 |
|
let mut cmd = tokio::process::Command::new(bin);
|
| 1439 |
1432 |
|
cmd.env("MNW_CHECK_DOCS", "1")
|
| 1440 |
1433 |
|
.current_dir(&server_dir)
|
| 1441 |
|
- |
.stdout(std::process::Stdio::piped())
|
| 1442 |
|
- |
.stderr(std::process::Stdio::piped())
|
| 1443 |
1434 |
|
.kill_on_drop(true);
|
| 1444 |
|
- |
let out = match tokio::time::timeout(std::time::Duration::from_mins(1), cmd.output()).await {
|
| 1445 |
|
- |
Ok(Ok(out)) => out,
|
| 1446 |
|
- |
Ok(Err(e)) => {
|
| 1447 |
|
- |
log_buf.extend_from_slice(format!("docs check spawn failed: {e}\n").as_bytes());
|
| 1448 |
|
- |
return Some(GateOutcome::failed(GateFailure::SpawnFailed {
|
| 1449 |
|
- |
message: e.to_string(),
|
| 1450 |
|
- |
}));
|
| 1451 |
|
- |
}
|
| 1452 |
|
- |
Err(_elapsed) => {
|
| 1453 |
|
- |
let reason =
|
| 1454 |
|
- |
"docs check timed out after 60s (staged binary may predate MNW_CHECK_DOCS)"
|
| 1455 |
|
- |
.to_string();
|
| 1456 |
|
- |
log_buf.extend_from_slice(reason.as_bytes());
|
| 1457 |
|
- |
log_buf.push(b'\n');
|
| 1458 |
|
- |
return Some(GateOutcome::failed(GateFailure::CodeSmokeSetup { reason }));
|
| 1459 |
|
- |
}
|
| 1460 |
|
- |
};
|
| 1461 |
|
- |
log_buf.extend_from_slice(&out.stdout);
|
| 1462 |
|
- |
log_buf.extend_from_slice(&out.stderr);
|
| 1463 |
|
- |
if out.status.success() {
|
|
1435 |
+ |
let (stdout, _stderr, status) =
|
|
1436 |
+ |
match tokio::time::timeout(std::time::Duration::from_mins(1), log.run(&mut cmd)).await {
|
|
1437 |
+ |
Ok(Ok(out)) => out,
|
|
1438 |
+ |
Ok(Err(e)) => {
|
|
1439 |
+ |
log.line(&format!("docs check spawn failed: {e}\n")).await;
|
|
1440 |
+ |
return Some(GateOutcome::failed(GateFailure::SpawnFailed {
|
|
1441 |
+ |
message: e.to_string(),
|
|
1442 |
+ |
}));
|
|
1443 |
+ |
}
|
|
1444 |
+ |
Err(_elapsed) => {
|
|
1445 |
+ |
let reason =
|
|
1446 |
+ |
"docs check timed out after 60s (staged binary may predate MNW_CHECK_DOCS)"
|
|
1447 |
+ |
.to_string();
|
|
1448 |
+ |
log.line(&format!("{reason}\n")).await;
|
|
1449 |
+ |
return Some(GateOutcome::failed(GateFailure::CodeSmokeSetup { reason }));
|
|
1450 |
+ |
}
|
|
1451 |
+ |
};
|
|
1452 |
+ |
if status.success() {
|
| 1464 |
1453 |
|
return None;
|
| 1465 |
1454 |
|
}
|
| 1466 |
1455 |
|
Some(GateOutcome::failed(GateFailure::CodeSmokeDocs {
|
| 1467 |
|
- |
broken: parse_check_docs_broken_count(&out.stdout),
|
|
1456 |
+ |
broken: parse_check_docs_broken_count(&stdout),
|
| 1468 |
1457 |
|
}))
|
| 1469 |
1458 |
|
}
|
| 1470 |
1459 |
|
|
| 1488 |
1477 |
|
/// The createdb-to-dropdb interior of `code_smoke`: migrate+seed, then boot and
|
| 1489 |
1478 |
|
/// probe. Returns the outcome without a `log_ref` (the caller attaches it after
|
| 1490 |
1479 |
|
/// teardown). Never returns `Err` — spawn/child failures map to typed outcomes.
|
| 1491 |
|
- |
async fn code_smoke_body(
|
| 1492 |
|
- |
ctx: &GateCtx,
|
| 1493 |
|
- |
bin: &str,
|
| 1494 |
|
- |
db_url: &str,
|
| 1495 |
|
- |
log_buf: &mut Vec<u8>,
|
| 1496 |
|
- |
) -> GateOutcome {
|
|
1480 |
+ |
async fn code_smoke_body(ctx: &GateCtx, bin: &str, db_url: &str, log: &GateLog) -> GateOutcome {
|
| 1497 |
1481 |
|
let server_dir = ctx.worktree.join("server");
|
| 1498 |
1482 |
|
|
| 1499 |
1483 |
|
// Phase 1: migrate-from-scratch + seed. `--seed-examples` loads config,
|
| 1500 |
1484 |
|
// connects, runs migrations against the empty DB, seeds the catalog, exits.
|
| 1501 |
1485 |
|
// A non-zero exit here is the "code is unsound" signal (broken migration,
|
| 1502 |
1486 |
|
// seed error, or config-load failure).
|
| 1503 |
|
- |
log_buf.extend_from_slice(b"---- migrate + seed (--seed-examples) ----\n");
|
|
1487 |
+ |
log.line("---- migrate + seed (--seed-examples) ----\n")
|
|
1488 |
+ |
.await;
|
| 1504 |
1489 |
|
let mut seed_cmd = tokio::process::Command::new(bin);
|
| 1505 |
1490 |
|
seed_cmd.arg("--seed-examples").current_dir(&server_dir);
|
| 1506 |
1491 |
|
code_smoke_env(&mut seed_cmd, ctx, db_url);
|
| 1507 |
|
- |
seed_cmd
|
| 1508 |
|
- |
.env("ALLOW_EXAMPLE_SEED", "1")
|
| 1509 |
|
- |
.stdout(std::process::Stdio::piped())
|
| 1510 |
|
- |
.stderr(std::process::Stdio::piped())
|
| 1511 |
|
- |
.kill_on_drop(true);
|
| 1512 |
|
- |
let seed_out = match seed_cmd.output().await {
|
| 1513 |
|
- |
Ok(o) => o,
|
|
1492 |
+ |
seed_cmd.env("ALLOW_EXAMPLE_SEED", "1").kill_on_drop(true);
|
|
1493 |
+ |
let seed_status = match log.run(&mut seed_cmd).await {
|
|
1494 |
+ |
Ok((_stdout, _stderr, status)) => status,
|
| 1514 |
1495 |
|
Err(e) => {
|
| 1515 |
1496 |
|
return GateOutcome::failed(GateFailure::SpawnFailed {
|
| 1516 |
1497 |
|
message: e.to_string(),
|
| 1517 |
1498 |
|
});
|
| 1518 |
1499 |
|
}
|