| 10 |
10 |
|
use crate::live_log::LiveLog;
|
| 11 |
11 |
|
use crate::outcome::{GateBlocker, GateFailure, GateOutcome, LogRef, PassNote};
|
| 12 |
12 |
|
use crate::topology::Gate;
|
| 13 |
|
- |
use anyhow::Result;
|
|
13 |
+ |
use anyhow::{Context, Result};
|
| 14 |
14 |
|
use chrono::Utc;
|
| 15 |
15 |
|
use sqlx::SqlitePool;
|
| 16 |
16 |
|
use std::path::PathBuf;
|
| 296 |
296 |
|
};
|
| 297 |
297 |
|
|
| 298 |
298 |
|
log_buf.extend_from_slice(b"---- reset_scratch ----\n");
|
| 299 |
|
- |
if let Err(e) = reset_scratch(db_url).await {
|
|
299 |
+ |
if let Err(e) = reset_scratch(db_url, &ctx.cfg.scratch_owner_role).await {
|
| 300 |
300 |
|
let msg = format!("scratch reset: {e}");
|
| 301 |
301 |
|
log_buf.extend_from_slice(msg.as_bytes());
|
| 302 |
302 |
|
return Ok(finish(
|
| 338 |
338 |
|
}
|
| 339 |
339 |
|
}
|
| 340 |
340 |
|
|
| 341 |
|
- |
pub(crate) async fn reset_scratch(db_url: &str) -> Result<()> {
|
|
341 |
+ |
pub(crate) async fn reset_scratch(db_url: &str, owner_role: &str) -> Result<()> {
|
| 342 |
342 |
|
use sqlx::postgres::PgPoolOptions;
|
| 343 |
343 |
|
use sqlx::Executor;
|
| 344 |
344 |
|
let pool = PgPoolOptions::new().max_connections(1).connect(db_url).await?;
|
| 345 |
|
- |
// Drop every non-system schema, not just public — migrations create custom
|
| 346 |
|
- |
// schemas (e.g. tower_sessions) that survive `DROP SCHEMA public CASCADE`
|
| 347 |
|
- |
// and then collide on the next migration run.
|
| 348 |
|
- |
pool.execute(
|
|
345 |
+ |
// `owner_role` is validated `[A-Za-z0-9_]+` at config load, so interpolating
|
|
346 |
+ |
// it into DDL is sound. It still goes through `format('%I')` inside the DO
|
|
347 |
+ |
// block for the quoting Postgres expects on an identifier.
|
|
348 |
+ |
let sql = format!(
|
| 349 |
349 |
|
r#"
|
| 350 |
350 |
|
DO $$
|
| 351 |
351 |
|
DECLARE s text;
|
| 352 |
352 |
|
BEGIN
|
|
353 |
+ |
-- The dump restores objects owned by the prod role and re-grants to
|
|
354 |
+ |
-- it (`ALTER ... OWNER TO {owner_role}`), which errors if the role
|
|
355 |
+ |
-- is absent — superuser does not imply the role exists. Create it
|
|
356 |
+ |
-- NOLOGIN: the scratch DB needs the role as an *owner* only, never
|
|
357 |
+ |
-- as a connecting identity. Idempotent, so a re-reset is a no-op.
|
|
358 |
+ |
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{owner_role}') THEN
|
|
359 |
+ |
EXECUTE format('CREATE ROLE %I NOLOGIN', '{owner_role}');
|
|
360 |
+ |
END IF;
|
|
361 |
+ |
|
|
362 |
+ |
-- Drop every non-system schema, not just public — migrations create
|
|
363 |
+ |
-- custom schemas (e.g. tower_sessions) that survive `DROP SCHEMA
|
|
364 |
+ |
-- public CASCADE` and then collide on the next migration run.
|
| 353 |
365 |
|
FOR s IN
|
| 354 |
366 |
|
SELECT nspname FROM pg_namespace
|
| 355 |
367 |
|
WHERE nspname NOT LIKE 'pg_%'
|
| 362 |
374 |
|
-- scratch DB. Without this, the freshly-created public is owned by
|
| 363 |
375 |
|
-- the connecting role (sando) with no grant to anyone else, so a
|
| 364 |
376 |
|
-- migration's FK/trigger check that Postgres runs as a *restored*
|
| 365 |
|
- |
-- prod-owned table's owner (e.g. makenotwork from the backup dump)
|
|
377 |
+ |
-- prod-owned table's owner ({owner_role} from the backup dump)
|
| 366 |
378 |
|
-- fails with "permission denied for schema public". Granting to
|
| 367 |
379 |
|
-- PUBLIC is role-agnostic and safe here — this DB is disposable and
|
| 368 |
380 |
|
-- exists only to dry-run migrations.
|
| 369 |
381 |
|
EXECUTE 'GRANT USAGE, CREATE ON SCHEMA public TO PUBLIC';
|
|
382 |
+ |
-- PG15+: the new owner needs CREATE on public in its own right, not
|
|
383 |
+ |
-- only via PUBLIC, for the restore's owner-scoped DDL.
|
|
384 |
+ |
EXECUTE format('GRANT USAGE, CREATE ON SCHEMA public TO %I', '{owner_role}');
|
| 370 |
385 |
|
END $$;
|
| 371 |
|
- |
"#,
|
|
386 |
+ |
"#
|
|
387 |
+ |
);
|
|
388 |
+ |
pool.execute(sql.as_str()).await?;
|
|
389 |
+ |
pool.close().await;
|
|
390 |
+ |
Ok(())
|
|
391 |
+ |
}
|
|
392 |
+ |
|
|
393 |
+ |
/// Startup assertion for the scratch cluster: the gates reset it, seed an owner
|
|
394 |
+ |
/// role into it, and drop leftover test databases in it — none of which a plain
|
|
395 |
+ |
/// unprivileged role can do. Historically these preconditions were satisfied by
|
|
396 |
+ |
/// hand on fw13 (`ALTER ROLE sando SUPERUSER`, a hand-created `makenotwork`
|
|
397 |
+ |
/// role) and nothing recorded that, so a rebuild elsewhere would fail one gate
|
|
398 |
+ |
/// at a time with an opaque permissions error. Assert once, at boot, loudly.
|
|
399 |
+ |
///
|
|
400 |
+ |
/// Not part of `--check-config`: that path is pure by design (no DB, no
|
|
401 |
+ |
/// network), and a green there must mean "this build understands its config",
|
|
402 |
+ |
/// not "the cluster is reachable".
|
|
403 |
+ |
pub async fn preflight_scratch_privileges(db_url: &str) -> Result<()> {
|
|
404 |
+ |
use sqlx::postgres::PgPoolOptions;
|
|
405 |
+ |
let pool = PgPoolOptions::new()
|
|
406 |
+ |
.max_connections(1)
|
|
407 |
+ |
.connect(db_url)
|
|
408 |
+ |
.await
|
|
409 |
+ |
.context("connecting to scratch_db_url for the startup privilege check")?;
|
|
410 |
+ |
let (is_super, can_signal): (bool, bool) = sqlx::query_as(
|
|
411 |
+ |
"SELECT rolsuper, pg_catalog.pg_has_role(current_user, 'pg_signal_backend', 'USAGE')
|
|
412 |
+ |
FROM pg_roles WHERE rolname = current_user",
|
| 372 |
413 |
|
)
|
|
414 |
+ |
.fetch_one(&pool)
|
| 373 |
415 |
|
.await?;
|
| 374 |
416 |
|
pool.close().await;
|
|
417 |
+ |
anyhow::ensure!(
|
|
418 |
+ |
is_super || can_signal,
|
|
419 |
+ |
"the scratch_db_url role has neither SUPERUSER nor pg_signal_backend; migration_dry_run \
|
|
420 |
+ |
and cargo_test cannot reset the scratch DB or clear stale test databases. Grant one:\n \
|
|
421 |
+ |
ALTER ROLE <role> SUPERUSER; -- what fw13 uses\n \
|
|
422 |
+ |
GRANT pg_signal_backend TO <role>; -- narrower: terminate only, cannot drop \
|
|
423 |
+ |
foreign-owned databases",
|
|
424 |
+ |
);
|
|
425 |
+ |
if !is_super {
|
|
426 |
+ |
tracing::warn!(
|
|
427 |
+ |
"scratch role has pg_signal_backend but not SUPERUSER: stale test databases owned by \
|
|
428 |
+ |
another role cannot be dropped, and the scratch owner role cannot be created if absent"
|
|
429 |
+ |
);
|
|
430 |
+ |
}
|
| 375 |
431 |
|
Ok(())
|
| 376 |
432 |
|
}
|
| 377 |
433 |
|
|
| 378 |
434 |
|
/// Best-effort cleanup of stale per-test database clones (`mnw_test_<uuid>`)
|
| 379 |
|
- |
/// left behind by a previously-killed `cargo_test` run. Only drops databases
|
| 380 |
|
- |
/// owned by the connecting role — a foreign-owned leftover can't be dropped
|
| 381 |
|
- |
/// without superuser anyway, and the harness namespaces its template per role
|
| 382 |
|
- |
/// so one can't wedge us.
|
|
435 |
+ |
/// left behind by a previously-killed `cargo_test` run.
|
|
436 |
+ |
///
|
|
437 |
+ |
/// Drops **foreign-owned leftovers too**, which is why the daemon asserts
|
|
438 |
+ |
/// SUPERUSER at startup (`preflight_scratch_privileges`): `DROP DATABASE`
|
|
439 |
+ |
/// requires ownership or superuser, and the `WITH (FORCE)` terminate requires
|
|
440 |
+ |
/// superuser or `pg_signal_backend`. Without both, orphans from a run under a
|
|
441 |
+ |
/// different role accumulate and degrade the gate — the failure this cleanup
|
|
442 |
+ |
/// exists to prevent.
|
|
443 |
+ |
///
|
|
444 |
+ |
/// OPERATIONAL HAZARD: fw13 runs one Postgres cluster shared with local `cargo
|
|
445 |
+ |
/// test` as `max`, so a gate firing mid-local-test will force-drop that run's
|
|
446 |
+ |
/// databases out from under it. That collision is known and tracked separately
|
|
447 |
+ |
/// (give the gate its own cluster); until then, do not run local tests on fw13
|
|
448 |
+ |
/// while a Sando gate is live.
|
| 383 |
449 |
|
///
|
| 384 |
450 |
|
/// Deliberately **excludes the template** (`mnw_test_template_*`): the harness
|
| 385 |
451 |
|
/// reuses it across runs when it's migration-current (skipping a full
|
| 397 |
463 |
|
return;
|
| 398 |
464 |
|
}
|
| 399 |
465 |
|
};
|
| 400 |
|
- |
// Owned by us OR by a role we're a member of (the shared `mnw_test`
|
| 401 |
|
- |
// role): pg_has_role(.., 'USAGE') covers both, and membership is enough
|
| 402 |
|
- |
// privilege to DROP. Foreign-owned leftovers are left untouched.
|
|
466 |
+ |
// Every per-test clone, whoever owns it. The ownership filter this used to
|
|
467 |
+ |
// carry is what let foreign-owned orphans pile up; superuser (asserted at
|
|
468 |
+ |
// startup) makes them droppable.
|
| 403 |
469 |
|
let names: Vec<(String,)> = sqlx::query_as(
|
| 404 |
470 |
|
"SELECT datname FROM pg_database
|
| 405 |
471 |
|
WHERE datname LIKE 'mnw_test_%'
|
| 406 |
|
- |
AND datname NOT LIKE '%template%'
|
| 407 |
|
- |
AND pg_catalog.pg_has_role(current_user, datdba, 'USAGE')",
|
|
472 |
+ |
AND datname NOT LIKE '%template%'",
|
| 408 |
473 |
|
)
|
| 409 |
474 |
|
.fetch_all(&pool)
|
| 410 |
475 |
|
.await
|
| 1086 |
1151 |
|
.await.unwrap();
|
| 1087 |
1152 |
|
pool.close().await;
|
| 1088 |
1153 |
|
|
| 1089 |
|
- |
reset_scratch(&url).await.expect("reset_scratch");
|
|
1154 |
+ |
reset_scratch(&url, "makenotwork").await.expect("reset_scratch");
|
| 1090 |
1155 |
|
|
| 1091 |
1156 |
|
let pool = PgPoolOptions::new().max_connections(1).connect(&url).await.unwrap();
|
| 1092 |
1157 |
|
let rows: Vec<(String,)> = sqlx::query_as(
|
| 1101 |
1166 |
|
pool.close().await;
|
| 1102 |
1167 |
|
}
|
| 1103 |
1168 |
|
|
|
1169 |
+ |
/// reset_scratch must leave the dump's owner role existing and able to
|
|
1170 |
+ |
/// create in `public`, because a prod `pg_dump` carries `ALTER ... OWNER TO
|
|
1171 |
+ |
/// <role>` for every object. This was satisfied by a hand-created NOLOGIN
|
|
1172 |
+ |
/// role on fw13; nothing recorded it, so any other box failed
|
|
1173 |
+ |
/// migration_dry_run at the restore with "role does not exist".
|
|
1174 |
+ |
///
|
|
1175 |
+ |
/// Uses a throwaway role name so it can prove the *creation* path rather
|
|
1176 |
+ |
/// than passing on fw13's pre-existing `makenotwork`. Same
|
|
1177 |
+ |
/// `SANDO_TEST_PG_URL` gate as above; needs a superuser connection.
|
|
1178 |
+ |
#[tokio::test]
|
|
1179 |
+ |
async fn reset_scratch_seeds_the_dump_owner_role_when_absent() {
|
|
1180 |
+ |
let Ok(url) = std::env::var("SANDO_TEST_PG_URL") else {
|
|
1181 |
+ |
eprintln!("skipping: SANDO_TEST_PG_URL not set");
|
|
1182 |
+ |
return;
|
|
1183 |
+ |
};
|
|
1184 |
+ |
use sqlx::Executor;
|
|
1185 |
+ |
use sqlx::postgres::PgPoolOptions;
|
|
1186 |
+ |
|
|
1187 |
+ |
let role = "sando_test_owner_probe";
|
|
1188 |
+ |
// `DROP ROLE` refuses while the role still holds the grants reset_scratch
|
|
1189 |
+ |
// gave it, so drop what it owns first. Idempotent, and a no-op when the
|
|
1190 |
+ |
// role is absent (the usual case on a first run).
|
|
1191 |
+ |
let drop_role = format!(
|
|
1192 |
+ |
"DO $$ BEGIN
|
|
1193 |
+ |
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{role}') THEN
|
|
1194 |
+ |
EXECUTE 'DROP OWNED BY {role}';
|
|
1195 |
+ |
EXECUTE 'DROP ROLE {role}';
|
|
1196 |
+ |
END IF;
|
|
1197 |
+ |
END $$;"
|
|
1198 |
+ |
);
|
|
1199 |
+ |
|
|
1200 |
+ |
let pool = PgPoolOptions::new().max_connections(1).connect(&url).await.unwrap();
|
|
1201 |
+ |
pool.execute(drop_role.as_str()).await.unwrap();
|
|
1202 |
+ |
pool.close().await;
|
|
1203 |
+ |
|
|
1204 |
+ |
reset_scratch(&url, role).await.expect("reset_scratch creates the owner role");
|
|
1205 |
+ |
|
|
1206 |
+ |
let pool = PgPoolOptions::new().max_connections(1).connect(&url).await.unwrap();
|
|
1207 |
+ |
let (exists, can_login): (bool, bool) = sqlx::query_as(
|
|
1208 |
+ |
"SELECT true, rolcanlogin FROM pg_roles WHERE rolname = $1",
|
|
1209 |
+ |
)
|
|
1210 |
+ |
.bind(role)
|
|
1211 |
+ |
.fetch_one(&pool)
|
|
1212 |
+ |
.await
|
|
1213 |
+ |
.expect("owner role exists after reset");
|
|
1214 |
+ |
assert!(exists);
|
|
1215 |
+ |
assert!(!can_login, "the owner role is an owner only, never a login identity");
|
|
1216 |
+ |
|
|
1217 |
+ |
// The restore's owner-scoped DDL needs CREATE on public in the role's
|
|
1218 |
+ |
// own right (PG15+ dropped the implicit grant).
|
|
1219 |
+ |
let (has_create,): (bool,) = sqlx::query_as(
|
|
1220 |
+ |
"SELECT pg_catalog.has_schema_privilege($1, 'public', 'CREATE')",
|
|
1221 |
+ |
)
|
|
1222 |
+ |
.bind(role)
|
|
1223 |
+ |
.fetch_one(&pool)
|
|
1224 |
+ |
.await
|
|
1225 |
+ |
.unwrap();
|
|
1226 |
+ |
assert!(has_create, "owner role must be able to create in public");
|
|
1227 |
+ |
|
|
1228 |
+ |
// Idempotent: a second reset must not error on the now-existing role.
|
|
1229 |
+ |
pool.close().await;
|
|
1230 |
+ |
reset_scratch(&url, role).await.expect("reset_scratch is idempotent");
|
|
1231 |
+ |
|
|
1232 |
+ |
let pool = PgPoolOptions::new().max_connections(1).connect(&url).await.unwrap();
|
|
1233 |
+ |
pool.execute(drop_role.as_str()).await.unwrap();
|
|
1234 |
+ |
pool.close().await;
|
|
1235 |
+ |
}
|
|
1236 |
+ |
|
|
1237 |
+ |
/// The preflight must pass against a privileged scratch connection. Guards
|
|
1238 |
+ |
/// the catalog query itself: a wrong column or a `current_user` that matches
|
|
1239 |
+ |
/// no `pg_roles` row would make `fetch_one` error (or, worse, a silently
|
|
1240 |
+ |
/// swapped pair would invert the check) and brick startup for everyone.
|
|
1241 |
+ |
/// `SANDO_TEST_PG_URL` is expected to be a superuser connection, as the
|
|
1242 |
+ |
/// gates require.
|
|
1243 |
+ |
#[tokio::test]
|
|
1244 |
+ |
async fn preflight_passes_on_a_privileged_scratch_connection() {
|
|
1245 |
+ |
let Ok(url) = std::env::var("SANDO_TEST_PG_URL") else {
|
|
1246 |
+ |
eprintln!("skipping: SANDO_TEST_PG_URL not set");
|
|
1247 |
+ |
return;
|
|
1248 |
+ |
};
|
|
1249 |
+ |
preflight_scratch_privileges(&url)
|
|
1250 |
+ |
.await
|
|
1251 |
+ |
.expect("a superuser scratch connection must satisfy the preflight");
|
|
1252 |
+ |
}
|
|
1253 |
+ |
|
| 1104 |
1254 |
|
/// CF4: the restore pipeline must carry `ON_ERROR_STOP=1` (so psql fails on
|
| 1105 |
1255 |
|
/// a bad statement instead of exiting 0 on a partial restore) and, for a
|
| 1106 |
1256 |
|
/// gzip source, `set -o pipefail` (so a `gunzip` failure on a truncated
|