1067 lines
48 KiB
Bash
1067 lines
48 KiB
Bash
|
|
#!/usr/bin/env bash
|
||
|
|
#
|
||
|
|
# Apply numbered SQL migrations to PostgreSQL, in numeric order, exactly once
|
||
|
|
# each — and refuse to touch the database at all if a migration that was already
|
||
|
|
# applied has since been edited.
|
||
|
|
#
|
||
|
|
# ./scripts/migrate.sh # apply everything pending
|
||
|
|
# ./scripts/migrate.sh --status # what is applied, what is pending, exit
|
||
|
|
# ./scripts/migrate.sh --dry-run # exactly what would run, in order
|
||
|
|
# ./scripts/migrate.sh --help
|
||
|
|
#
|
||
|
|
# MIGRATE_LOCK_WAIT=120 ./scripts/migrate.sh # wait longer for a concurrent run
|
||
|
|
# MIGRATE_ALLOW_MISSING=1 ./scripts/migrate.sh # after a deliberate squash
|
||
|
|
#
|
||
|
|
# Exit codes, so CI can tell these apart:
|
||
|
|
# 0 nothing pending, or everything pending was applied
|
||
|
|
# 1 misconfigured, or a check could not be performed
|
||
|
|
# 2 refused — an edited migration, a duplicate number, an unusable file
|
||
|
|
# 3 another migration run holds the lock
|
||
|
|
# 4 a migration failed while running
|
||
|
|
#
|
||
|
|
# ===========================================================================
|
||
|
|
# TEMPLATE COPY — configure this before the first run
|
||
|
|
# ===========================================================================
|
||
|
|
#
|
||
|
|
# Copy to `scripts/migrate.sh` and set the two values in the CONFIGURATION
|
||
|
|
# block below. The script refuses to run until they are set: it has no
|
||
|
|
# defaults, deliberately.
|
||
|
|
#
|
||
|
|
# `migrations/` looks like a safe default for the directory and
|
||
|
|
# `schema_migrations` looks like a safe default for the tracking table, and
|
||
|
|
# both are traps. A directory default means a copy of this script run from the
|
||
|
|
# wrong checkout applies another project's DDL to this project's database. A
|
||
|
|
# tracking-table default means two projects sharing one database share one
|
||
|
|
# ledger: project B reads project A's rows, concludes its own 0001 through 0009
|
||
|
|
# are already applied, and never applies them — and nothing anywhere reports an
|
||
|
|
# error. Neither failure is visible until something reads a column that was
|
||
|
|
# never created.
|
||
|
|
#
|
||
|
|
# Assumes: bash, coreutils, git (optional), and `psql` on PATH. No jq, no
|
||
|
|
# language runtime, no migration framework.
|
||
|
|
#
|
||
|
|
# ## Why this exists
|
||
|
|
#
|
||
|
|
# Between `psql -f` typed by hand and a framework's migrator there is a gap
|
||
|
|
# that most projects live in, and the gap is where databases quietly diverge.
|
||
|
|
# Applying files by hand means the record of what ran lives in somebody's shell
|
||
|
|
# history; the second environment gets a different subset and nobody finds out
|
||
|
|
# until a query fails in production against a column that exists on the laptop.
|
||
|
|
#
|
||
|
|
# What closes that gap is not automation, it is a ledger the database itself
|
||
|
|
# carries: a row per migration, with the checksum of the file that produced it.
|
||
|
|
# Everything else here — the ordering, the lock, the transactions — exists to
|
||
|
|
# keep that ledger true.
|
||
|
|
#
|
||
|
|
# ## Immutability is the whole point
|
||
|
|
#
|
||
|
|
# A migration that has been applied is history. It is not a source file any
|
||
|
|
# more, and editing it is editing the past of every database that already ran
|
||
|
|
# it. So the checksum of every applied file is compared against the recorded
|
||
|
|
# one on every run, and a mismatch stops the run and names the file.
|
||
|
|
#
|
||
|
|
# This is the sharpest failure this script exists to prevent, because the
|
||
|
|
# unguarded version of it is silent in both directions. A runner that tracks
|
||
|
|
# only filenames will not re-apply the edited file — so the database that ran
|
||
|
|
# it yesterday keeps the old definition, the database created tomorrow gets the
|
||
|
|
# new one, and the two are now different databases with identical ledgers. A
|
||
|
|
# runner that re-applies it instead will run the edit against a schema that
|
||
|
|
# already has the original, which usually errors, and occasionally does not.
|
||
|
|
#
|
||
|
|
# The fix for a migration that was wrong is never an edit. It is a new
|
||
|
|
# migration with a higher number.
|
||
|
|
#
|
||
|
|
# ## One transaction per migration, including the tracking row
|
||
|
|
#
|
||
|
|
# Each migration and the INSERT that claims it was applied commit together. A
|
||
|
|
# failure therefore leaves neither the change nor the claim — no half-applied
|
||
|
|
# schema wearing a row that says it is done, which is the state that needs a
|
||
|
|
# human with a psql prompt to untangle.
|
||
|
|
#
|
||
|
|
# Some statements cannot run inside a transaction: CREATE INDEX CONCURRENTLY
|
||
|
|
# and DROP INDEX CONCURRENTLY are the ones that come up, and ALTER TYPE ... ADD
|
||
|
|
# VALUE on older servers. Mark such a file by putting this line anywhere in it:
|
||
|
|
#
|
||
|
|
# -- migrate: no-transaction
|
||
|
|
#
|
||
|
|
# and it runs with autocommit, with its tracking row inserted afterwards as a
|
||
|
|
# separate statement. That is strictly weaker and the script says so out loud
|
||
|
|
# each time: if the migration succeeds and the INSERT does not, the next run
|
||
|
|
# will try to apply it again. Use the marker only for statements that require
|
||
|
|
# it, and put nothing else in the file.
|
||
|
|
#
|
||
|
|
# ## What it deliberately does not do
|
||
|
|
#
|
||
|
|
# It has no `down` migration and it never rolls back a migration that already
|
||
|
|
# committed. Both are the same refusal. A down migration is written at the
|
||
|
|
# moment it is not needed, reviewed less carefully than the up, and then run
|
||
|
|
# for the first time on the one day everything is already on fire — which is
|
||
|
|
# also the first time anyone finds out it does not work, or that it drops a
|
||
|
|
# column holding data written since. Reversal that has never been executed is
|
||
|
|
# not a safety net, it is a second untested migration wearing one.
|
||
|
|
#
|
||
|
|
# Recovery here is a new forward migration, or the backup. Both are things that
|
||
|
|
# get tested.
|
||
|
|
#
|
||
|
|
# It also does not create the database, does not manage roles or extensions
|
||
|
|
# beyond whatever the migrations themselves do, and does not deploy anything.
|
||
|
|
# It reads a directory and writes to one database, and that is all.
|
||
|
|
|
||
|
|
set -uo pipefail
|
||
|
|
|
||
|
|
# Run from the repository root when there is one, so that a relative
|
||
|
|
# MIGRATIONS_DIR means the same directory whether this was invoked from the
|
||
|
|
# root or from a subdirectory. Not required: this also runs inside a deployed
|
||
|
|
# container where there is no checkout at all, so a missing git is not fatal.
|
||
|
|
if root=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$root" ]; then
|
||
|
|
cd "$root" || exit 1
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# CONFIGURATION — set these two, then delete this banner.
|
||
|
|
#
|
||
|
|
# Both are empty on purpose. See the note at the top: a directory default
|
||
|
|
# points this at another project's SQL, and a tracking-table default makes two
|
||
|
|
# projects sharing a database share one ledger. Both failures are silent.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# Directory holding the numbered migrations, e.g. db/migrations
|
||
|
|
MIGRATIONS_DIR="${MIGRATE_DIR:-}"
|
||
|
|
|
||
|
|
# Table that records what has been applied. Schema-qualify it if this project
|
||
|
|
# does not own the search_path, e.g. myapp.schema_migrations
|
||
|
|
TRACKING_TABLE="${MIGRATE_TABLE:-}"
|
||
|
|
|
||
|
|
say() { printf '\033[1mmigrate:\033[0m %s\n' "$*" >&2; }
|
||
|
|
die() { printf '\033[1mmigrate:\033[0m %s\n' "$*" >&2; exit 1; }
|
||
|
|
|
||
|
|
# Exit 2 rather than 1, and kept apart from die() for the whole file: "this is
|
||
|
|
# not configured" and "this is configured and I am refusing" are different
|
||
|
|
# answers, and CI that retries the first must not retry the second.
|
||
|
|
refuse() { printf '\033[1mmigrate:\033[0m %s\n' "$*" >&2; exit 2; }
|
||
|
|
|
||
|
|
MODE="apply"
|
||
|
|
|
||
|
|
# Arguments are read BEFORE the configuration is checked, so that --help works
|
||
|
|
# on an unconfigured copy. This file ships unconfigured on purpose, so the
|
||
|
|
# state somebody most needs to read the help in is the state it arrives in, and
|
||
|
|
# a --help that answers "set MIGRATIONS_DIR first" answers a question nobody
|
||
|
|
# asked. It also means an unknown argument is reported as an unknown argument.
|
||
|
|
#
|
||
|
|
# ${1+"$@"} rather than "$@": with no positional parameters and `set -u`, bash
|
||
|
|
# before 4.4 treats "$@" as unset and aborts — and bash 3.2 is what /bin/bash
|
||
|
|
# is on macOS, which the shasum fallback above exists to support. The ${1+...}
|
||
|
|
# form expands to nothing when there are no arguments, and to exactly "$@"
|
||
|
|
# when there are.
|
||
|
|
for arg in ${1+"$@"}; do
|
||
|
|
case "$arg" in
|
||
|
|
--status) MODE="status" ;;
|
||
|
|
--dry-run) MODE="dry-run" ;;
|
||
|
|
--help|-h)
|
||
|
|
printf '%s\n' \
|
||
|
|
"usage: migrate.sh [--status | --dry-run]" \
|
||
|
|
"" \
|
||
|
|
" (no flags) apply every pending migration, in numeric order" \
|
||
|
|
" --status print applied and pending migrations, then exit" \
|
||
|
|
" --dry-run print exactly what would be applied, and change nothing" \
|
||
|
|
"" \
|
||
|
|
"Required, and deliberately without defaults — see the CONFIGURATION" \
|
||
|
|
"block in this file for why:" \
|
||
|
|
"" \
|
||
|
|
" MIGRATE_DIR=<path> directory holding the numbered .sql files" \
|
||
|
|
" MIGRATE_TABLE=<name> table recording what has been applied" \
|
||
|
|
"" \
|
||
|
|
"Connection comes from the environment: DATABASE_URL, or the standard" \
|
||
|
|
"libpq variables (PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD)." \
|
||
|
|
"" \
|
||
|
|
"Prefer the libpq variables on a shared machine. DATABASE_URL is passed" \
|
||
|
|
"to psql as an argument, and arguments are world-readable in ps and" \
|
||
|
|
"/proc for the life of the run — so the password is visible to any" \
|
||
|
|
"local user while a migration is applying. PGPASSWORD is not." \
|
||
|
|
"" \
|
||
|
|
" MIGRATE_LOCK_WAIT=<seconds> how long to wait for a concurrent run (30)" \
|
||
|
|
" MIGRATE_PG_LOCK_TIMEOUT=<value> server lock_timeout, e.g. '5s' (unset)" \
|
||
|
|
" MIGRATE_ALLOW_MISSING=1 proceed when an applied migration has no file" \
|
||
|
|
" MIGRATE_ALLOW_OUT_OF_ORDER=1 proceed when a pending number is below the" \
|
||
|
|
" highest applied" >&2
|
||
|
|
exit 0 ;;
|
||
|
|
*) die "unknown argument '$arg'. Usage: migrate.sh [--status | --dry-run]" ;;
|
||
|
|
esac
|
||
|
|
done
|
||
|
|
|
||
|
|
# Refused rather than defaulted, and named one at a time so the message says
|
||
|
|
# which value is missing rather than "configuration error".
|
||
|
|
[ -n "$MIGRATIONS_DIR" ] || die "set MIGRATIONS_DIR (or MIGRATE_DIR) — the directory holding the numbered .sql files. See the CONFIGURATION block."
|
||
|
|
[ -n "$TRACKING_TABLE" ] || die "set TRACKING_TABLE (or MIGRATE_TABLE) — the table recording what has been applied. See the CONFIGURATION block."
|
||
|
|
|
||
|
|
# --status is a read-only diagnostic, and an edited migration, a missing file
|
||
|
|
# and an out-of-order pending number are precisely the three states somebody
|
||
|
|
# runs it to understand. Exiting before the report is refusing to answer the
|
||
|
|
# question that was asked — and worse, the failure text at the bottom of this
|
||
|
|
# script sends operators to --status by name to find out which migrations
|
||
|
|
# already succeeded, which is unreachable if drift makes --status refuse.
|
||
|
|
#
|
||
|
|
# So in status mode a refusal is recorded instead of taken, the report is still
|
||
|
|
# printed, and the exit code is still 2 afterwards — CI cannot read it as
|
||
|
|
# success, and a human gets the picture they asked for.
|
||
|
|
STATUS_EXIT=0
|
||
|
|
|
||
|
|
refuse_or_defer() {
|
||
|
|
if [ "$MODE" = "status" ]; then
|
||
|
|
STATUS_EXIT=2
|
||
|
|
say "(--status continues so it can still show you the state below; a real run stops here.)"
|
||
|
|
return 0
|
||
|
|
fi
|
||
|
|
exit 2
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tools, checked before anything is read or connected to.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
command -v psql >/dev/null 2>&1 || die "psql is not on PATH. Install the PostgreSQL client, or run this from an image that has one."
|
||
|
|
|
||
|
|
# \if and \gset are how each migration re-checks the ledger after the lock is
|
||
|
|
# held (see "the plan is re-checked" below). psql gained \if in 10; on anything
|
||
|
|
# older the conditional blocks are ignored rather than obeyed, which would run
|
||
|
|
# every migration in the plan unguarded. Refused rather than degraded.
|
||
|
|
psql_major=$(psql --version 2>/dev/null | sed -E 's/[^0-9]*([0-9]+).*/\1/')
|
||
|
|
|
||
|
|
case "$psql_major" in
|
||
|
|
''|*[!0-9]*) die "could not read the psql version ('$(psql --version 2>&1 | head -n 1)'). This needs psql 10 or newer for \\if." ;;
|
||
|
|
esac
|
||
|
|
|
||
|
|
[ "$psql_major" -ge 10 ] || die "psql ${psql_major} is too old: \\if arrived in psql 10, and without it the re-check that stops a concurrent run re-applying a migration is silently ignored."
|
||
|
|
|
||
|
|
# sha256sum is coreutils; shasum ships on macOS where coreutils does not. The
|
||
|
|
# checksum column is the load-bearing part of this script, so which tool
|
||
|
|
# produced it is decided once, here, rather than per call — two call sites
|
||
|
|
# picking different tools would produce two different hashes of one file and
|
||
|
|
# every migration would look edited.
|
||
|
|
if command -v sha256sum >/dev/null 2>&1; then
|
||
|
|
sha256_stream() { sha256sum | cut -d' ' -f1; }
|
||
|
|
elif command -v shasum >/dev/null 2>&1; then
|
||
|
|
sha256_stream() { shasum -a 256 | cut -d' ' -f1; }
|
||
|
|
else
|
||
|
|
die "no sha256 tool found (looked for sha256sum and shasum). Migrations cannot be checksummed, so this cannot verify that applied files are unchanged."
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Hashing stdin rather than a named file keeps the filename out of the digest,
|
||
|
|
# which is what makes a rename a rename instead of a new migration.
|
||
|
|
sha256_of() { sha256_stream < "$1"; }
|
||
|
|
|
||
|
|
work=$(mktemp -d) || die "cannot create a temporary directory"
|
||
|
|
|
||
|
|
# The generated SQL is kept when a migration fails, and only then. psql reports
|
||
|
|
# errors as "run.sql:LINE", and deleting the file it is pointing at turns the
|
||
|
|
# one piece of evidence about what actually ran into a dangling path.
|
||
|
|
KEEP_WORK=""
|
||
|
|
|
||
|
|
# The only rm in this script, and the belt-and-braces on it is deliberate:
|
||
|
|
# $work can only ever be what mktemp -d printed (the assignment above dies
|
||
|
|
# otherwise, and this trap is not installed until after it), but `rm -rf` is
|
||
|
|
# not a place to reason from "can only ever". -d re-confirms it is a directory
|
||
|
|
# that exists, so an empty or clobbered $work removes nothing.
|
||
|
|
trap '[ -n "$KEEP_WORK" ] || { [ -n "$work" ] && [ -d "$work" ] && rm -rf "$work"; }' EXIT
|
||
|
|
|
||
|
|
TAB=$(printf '\t')
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Validate the configured names before they reach SQL.
|
||
|
|
#
|
||
|
|
# The table name is interpolated into statements unquoted, so it is checked
|
||
|
|
# against what a bare identifier may contain and refused otherwise. This is not
|
||
|
|
# hostile-input defence — it is configuration — but a name needing quoting
|
||
|
|
# would break at a different place each time it was used, and the failure would
|
||
|
|
# look like a syntax error inside somebody's migration.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
printf '%s' "$TRACKING_TABLE" \
|
||
|
|
| grep -Eq '^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$' \
|
||
|
|
|| die "TRACKING_TABLE ('$TRACKING_TABLE') is not a plain identifier. Use name or schema.name, letters, digits and underscore only."
|
||
|
|
|
||
|
|
[ -d "$MIGRATIONS_DIR" ] || die "MIGRATIONS_DIR ('$MIGRATIONS_DIR') is not a directory. Create it, or point MIGRATE_DIR at the right one — this will not guess."
|
||
|
|
|
||
|
|
MIGRATIONS_ABS=$(cd "$MIGRATIONS_DIR" && pwd) || die "cannot resolve MIGRATIONS_DIR ('$MIGRATIONS_DIR')."
|
||
|
|
|
||
|
|
# psql's \i takes a single-quoted path, and its quoting rules are not the
|
||
|
|
# shell's: inside single quotes psql itself expands \t, \n, \r, \b, \f, \NNN
|
||
|
|
# and \xNN before the path ever reaches the filesystem. So all three of these
|
||
|
|
# produce a valid-looking script that addresses the wrong file, and all three
|
||
|
|
# are refused rather than guessed at.
|
||
|
|
#
|
||
|
|
# The backslash case is the one that looks harmless and is not. A directory
|
||
|
|
# named `tab\test` is handed to psql as `tab\test` and opened as `tab<TAB>est`
|
||
|
|
# — which usually does not exist, and the run fails claiming the MIGRATION is
|
||
|
|
# broken when the path is. It does not have to not-exist, either: a path that
|
||
|
|
# escapes into another real directory includes another project's SQL, silently.
|
||
|
|
#
|
||
|
|
# Spaces are fine and stay fine — the single quotes handle them, and the
|
||
|
|
# directories this lands in have spaces in their names.
|
||
|
|
case "$MIGRATIONS_ABS" in
|
||
|
|
*"'"*|*'"'*)
|
||
|
|
die "the migrations path ('$MIGRATIONS_ABS') contains a quote character. psql's \\i cannot address it safely; move the directory." ;;
|
||
|
|
*\\*)
|
||
|
|
die "the migrations path ('$MIGRATIONS_ABS') contains a backslash. psql expands backslash escapes inside \\i's quoted argument, so this would open a different path than the one on disk; move the directory." ;;
|
||
|
|
*[[:cntrl:]]*)
|
||
|
|
die "the migrations path contains a control character (a newline or a tab). psql's \\i cannot address it; move the directory." ;;
|
||
|
|
esac
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Read the directory.
|
||
|
|
#
|
||
|
|
# Everything here is checkable without a database and is checked before one is
|
||
|
|
# opened, so a badly-named file is a failure that costs nothing rather than one
|
||
|
|
# discovered halfway through a deploy.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
FILES_TSV="$work/files.tsv" # version <TAB> filename <TAB> checksum <TAB> transactional
|
||
|
|
: > "$FILES_TSV"
|
||
|
|
|
||
|
|
for path in "$MIGRATIONS_ABS"/*; do
|
||
|
|
[ -f "$path" ] || continue
|
||
|
|
|
||
|
|
base=${path##*/}
|
||
|
|
|
||
|
|
case "$base" in
|
||
|
|
*.sql) ;;
|
||
|
|
*)
|
||
|
|
# Said out loud rather than passed over. Only *.sql is a migration, and a
|
||
|
|
# file that is being ignored should say so on the run that ignores it —
|
||
|
|
# "0007_x.sql.bak" and "0007_x.sql" look identical in a code review.
|
||
|
|
say "note: ignoring $base — only *.sql files are migrations."
|
||
|
|
continue ;;
|
||
|
|
esac
|
||
|
|
|
||
|
|
# Refused, not skipped. A .sql file this script cannot number is a file that
|
||
|
|
# will never be applied and will never be mentioned again, which is the exact
|
||
|
|
# failure the ledger exists to prevent.
|
||
|
|
case "$base" in
|
||
|
|
[0-9]*_*) ;;
|
||
|
|
*) refuse "'$base' is not named <number>_<name>.sql, so it would never be applied. Rename it, or move it out of $MIGRATIONS_DIR." ;;
|
||
|
|
esac
|
||
|
|
|
||
|
|
case "$base" in
|
||
|
|
*[!A-Za-z0-9._-]*) refuse "'$base' contains a character outside A-Z a-z 0-9 . _ - . psql's \\i cannot address it reliably; rename it." ;;
|
||
|
|
esac
|
||
|
|
|
||
|
|
num=${base%%_*}
|
||
|
|
case "$num" in
|
||
|
|
*[!0-9]*) refuse "'$base' has a non-numeric prefix ('$num'). The prefix decides the order, so it must be digits only." ;;
|
||
|
|
esac
|
||
|
|
|
||
|
|
# 10# forces base ten. Without it, $((0008)) is an invalid octal literal and
|
||
|
|
# the eighth migration of any project stops the run with an arithmetic error.
|
||
|
|
version=$((10#$num))
|
||
|
|
|
||
|
|
checksum=$(sha256_of "$path") || die "could not checksum $base."
|
||
|
|
[ -n "$checksum" ] || die "empty checksum for $base — refusing to record a migration it cannot identify."
|
||
|
|
|
||
|
|
if grep -Eq '^[[:space:]]*--[[:space:]]*migrate:[[:space:]]*no-transaction[[:space:]]*$' "$path"; then
|
||
|
|
transactional="no"
|
||
|
|
else
|
||
|
|
transactional="yes"
|
||
|
|
fi
|
||
|
|
|
||
|
|
# A file whose last statement has no terminating semicolon is refused,
|
||
|
|
# because psql discards an unterminated buffer at end of file without
|
||
|
|
# complaining: the migration would run everything up to the last semicolon,
|
||
|
|
# succeed, and be recorded as applied with its final statement never sent.
|
||
|
|
# Trailing blank lines and trailing comment lines are ignored.
|
||
|
|
last=$(grep -vE '^[[:space:]]*(--.*)?$' "$path" | tail -n 1)
|
||
|
|
|
||
|
|
if [ -z "$last" ]; then
|
||
|
|
refuse "'$base' contains no SQL. Delete it, or give it a body — an empty migration would be recorded as applied and could never be filled in."
|
||
|
|
fi
|
||
|
|
|
||
|
|
printf '%s' "$last" | grep -Eq ';[[:space:]]*(--.*)?$' \
|
||
|
|
|| refuse "the last statement in '$base' has no terminating semicolon. psql drops an unterminated statement at end of file without an error, so this would be recorded as applied while its final statement never ran."
|
||
|
|
|
||
|
|
# Only for transactional files: this script opens the transaction, and a
|
||
|
|
# BEGIN or COMMIT inside the file would close it early — committing part of
|
||
|
|
# the migration and leaving the tracking INSERT in a transaction of its own.
|
||
|
|
# A file that genuinely manages its own transactions declares that with the
|
||
|
|
# no-transaction marker, so it is exempt.
|
||
|
|
#
|
||
|
|
# The semicolon in the pattern is load-bearing: plpgsql bodies open with a
|
||
|
|
# bare `BEGIN` and close with `END;`, and matching those would refuse every
|
||
|
|
# function definition in the project. `END` is left out of the pattern
|
||
|
|
# entirely for that reason.
|
||
|
|
if [ "$transactional" = "yes" ] \
|
||
|
|
&& grep -Eqi '^[[:space:]]*(BEGIN|COMMIT|ROLLBACK)[[:space:]]*;' "$path"; then
|
||
|
|
refuse "'$base' contains its own BEGIN;, COMMIT; or ROLLBACK;. This runner wraps each migration in a transaction together with its tracking row, and a nested one would commit half of it. Remove them — or, if this is a procedure body that must COMMIT, add '-- migrate: no-transaction' and take responsibility for the transaction yourself."
|
||
|
|
fi
|
||
|
|
|
||
|
|
printf '%s\t%s\t%s\t%s\n' "$version" "$base" "$checksum" "$transactional" >> "$FILES_TSV"
|
||
|
|
done
|
||
|
|
|
||
|
|
sort -t "$TAB" -k1,1n "$FILES_TSV" -o "$FILES_TSV" || die "could not sort the migration list."
|
||
|
|
|
||
|
|
TOTAL=$(grep -c . "$FILES_TSV")
|
||
|
|
|
||
|
|
if [ "$TOTAL" -eq 0 ]; then
|
||
|
|
say "no migrations in $MIGRATIONS_DIR — nothing to do."
|
||
|
|
exit 0
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Duplicate numbers are refused; gaps are reported.
|
||
|
|
#
|
||
|
|
# Two files sharing a number is not a style problem: the ledger is keyed on the
|
||
|
|
# number, so whichever ran first would make the other look applied for ever
|
||
|
|
# after. Both are named, because the fix is deciding which one moves.
|
||
|
|
#
|
||
|
|
# A gap is only ever a warning. It is normal after a branch is abandoned and
|
||
|
|
# suspicious after a merge, and this script cannot tell those apart — but a
|
||
|
|
# database that is missing 0006 while it holds 0005 and 0007 should hear about
|
||
|
|
# it here rather than from a foreign key three releases later.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
prev_version=""
|
||
|
|
prev_base=""
|
||
|
|
gaps=""
|
||
|
|
|
||
|
|
while IFS="$TAB" read -r version base _checksum _transactional; do
|
||
|
|
if [ -n "$prev_version" ]; then
|
||
|
|
if [ "$version" -eq "$prev_version" ]; then
|
||
|
|
refuse "'$prev_base' and '$base' share the number $version. The ledger is keyed on the number, so applying one would mark the other as applied. Renumber one of them."
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ "$version" -eq $((prev_version + 2)) ]; then
|
||
|
|
gaps="${gaps}${gaps:+, }$((prev_version + 1))"
|
||
|
|
elif [ "$version" -gt $((prev_version + 1)) ]; then
|
||
|
|
gaps="${gaps}${gaps:+, }$((prev_version + 1))-$((version - 1))"
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
prev_version="$version"
|
||
|
|
prev_base="$base"
|
||
|
|
done < "$FILES_TSV"
|
||
|
|
|
||
|
|
if [ -n "$gaps" ]; then
|
||
|
|
say "WARNING: the numbering skips ${gaps}. That is fine after an abandoned"
|
||
|
|
say " branch and is a missing file after a merge — check which."
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Connection. From the environment, never from this file.
|
||
|
|
#
|
||
|
|
# ## DATABASE_URL puts the password somewhere the redaction below cannot reach
|
||
|
|
#
|
||
|
|
# The redaction that follows keeps credentials out of THIS script's output. It
|
||
|
|
# does nothing about `ps`: a URL passed as an argument is world-readable in
|
||
|
|
# /proc/<pid>/cmdline for as long as psql runs, so on a shared machine any
|
||
|
|
# local user can read the password off a migration in progress.
|
||
|
|
#
|
||
|
|
# It is not parsed apart here, because doing that correctly means percent-
|
||
|
|
# decoding the userinfo exactly as libpq does, and a decoder that is subtly
|
||
|
|
# wrong breaks the connection — a worse outcome than the exposure it fixes.
|
||
|
|
#
|
||
|
|
# The fix is to not use a URL: set PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD
|
||
|
|
# instead, which libpq reads from the environment and never places in argv.
|
||
|
|
# --help says so too.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
DB_URL="${DATABASE_URL:-}"
|
||
|
|
|
||
|
|
PSQL=(psql -X -q -v ON_ERROR_STOP=1)
|
||
|
|
|
||
|
|
# -X is not cosmetic. A ~/.psqlrc setting AUTOCOMMIT off, ON_ERROR_ROLLBACK on,
|
||
|
|
# or a default search_path changes what these migrations mean, and the effect
|
||
|
|
# would follow one operator's laptop and no one else's.
|
||
|
|
|
||
|
|
if [ -n "$DB_URL" ]; then
|
||
|
|
PSQL+=(-d "$DB_URL")
|
||
|
|
# Credentials are redacted here because this string is printed on every run,
|
||
|
|
# into terminals and CI logs that are kept. psql accepts three shapes and
|
||
|
|
# only the first is a URI, so all three have to be covered or the redaction
|
||
|
|
# is theatre:
|
||
|
|
#
|
||
|
|
# postgres://user:pw@host/db URI userinfo
|
||
|
|
# host=h dbname=d password=pw keyword/value conninfo
|
||
|
|
# postgres://user@host/db?sslpassword=pw URI query parameter
|
||
|
|
#
|
||
|
|
# The userinfo pattern stops at the first '/', '?' or '#', so it can only
|
||
|
|
# ever redact inside the authority — and it takes the LAST '@' in there
|
||
|
|
# rather than the first, so a password containing a literal '@' does not
|
||
|
|
# leave its own tail sitting in the log.
|
||
|
|
TARGET=$(printf '%s' "$DB_URL" | sed -E \
|
||
|
|
-e 's%(//[^:/@]*):[^/?#]*@%\1:***@%' \
|
||
|
|
-e 's%(^|[[:space:]&?])(password|sslpassword)=[^[:space:]&]*%\1\2=***%g')
|
||
|
|
elif [ -n "${PGDATABASE:-}" ]; then
|
||
|
|
TARGET="${PGUSER:-$(id -un)}@${PGHOST:-local socket}${PGPORT:+:$PGPORT}/${PGDATABASE}"
|
||
|
|
else
|
||
|
|
# libpq's own default is a database named after the current unix user on the
|
||
|
|
# local socket. That is a real connection to a real database, so leaving it
|
||
|
|
# implicit means a mistyped environment migrates something at random instead
|
||
|
|
# of failing. Named values only.
|
||
|
|
die "no connection configured. Set DATABASE_URL, or at least PGDATABASE (with PGHOST/PGPORT/PGUSER as needed) — with neither, psql would connect to a database named after \$USER on the local socket, which is not a target anyone chose."
|
||
|
|
fi
|
||
|
|
|
||
|
|
db_query() { "${PSQL[@]}" -A -t -F "$TAB" -c "$1"; }
|
||
|
|
|
||
|
|
conn=$(db_query "SELECT current_database(), current_user, current_setting('server_version')" 2>"$work/connerr")
|
||
|
|
|
||
|
|
if [ -z "$conn" ]; then
|
||
|
|
say "could not open the database. psql said:"
|
||
|
|
sed 's/^/ /' "$work/connerr" >&2
|
||
|
|
die "target was ${TARGET}. Fix the connection and run this again; nothing was read or written."
|
||
|
|
fi
|
||
|
|
|
||
|
|
conn_db=$(printf '%s' "$conn" | cut -f1)
|
||
|
|
conn_user=$(printf '%s' "$conn" | cut -f2)
|
||
|
|
conn_ver=$(printf '%s' "$conn" | cut -f3)
|
||
|
|
|
||
|
|
say "target ${TARGET}"
|
||
|
|
say " database '${conn_db}' as '${conn_user}', PostgreSQL ${conn_ver}"
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Read the ledger.
|
||
|
|
#
|
||
|
|
# The table's absence is reported as absence, never as an empty ledger. "No
|
||
|
|
# migration has ever been applied here" and "the migrations were applied under
|
||
|
|
# a different table name, or into a schema this role cannot see" produce the
|
||
|
|
# same empty list and want opposite reactions from the operator.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
TABLE_EXISTS=$(db_query "SELECT to_regclass('${TRACKING_TABLE}') IS NOT NULL" 2>"$work/regerr")
|
||
|
|
|
||
|
|
case "$TABLE_EXISTS" in
|
||
|
|
t|f) ;;
|
||
|
|
*)
|
||
|
|
say "could not determine whether ${TRACKING_TABLE} exists. psql said:"
|
||
|
|
sed 's/^/ /' "$work/regerr" >&2
|
||
|
|
die "refusing to continue without knowing what has already been applied." ;;
|
||
|
|
esac
|
||
|
|
|
||
|
|
APPLIED_TSV="$work/applied.tsv" # version <TAB> checksum <TAB> filename <TAB> applied_at
|
||
|
|
: > "$APPLIED_TSV"
|
||
|
|
|
||
|
|
if [ "$TABLE_EXISTS" = "t" ]; then
|
||
|
|
if ! db_query "SELECT version, checksum, filename, applied_at FROM ${TRACKING_TABLE}" > "$APPLIED_TSV" 2>"$work/readerr"; then
|
||
|
|
say "could not read ${TRACKING_TABLE}. psql said:"
|
||
|
|
sed 's/^/ /' "$work/readerr" >&2
|
||
|
|
die "refusing to continue without the list of applied migrations."
|
||
|
|
fi
|
||
|
|
# Checked, like the sort of the file list is. `sort -o` writing over its own
|
||
|
|
# input can leave that input truncated if it fails, and a truncated ledger
|
||
|
|
# snapshot reads as "fewer migrations are applied than really are" — which
|
||
|
|
# plans already-applied migrations for re-application. The re-check inside
|
||
|
|
# the lock would catch it, but a guard that is only load-bearing when another
|
||
|
|
# guard fails is not one to lean on.
|
||
|
|
sort -t "$TAB" -k1,1n "$APPLIED_TSV" -o "$APPLIED_TSV" \
|
||
|
|
|| die "could not sort the list of applied migrations read from ${TRACKING_TABLE}."
|
||
|
|
fi
|
||
|
|
|
||
|
|
APPLIED_COUNT=$(grep -c . "$APPLIED_TSV")
|
||
|
|
|
||
|
|
applied_row() {
|
||
|
|
grep -m1 "^$1$TAB" "$APPLIED_TSV" 2>/dev/null
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# The immutability check. Everything above this exists to make it possible.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
DRIFT=""
|
||
|
|
|
||
|
|
while IFS="$TAB" read -r version base checksum _transactional; do
|
||
|
|
row=$(applied_row "$version") || true
|
||
|
|
[ -n "$row" ] || continue
|
||
|
|
|
||
|
|
was_checksum=$(printf '%s' "$row" | cut -f2)
|
||
|
|
was_file=$(printf '%s' "$row" | cut -f3)
|
||
|
|
|
||
|
|
if [ "$was_checksum" != "$checksum" ]; then
|
||
|
|
DRIFT="yes"
|
||
|
|
say "EDITED: ${base}"
|
||
|
|
say " recorded ${was_checksum}"
|
||
|
|
say " on disk ${checksum}"
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Same content, different name. Harmless to the database and worth saying:
|
||
|
|
# it is how a ledger and a directory start describing each other in different
|
||
|
|
# words, and the next person to grep for the recorded filename finds nothing.
|
||
|
|
if [ "$was_file" != "$base" ]; then
|
||
|
|
say "note: migration ${version} was recorded as '${was_file}' and is now '${base}' — same contents."
|
||
|
|
fi
|
||
|
|
done < "$FILES_TSV"
|
||
|
|
|
||
|
|
if [ -n "$DRIFT" ]; then
|
||
|
|
say ""
|
||
|
|
say "refusing to run: the file(s) above were applied to this database and have"
|
||
|
|
say "changed since. Editing an applied migration changes the past of every"
|
||
|
|
say "database that already ran it, and the databases that have not run it yet"
|
||
|
|
say "will get something different — with both ledgers claiming to agree."
|
||
|
|
say ""
|
||
|
|
say "Restore the original contents (git show is usually enough) and put the"
|
||
|
|
say "change in a NEW migration with a higher number. If the edit was only"
|
||
|
|
say "cosmetic, it still has to be reverted: the checksum is the only evidence"
|
||
|
|
say "these databases are the same, and it cannot tell cosmetic from not."
|
||
|
|
refuse_or_defer
|
||
|
|
fi
|
||
|
|
|
||
|
|
# A row with no file. Usually the wrong MIGRATIONS_DIR, sometimes a deliberate
|
||
|
|
# squash, occasionally a deleted migration that other databases still run.
|
||
|
|
# Refused by default because two of those three are wrong and all three are
|
||
|
|
# invisible.
|
||
|
|
ORPHANS=""
|
||
|
|
|
||
|
|
while IFS="$TAB" read -r version _checksum filename _applied_at; do
|
||
|
|
[ -n "$version" ] || continue
|
||
|
|
if ! cut -f1 "$FILES_TSV" | grep -qx "$version"; then
|
||
|
|
ORPHANS="${ORPHANS} ${version} ${filename}"$'\n'
|
||
|
|
fi
|
||
|
|
done < "$APPLIED_TSV"
|
||
|
|
|
||
|
|
if [ -n "$ORPHANS" ]; then
|
||
|
|
say "these migrations are recorded as applied but have no file in ${MIGRATIONS_DIR}:"
|
||
|
|
printf '%s' "$ORPHANS" >&2
|
||
|
|
|
||
|
|
if [ -z "${MIGRATE_ALLOW_MISSING:-}" ]; then
|
||
|
|
say "refusing to run. Either this is pointed at the wrong directory, or"
|
||
|
|
say "migrations were deleted that other databases have not applied yet."
|
||
|
|
say "If they were squashed deliberately, re-run with MIGRATE_ALLOW_MISSING=1."
|
||
|
|
refuse_or_defer
|
||
|
|
else
|
||
|
|
say "MIGRATE_ALLOW_MISSING is set — continuing without them."
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Pending, in order.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
PENDING_TSV="$work/pending.tsv"
|
||
|
|
: > "$PENDING_TSV"
|
||
|
|
|
||
|
|
HIGHEST_APPLIED=""
|
||
|
|
if [ "$APPLIED_COUNT" -gt 0 ]; then
|
||
|
|
HIGHEST_APPLIED=$(cut -f1 "$APPLIED_TSV" | sort -n | tail -n 1)
|
||
|
|
fi
|
||
|
|
|
||
|
|
BEHIND=""
|
||
|
|
|
||
|
|
while IFS="$TAB" read -r version base checksum transactional; do
|
||
|
|
applied_row "$version" >/dev/null && continue
|
||
|
|
|
||
|
|
if [ -n "$HIGHEST_APPLIED" ] && [ "$version" -lt "$HIGHEST_APPLIED" ]; then
|
||
|
|
BEHIND="${BEHIND} ${base} (this database is already at ${HIGHEST_APPLIED})"$'\n'
|
||
|
|
fi
|
||
|
|
|
||
|
|
printf '%s\t%s\t%s\t%s\n' "$version" "$base" "$checksum" "$transactional" >> "$PENDING_TSV"
|
||
|
|
done < "$FILES_TSV"
|
||
|
|
|
||
|
|
PENDING_COUNT=$(grep -c . "$PENDING_TSV")
|
||
|
|
|
||
|
|
# A pending migration numbered below one already applied arrives when two
|
||
|
|
# branches merge. It is refused rather than applied because the order it would
|
||
|
|
# run in here is not the order it ran in anywhere that merged the other way
|
||
|
|
# first, and "same migrations, different order" is the same divergence the
|
||
|
|
# checksum guard exists to catch — just harder to see.
|
||
|
|
if [ -n "$BEHIND" ]; then
|
||
|
|
say "these pending migrations are numbered below what this database has already applied:"
|
||
|
|
printf '%s' "$BEHIND" >&2
|
||
|
|
|
||
|
|
if [ -z "${MIGRATE_ALLOW_OUT_OF_ORDER:-}" ]; then
|
||
|
|
say "refusing to run. Renumber them above ${HIGHEST_APPLIED} so every database"
|
||
|
|
say "applies them in the same order, or re-run with"
|
||
|
|
say "MIGRATE_ALLOW_OUT_OF_ORDER=1 if you have confirmed the order does not"
|
||
|
|
say "matter for these particular statements."
|
||
|
|
refuse_or_defer
|
||
|
|
else
|
||
|
|
say "MIGRATE_ALLOW_OUT_OF_ORDER is set — applying them anyway."
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# --status
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
if [ "$MODE" = "status" ]; then
|
||
|
|
if [ "$TABLE_EXISTS" = "f" ]; then
|
||
|
|
say "${TRACKING_TABLE} does not exist. Nothing has been applied THROUGH THIS"
|
||
|
|
say "SCRIPT — which is not the same as an empty database. Check the table"
|
||
|
|
say "name before concluding the schema is unmigrated."
|
||
|
|
else
|
||
|
|
say "${APPLIED_COUNT} applied:"
|
||
|
|
while IFS="$TAB" read -r version _checksum filename applied_at; do
|
||
|
|
[ -n "$version" ] || continue
|
||
|
|
printf ' %-6s %-44s %s\n' "$version" "$filename" "$applied_at" >&2
|
||
|
|
done < "$APPLIED_TSV"
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ "$PENDING_COUNT" -eq 0 ]; then
|
||
|
|
say "0 pending — every file in ${MIGRATIONS_DIR} has been applied."
|
||
|
|
else
|
||
|
|
say "${PENDING_COUNT} pending:"
|
||
|
|
while IFS="$TAB" read -r version base _checksum transactional; do
|
||
|
|
[ -n "$version" ] || continue
|
||
|
|
if [ "$transactional" = "no" ]; then
|
||
|
|
printf ' %-6s %-44s %s\n' "$version" "$base" "(no-transaction)" >&2
|
||
|
|
else
|
||
|
|
printf ' %-6s %s\n' "$version" "$base" >&2
|
||
|
|
fi
|
||
|
|
done < "$PENDING_TSV"
|
||
|
|
fi
|
||
|
|
|
||
|
|
# 0 normally; 2 if something above would have stopped a real run. The report
|
||
|
|
# is printed either way, which is the whole point of --status.
|
||
|
|
exit "$STATUS_EXIT"
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ "$PENDING_COUNT" -eq 0 ]; then
|
||
|
|
say "${APPLIED_COUNT} applied, 0 pending — nothing to do."
|
||
|
|
exit 0
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# --dry-run
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
if [ "$MODE" = "dry-run" ]; then
|
||
|
|
say "--dry-run: nothing was changed. It would apply ${PENDING_COUNT}, in this order:"
|
||
|
|
|
||
|
|
if [ "$TABLE_EXISTS" = "f" ]; then
|
||
|
|
say " create ${TRACKING_TABLE} (it does not exist yet)"
|
||
|
|
fi
|
||
|
|
|
||
|
|
while IFS="$TAB" read -r version base _checksum transactional; do
|
||
|
|
[ -n "$version" ] || continue
|
||
|
|
if [ "$transactional" = "no" ]; then
|
||
|
|
printf ' %-6s %-44s %s\n' "$version" "$base" "NOT in a transaction" >&2
|
||
|
|
else
|
||
|
|
printf ' %-6s %s\n' "$version" "$base" >&2
|
||
|
|
fi
|
||
|
|
done < "$PENDING_TSV"
|
||
|
|
|
||
|
|
say "against ${TARGET} (database '${conn_db}')."
|
||
|
|
exit 0
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# The advisory lock, and why the whole run is a single psql session.
|
||
|
|
#
|
||
|
|
# A session-level advisory lock is released when its SESSION ends, so it can
|
||
|
|
# only span the run if the run is one session. That is the reason this builds
|
||
|
|
# one SQL program and feeds it to psql once, rather than calling psql per
|
||
|
|
# migration: per-migration calls would drop the lock between every file, and
|
||
|
|
# two deploys landing together would interleave — each seeing the other's
|
||
|
|
# half-finished ledger and both concluding the same migration was theirs to
|
||
|
|
# apply.
|
||
|
|
#
|
||
|
|
# The key is derived from the tracking table name, so two projects sharing one
|
||
|
|
# database do not block each other, and two runs of the same project always
|
||
|
|
# collide. That collision is the entire feature.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
LOCK_WAIT="${MIGRATE_LOCK_WAIT:-30}"
|
||
|
|
case "$LOCK_WAIT" in
|
||
|
|
''|*[!0-9]*) die "MIGRATE_LOCK_WAIT must be a whole number of seconds, got '$LOCK_WAIT'." ;;
|
||
|
|
esac
|
||
|
|
|
||
|
|
# Fifteen hex digits: the largest slice that always fits in a signed 64-bit
|
||
|
|
# integer, which is what pg_advisory_lock takes and what bash arithmetic holds.
|
||
|
|
lock_hex=$(printf '%s' "migrate:${TRACKING_TABLE}" | sha256_stream | cut -c1-15)
|
||
|
|
[ -n "$lock_hex" ] || die "could not derive the advisory lock key."
|
||
|
|
LOCK_KEY=$((16#$lock_hex))
|
||
|
|
|
||
|
|
sql_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/''/g")"; }
|
||
|
|
|
||
|
|
PROGRAM="$work/run.sql"
|
||
|
|
LOCK_FAILED_MARKER="MIGRATE_LOCK_UNAVAILABLE"
|
||
|
|
|
||
|
|
# Printed by psql itself, one line before each migration starts. It is progress
|
||
|
|
# for the operator and, afterwards, the record of how far the session got — a
|
||
|
|
# migration that hangs is otherwise indistinguishable from a psql that never
|
||
|
|
# connected.
|
||
|
|
STARTED_PREFIX="migrate: applying "
|
||
|
|
SKIPPED_PREFIX="migrate: skipping "
|
||
|
|
|
||
|
|
{
|
||
|
|
printf '\\set ON_ERROR_STOP on\n'
|
||
|
|
printf '\\timing off\n'
|
||
|
|
|
||
|
|
# The one place a server-side timeout is worth having: DDL waiting behind a
|
||
|
|
# long-running reader takes the whole deploy with it, and it does so while
|
||
|
|
# holding the advisory lock. Optional and unset by default, because a value
|
||
|
|
# that aborts migrations on a busy table is not something to inherit.
|
||
|
|
if [ -n "${MIGRATE_PG_LOCK_TIMEOUT:-}" ]; then
|
||
|
|
printf "SET lock_timeout = %s;\n" "$(sql_quote "$MIGRATE_PG_LOCK_TIMEOUT")"
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Retry rather than block for ever: a deploy that hangs silently on a stale
|
||
|
|
# session is worse than one that fails and says why. The lock is session
|
||
|
|
# level, so it survives the end of this DO block's own transaction.
|
||
|
|
cat <<SQL
|
||
|
|
DO \$migrate_lock\$
|
||
|
|
DECLARE
|
||
|
|
waited int := 0;
|
||
|
|
BEGIN
|
||
|
|
WHILE NOT pg_try_advisory_lock(${LOCK_KEY}) LOOP
|
||
|
|
IF waited >= ${LOCK_WAIT} THEN
|
||
|
|
RAISE EXCEPTION '${LOCK_FAILED_MARKER}: another migration run has held the lock for % seconds', waited;
|
||
|
|
END IF;
|
||
|
|
PERFORM pg_sleep(1);
|
||
|
|
waited := waited + 1;
|
||
|
|
END LOOP;
|
||
|
|
END
|
||
|
|
\$migrate_lock\$;
|
||
|
|
SQL
|
||
|
|
|
||
|
|
# Created inside the lock, so two first-ever runs cannot race here. The
|
||
|
|
# notice from IF NOT EXISTS is suppressed for this statement only — it is
|
||
|
|
# printed on every run after the first and trains people to ignore output
|
||
|
|
# that on some other run says something that matters.
|
||
|
|
printf 'SET client_min_messages = warning;\n'
|
||
|
|
cat <<SQL
|
||
|
|
CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} (
|
||
|
|
version bigint PRIMARY KEY,
|
||
|
|
filename text NOT NULL,
|
||
|
|
checksum text NOT NULL,
|
||
|
|
applied_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
applied_by text NOT NULL DEFAULT current_user
|
||
|
|
);
|
||
|
|
SQL
|
||
|
|
printf 'RESET client_min_messages;\n'
|
||
|
|
|
||
|
|
# The ledger is keyed on the NUMBER, not the filename: renaming
|
||
|
|
# 0007_add_users.sql to 0007_add_user_table.sql is a rename, and a
|
||
|
|
# filename-keyed ledger would read it as a new migration and run it twice.
|
||
|
|
# The filename column keeps the last name seen so the drift is visible.
|
||
|
|
#
|
||
|
|
# ## The plan is re-checked, one migration at a time, after the lock is held
|
||
|
|
#
|
||
|
|
# The list of pending migrations was computed BEFORE the lock was acquired,
|
||
|
|
# and a run that waited on the lock waited precisely because another run was
|
||
|
|
# applying things — so by the time it gets in, its plan is stale. Without
|
||
|
|
# this check the second deploy re-runs everything the first one just did:
|
||
|
|
# every statement executes twice and the run dies on the tracking table's
|
||
|
|
# primary key, reporting a migration failure that is really a race.
|
||
|
|
#
|
||
|
|
# So each migration asks the ledger, inside the lock, whether it is still
|
||
|
|
# pending, and skips itself if not. \gset and \if do that in a static script
|
||
|
|
# with no branching from the shell side.
|
||
|
|
|
||
|
|
while IFS="$TAB" read -r version base checksum transactional; do
|
||
|
|
[ -n "$version" ] || continue
|
||
|
|
|
||
|
|
# 'on'/'off' rather than a boolean: \if is documented to take those, and a
|
||
|
|
# value it cannot parse is an error rather than a guess.
|
||
|
|
printf "SELECT CASE WHEN EXISTS (SELECT 1 FROM %s WHERE version = %s) THEN 'off' ELSE 'on' END AS mig_%s \\\\gset\n" \
|
||
|
|
"$TRACKING_TABLE" "$version" "$version"
|
||
|
|
printf '\\if :mig_%s\n' "$version"
|
||
|
|
printf "\\\\echo '%s%s'\n" "$STARTED_PREFIX" "$base"
|
||
|
|
|
||
|
|
insert="INSERT INTO ${TRACKING_TABLE} (version, filename, checksum) VALUES (${version}, $(sql_quote "$base"), $(sql_quote "$checksum"));"
|
||
|
|
|
||
|
|
if [ "$transactional" = "yes" ]; then
|
||
|
|
printf 'BEGIN;\n'
|
||
|
|
printf "\\\\i '%s/%s'\n" "$MIGRATIONS_ABS" "$base"
|
||
|
|
printf '%s\n' "$insert"
|
||
|
|
printf 'COMMIT;\n'
|
||
|
|
else
|
||
|
|
printf "\\\\i '%s/%s'\n" "$MIGRATIONS_ABS" "$base"
|
||
|
|
printf '%s\n' "$insert"
|
||
|
|
fi
|
||
|
|
|
||
|
|
printf '\\else\n'
|
||
|
|
printf "\\\\echo '%s%s (another run applied it first)'\n" "$SKIPPED_PREFIX" "$base"
|
||
|
|
printf '\\endif\n'
|
||
|
|
done < "$PENDING_TSV"
|
||
|
|
|
||
|
|
# Not strictly needed — ending the session drops it — but an explicit unlock
|
||
|
|
# keeps the intent readable in the generated script, which is the thing an
|
||
|
|
# operator reads when they are trying to work out what ran. Wrapped in a DO
|
||
|
|
# block so it does not print a result row into the run's output.
|
||
|
|
printf 'DO $migrate_unlock$ BEGIN PERFORM pg_advisory_unlock(%s); END $migrate_unlock$;\n' "$LOCK_KEY"
|
||
|
|
} > "$PROGRAM"
|
||
|
|
|
||
|
|
say "applying ${PENDING_COUNT} migration(s) to '${conn_db}'…"
|
||
|
|
|
||
|
|
nontx=$(cut -f4 "$PENDING_TSV" | grep -c '^no$')
|
||
|
|
if [ "$nontx" -gt 0 ]; then
|
||
|
|
say "WARNING: ${nontx} of them run OUTSIDE a transaction (-- migrate:"
|
||
|
|
say " no-transaction). If one fails partway, its tracking row is not"
|
||
|
|
say " written and this run will report it — but the database keeps"
|
||
|
|
say " whatever it managed to do. A failed CREATE INDEX CONCURRENTLY"
|
||
|
|
say " leaves an INVALID index behind: DROP it before re-running."
|
||
|
|
fi
|
||
|
|
|
||
|
|
LOG="$work/psql.log"
|
||
|
|
|
||
|
|
"${PSQL[@]}" -f "$PROGRAM" 2>&1 | tee "$LOG" >&2
|
||
|
|
psql_status=${PIPESTATUS[0]}
|
||
|
|
|
||
|
|
# Checked first, and before the ledger is re-read: on a first-ever run the
|
||
|
|
# tracking table does not exist yet, so a lock loss there would otherwise be
|
||
|
|
# reported as "could not read the ledger" — which is true, and not the reason.
|
||
|
|
if grep -q "$LOCK_FAILED_MARKER" "$LOG"; then
|
||
|
|
say "another migration run holds the lock for ${TRACKING_TABLE} and did not"
|
||
|
|
say "release it within ${LOCK_WAIT}s. Nothing was applied by this run. Wait for"
|
||
|
|
say "the other deploy to finish, or raise MIGRATE_LOCK_WAIT. If nothing is"
|
||
|
|
say "running, look for a stale session:"
|
||
|
|
say " SELECT * FROM pg_locks WHERE locktype = 'advisory';"
|
||
|
|
exit 3
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# What was applied is read back out of the ledger, not counted from this
|
||
|
|
# script's own output.
|
||
|
|
#
|
||
|
|
# The two can disagree, and when they do the ledger is right: a no-transaction
|
||
|
|
# migration can run to completion and still fail to record itself, and a
|
||
|
|
# session killed between COMMIT and the next statement leaves output claiming
|
||
|
|
# less than the database holds. Reporting the plan, or the echoes, would be
|
||
|
|
# reporting an intention as a fact.
|
||
|
|
#
|
||
|
|
# If the ledger cannot be re-read, that is reported as not knowing — never as
|
||
|
|
# nothing having happened.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
CONFIRMED="$work/confirmed.tsv"
|
||
|
|
confirmed_ok="yes"
|
||
|
|
|
||
|
|
if ! db_query "SELECT version FROM ${TRACKING_TABLE}" > "$CONFIRMED" 2>"$work/confirmerr"; then
|
||
|
|
confirmed_ok="no"
|
||
|
|
: > "$CONFIRMED"
|
||
|
|
fi
|
||
|
|
|
||
|
|
applied_now=0
|
||
|
|
skipped_now=0
|
||
|
|
applied_list=""
|
||
|
|
skipped_list=""
|
||
|
|
missing_list=""
|
||
|
|
|
||
|
|
while IFS="$TAB" read -r version base _checksum _transactional; do
|
||
|
|
[ -n "$version" ] || continue
|
||
|
|
|
||
|
|
if ! grep -qx "$version" "$CONFIRMED"; then
|
||
|
|
missing_list="${missing_list} ${version} ${base}"$'\n'
|
||
|
|
elif grep -qF "${SKIPPED_PREFIX}${base}" "$LOG"; then
|
||
|
|
# In the ledger, but this run did not put it there. Counted separately
|
||
|
|
# rather than folded into the total: "we applied it" and "we found it
|
||
|
|
# already applied and stood down" are different events, and only the second
|
||
|
|
# one means another deploy was running against this database.
|
||
|
|
skipped_now=$((skipped_now + 1))
|
||
|
|
skipped_list="${skipped_list} ${version} ${base}"$'\n'
|
||
|
|
else
|
||
|
|
applied_now=$((applied_now + 1))
|
||
|
|
applied_list="${applied_list} ${version} ${base}"$'\n'
|
||
|
|
fi
|
||
|
|
done < "$PENDING_TSV"
|
||
|
|
|
||
|
|
if [ "$confirmed_ok" = "no" ]; then
|
||
|
|
say ""
|
||
|
|
|
||
|
|
if [ "$psql_status" -eq 0 ]; then
|
||
|
|
# psql was happy and the ledger has become unreadable between one statement
|
||
|
|
# and the next. Nothing can be said about what is applied, so nothing is.
|
||
|
|
say "the run reported success but ${TRACKING_TABLE} could not be re-read, so"
|
||
|
|
say "what is applied is UNKNOWN. psql said:"
|
||
|
|
sed 's/^/ /' "$work/confirmerr" >&2
|
||
|
|
say "Run --status once the database is reachable, before running this again."
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
|
||
|
|
# The far more common shape: the run failed early — a missing schema, a role
|
||
|
|
# without CREATE — so the table never existed to be read. The error printed
|
||
|
|
# above is the real one, and calling this "unknown" would send the operator
|
||
|
|
# looking for a database problem instead of reading it.
|
||
|
|
KEEP_WORK="yes"
|
||
|
|
say "the run failed (psql exit ${psql_status}) and ${TRACKING_TABLE} does not"
|
||
|
|
say "exist or cannot be read, so nothing was applied. Read the error above —"
|
||
|
|
say "a missing schema or a role without CREATE is the usual cause."
|
||
|
|
say "the SQL that was sent is kept at ${work}/run.sql."
|
||
|
|
exit 4
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ "$skipped_now" -gt 0 ]; then
|
||
|
|
say "another run applied ${skipped_now} of the ${PENDING_COUNT} planned while this one"
|
||
|
|
say "waited for the lock. They were skipped, not repeated:"
|
||
|
|
printf '%s' "$skipped_list" >&2
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ "$psql_status" -eq 0 ]; then
|
||
|
|
say "applied ${applied_now} of ${PENDING_COUNT}:"
|
||
|
|
|
||
|
|
if [ "$applied_now" -eq 0 ]; then
|
||
|
|
say " (none)"
|
||
|
|
else
|
||
|
|
printf '%s' "$applied_list" >&2
|
||
|
|
fi
|
||
|
|
|
||
|
|
# psql exited clean while the ledger disagrees with the plan. Rare, and
|
||
|
|
# exactly the state that must not be reported as success: something ran
|
||
|
|
# without recording itself, or something recorded itself twice.
|
||
|
|
if [ $((applied_now + skipped_now)) -ne "$PENDING_COUNT" ]; then
|
||
|
|
# Kept, like the other failure paths keep it. This branch tells the
|
||
|
|
# operator to check the database by hand, and the generated SQL is the
|
||
|
|
# only record of what was actually sent — deleting it on the way out would
|
||
|
|
# take the evidence with it.
|
||
|
|
KEEP_WORK="yes"
|
||
|
|
say ""
|
||
|
|
say "psql reported success but these are NOT in ${TRACKING_TABLE}:"
|
||
|
|
printf '%s' "$missing_list" >&2
|
||
|
|
say "Do not re-run until you know why — check the database by hand."
|
||
|
|
say "the SQL that was sent is kept at ${work}/run.sql."
|
||
|
|
exit 4
|
||
|
|
fi
|
||
|
|
|
||
|
|
exit 0
|
||
|
|
fi
|
||
|
|
|
||
|
|
# psql stops at the first error, so the last migration it announced is the one
|
||
|
|
# that failed — unless the ledger says that one committed, in which case the
|
||
|
|
# error came after it and blaming it would be a claim rather than a reading.
|
||
|
|
failed=$(grep "^${STARTED_PREFIX}" "$LOG" | tail -n 1 | sed "s/^${STARTED_PREFIX}//")
|
||
|
|
failed_version=""
|
||
|
|
[ -n "$failed" ] && failed_version=$(grep -m1 "${TAB}${failed}${TAB}" "$PENDING_TSV" | cut -f1)
|
||
|
|
|
||
|
|
KEEP_WORK="yes"
|
||
|
|
|
||
|
|
say ""
|
||
|
|
say "a migration failed (psql exit ${psql_status}). See the error above."
|
||
|
|
say "the SQL that was sent is kept at ${work}/run.sql — the line numbers in"
|
||
|
|
say "psql's error refer to it. Delete it when you are done."
|
||
|
|
|
||
|
|
if [ -z "$failed" ]; then
|
||
|
|
# Nothing was announced, so nothing was reached: the failure is the lock, the
|
||
|
|
# CREATE TABLE, or the connection. Said as such rather than blamed on a
|
||
|
|
# migration that never started.
|
||
|
|
say "no migration was reached — it failed on the lock, on creating"
|
||
|
|
say "${TRACKING_TABLE}, or on the connection itself."
|
||
|
|
elif [ -n "$failed_version" ] && grep -qx "$failed_version" "$CONFIRMED"; then
|
||
|
|
say "'${failed}' is the last one announced and it IS recorded as applied, so"
|
||
|
|
say "the failure came after it finished. Read the error above before re-running."
|
||
|
|
else
|
||
|
|
say "the failing migration is '${failed}'."
|
||
|
|
|
||
|
|
# A transactional failure needs no explanation beyond "nothing happened". A
|
||
|
|
# no-transaction failure does, and it is the one case where somebody has to
|
||
|
|
# look at the database before this is run again.
|
||
|
|
if grep -q "${TAB}${failed}${TAB}.*${TAB}no$" "$PENDING_TSV"; then
|
||
|
|
say ""
|
||
|
|
say "It ran OUTSIDE a transaction, so whatever it completed before failing is"
|
||
|
|
say "still there and is NOT recorded. Inspect the database before re-running:"
|
||
|
|
say "a half-built CREATE INDEX CONCURRENTLY leaves an INVALID index that has"
|
||
|
|
say "to be dropped, and any statement that did succeed will run a second time."
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
say ""
|
||
|
|
say "$((applied_now + skipped_now)) of ${PENDING_COUNT} are recorded in ${TRACKING_TABLE} and will"
|
||
|
|
say "not run again:"
|
||
|
|
|
||
|
|
if [ "$applied_now" -eq 0 ]; then
|
||
|
|
say " (none by this run)"
|
||
|
|
else
|
||
|
|
printf '%s' "$applied_list" >&2
|
||
|
|
fi
|
||
|
|
say ""
|
||
|
|
say "Fix the failing migration IN PLACE — it is not recorded, so its checksum is"
|
||
|
|
say "not history yet — then run this again. Never edit one that already"
|
||
|
|
say "succeeded; --status lists which those are."
|
||
|
|
exit 4
|