96 lines
4.7 KiB
Bash
Executable File
96 lines
4.7 KiB
Bash
Executable File
#!/bin/sh
|
|
# Ordered, checksummed migration runner (review MIG-01).
|
|
#
|
|
# Applies every /db/migrations/*.sql file exactly once, in name order, and
|
|
# records it in the _migration ledger. Apply and record share ONE transaction
|
|
# under a fixed advisory lock (review MIG-03): a crash can no longer leave an
|
|
# applied file unrecorded (or a recorded file unapplied), and a concurrent
|
|
# runner blocks on the lock, then aborts on the ledger primary key instead of
|
|
# double-applying. A recorded file is immutable: if its checksum no longer
|
|
# matches, the runner aborts before applying anything else.
|
|
#
|
|
# Migration files must therefore be transaction-safe and must NOT carry
|
|
# their own BEGIN/COMMIT (the runner wraps each file): statements that cannot
|
|
# run inside a transaction block (CREATE INDEX CONCURRENTLY, VACUUM, ...) are
|
|
# NOT supported by this runner — handle those as explicit operator steps
|
|
# outside the ledger. Files stay idempotent by convention (defense in depth;
|
|
# migration 001 must remain so for first ledger adoption on databases already
|
|
# upgraded via the legacy upgrade.sql).
|
|
#
|
|
# Environment: PGPASSWORD (required), DB_HOST, DB_USER, DB_NAME, MIGRATIONS.
|
|
set -eu
|
|
|
|
DB_HOST="${DB_HOST:-db}"
|
|
DB_USER="${DB_USER:-computer}"
|
|
DB_NAME="${DB_NAME:-computer}"
|
|
MIGRATIONS="${MIGRATIONS:-/db/migrations}"
|
|
[ -n "${PGPASSWORD:-}" ] || { echo "error: PGPASSWORD is required" >&2; exit 1; }
|
|
|
|
psql() {
|
|
command psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 "$@"
|
|
}
|
|
|
|
psql -qc 'CREATE TABLE IF NOT EXISTS _migration(name text PRIMARY KEY, checksum text NOT NULL, "appliedAt" timestamptz NOT NULL DEFAULT now())'
|
|
|
|
# --- Preflight (review CR-12): validate the COMPLETE inventory/ledger pair
|
|
# BEFORE any schema change. The old single loop applied pending files as it
|
|
# walked, so a pending migration earlier in the chain could mutate the
|
|
# database before a later file's checksum mismatch was detected. Policy:
|
|
# - a recorded migration missing from disk is an integrity failure (the
|
|
# applied chain must stay complete — restore the file, do not edit it);
|
|
# - a recorded migration whose checksum changed is an integrity failure
|
|
# (applied files are immutable; ship a new numbered file);
|
|
# - late insertion of an EARLIER-numbered file is allowed by design: files
|
|
# apply in name order on the next run and the ledger is keyed by name,
|
|
# not by sequence position.
|
|
# A concurrent runner between preflight and apply is still safe: the apply
|
|
# loop re-checks the ledger per file, and apply+record share one transaction
|
|
# under the advisory lock with no ON CONFLICT, so a racer fails loudly on the
|
|
# primary key instead of double-applying.
|
|
# Colon-joined so shell word splitting yields one entry per ledger row
|
|
# (names contain no colons; checksums are hex).
|
|
for entry in $(psql -Atc "SELECT name || ':' || checksum FROM _migration"); do
|
|
name="${entry%%:*}"
|
|
sum="${entry#*:}"
|
|
f="$MIGRATIONS/$name"
|
|
if [ ! -e "$f" ]; then
|
|
echo "error: migration $name is recorded in the ledger but missing from $MIGRATIONS." >&2
|
|
echo " The applied chain must stay complete; restore the file (ledger checksum $sum)." >&2
|
|
exit 1
|
|
fi
|
|
actual="$(sha256sum "$f" | cut -d' ' -f1)"
|
|
if [ "$actual" != "$sum" ]; then
|
|
echo "error: migration $name changed after being applied (ledger $sum, file $actual)." >&2
|
|
echo " Applied migrations are immutable; ship the change as a new numbered file." >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
for f in "$MIGRATIONS"/*.sql; do
|
|
[ -e "$f" ] || { echo "error: no migrations found in $MIGRATIONS" >&2; exit 1; }
|
|
name="$(basename "$f")"
|
|
sum="$(sha256sum "$f" | cut -d' ' -f1)"
|
|
recorded="$(psql -Atc "SELECT checksum FROM _migration WHERE name='$name'")"
|
|
if [ -n "$recorded" ]; then
|
|
# Preflight proved the checksum matches; re-check here so a file mutated
|
|
# mid-run is still refused before anything else applies.
|
|
if [ "$recorded" != "$sum" ]; then
|
|
echo "error: migration $name changed after being applied (ledger $recorded, file $sum)." >&2
|
|
echo " Applied migrations are immutable; ship the change as a new numbered file." >&2
|
|
exit 1
|
|
fi
|
|
continue
|
|
fi
|
|
echo "applying $name"
|
|
# One transaction: psql executes interleaved -c/-f options under -1 as a
|
|
# single transaction (PostgreSQL >= 12). Take the lock (arbitrary fixed
|
|
# key), record, then apply — any failure rolls back both. Deliberately no
|
|
# ON CONFLICT: a runner that lost the race must fail loudly on the primary
|
|
# key rather than silently re-execute an already-recorded file.
|
|
psql -1 -q \
|
|
-c 'DO $$ BEGIN PERFORM pg_advisory_xact_lock(742417); END $$' \
|
|
-c "INSERT INTO _migration(name,checksum) VALUES('$name','$sum')" \
|
|
-f "$f"
|
|
done
|
|
echo "migrations up to date"
|