#!/usr/bin/env bash # # Exercise the cat_tools test suite against a REAL database whose extension was # installed/updated/pg_upgraded OUTSIDE the suite ("existing" mode). Both the # pg-upgrade legs and the extension-update scenarios in .github/workflows/ci.yml # repeat the same sequence: # # install -> plant dependency guard -> update/upgrade -> assert version # -> run the suite in existing mode # # so it lives here once instead of being duplicated as inline YAML. It is NOT # CI-only: a developer can run any subcommand locally against a scratch database. # # USAGE: bin/test_existing [args] # # plant-guard DB # Plant + prove the dependency guard (extension must already be >= 0.2.2). # # update DB [TO_VERSION] # ALTER EXTENSION cat_tools UPDATE [TO 'TO_VERSION'] (empty => current). # # prepare-old DB INSTALL_VERSION [BRIDGE_TO] # Old-cluster prep for pg-upgrade-test: create DB + extension at # INSTALL_VERSION, optionally bridge-update to BRIDGE_TO, then plant guard. # # run-suite DB # Run the suite in existing mode (extension must be at the current version). # # update-scenario DB FROM_VERSION # Create DB + extension at FROM_VERSION, plant guard, update to current, # structurally compare against a fresh install of current (see # diff-fresh), run the suite against that real updated database. # # update-check DB FROM_VERSION TO_VERSION # Lightweight check that a specific update script applies (no suite), # then structurally compare against a fresh install of TO_VERSION (see # diff-fresh). # # update-check-version DB FROM_VERSION TO_VERSION # Same as update-check but WITHOUT the structural comparison: for a # landing point pinned at an already-tagged version, where a fresh- # install divergence is documented and can only converge forward into a # LATER, still-unpublished update script (an already-published # version-specific file is never retroactively edited). Only asserts the # version landed. # # diff-fresh DB VERSION # Structurally compare DB's cat_tools objects against a throwaway fresh # install of VERSION (function/view definitions, type labels, comments, # ACLs, extension membership -- see bin/structural_diff.sql). Fails loudly # on any nonempty diff. Generalizes a manual comparison that found the # trigger__parse divergence fixed by # https://github.com/Postgres-Extensions/cat_tools/pull/46; update-scenario # and update-check both call this automatically, but it's also directly # useful standalone against any already-populated database. # # Run `bin/test_existing` with no subcommand to print usage. # # TEST_EXISTING_DEPLOY=pgtle (env var, checked by run_suite): the extension # under test was deployed via pg_tle, not the filesystem, and must NEVER be # filesystem-installed by anything this script does. pgxntool 2.3.0 fixed # installcheck running before install (issue #79) by making `installcheck` # itself unconditionally depend on `install` in base.mk -- so even calling # `installcheck` directly (bypassing `test`) now runs `install` too, with no # opt-out. Redirect its filesystem writes to a throwaway DESTDIR instead of # skipping install entirely: this mode is only ever checked with pg_regress's # --use-existing against a database that already has the extension via pg_tle, # so the CREATE-EXTENSION-from-disk side effect that `install` would otherwise # produce is never actually exercised, and a scratch DESTDIR keeps the real # extension directory untouched (see run_suite for a related pgtap-specific # gotcha with this same DESTDIR). This whole workaround could be simplified # away if pgxntool grows a real install-skip override (requested upstream: # https://github.com/Postgres-Extensions/pgxntool/issues/55) -- revisit this # block if/when that lands. The CALLER is responsible for having already # registered pg_tle + cat_tools against `template1` before creating any # database this script will use (pg_tle's registration is per-database; # `createdb` only inherits it # because it copies `template1` by default) and for asserting filesystem # cleanliness before/after (see bin/assert_fs_clean) -- this flag only stops # this script from being the thing that dirties the filesystem itself. # # Why the dependency guard: "existing" mode must run the suite against the ACTUAL # upgraded/updated objects. If anything silently dropped + reinstalled the # extension, the suite would test a FRESH install and hide a regression. As # belt-and-suspenders to the load.sql guarantee (existing mode never drops the # extension), we plant an object that HARD-references a cat_tools member so a # non-CASCADE DROP EXTENSION fails, and we actively PROVE that here (see # plant_guard): if the drop unexpectedly succeeds, this script fails CI. # # See https://github.com/Postgres-Extensions/cat_tools/pull/16 for context. set -euo pipefail # Run from the repository root (where `make` works and test paths resolve), # regardless of the caller's cwd. bin/ sits directly under the repo root, so its # parent is the root (no dependency on being inside a git checkout). readlink -f # resolves any path the script was invoked through. cd "$(dirname "$(readlink -f "$0")")/.." # A view whose output column has a cat_tools enum type creates a pg_depend edge # to that extension member, so a non-CASCADE DROP EXTENSION cannot succeed. The # enum is only ever extended (ALTER TYPE ... ADD VALUE) by the update scripts, # never dropped, so the guard survives 0.2.2 -> current updates and binary # pg_upgrade. GUARD_SCHEMA=cat_tools_drop_guard GUARD_VIEW=guard # --------------------------------------------------------------------------- # psql helpers # --------------------------------------------------------------------------- # Capture the single-value (-tAc) output of a query against a database. Used for # the many "read one value back out" calls below so the psql flags live in one # place. psql_value() { local db=$1 sql=$2 psql -d "$db" -tAc "$sql" } # Run SQL that must succeed, aborting the whole script on any error # (ON_ERROR_STOP). Extra args pass through to psql, so callers use either # `psql_do DB -c '...'` or a heredoc (`psql_do DB </dev/null | sed -n 's/.*set to "\(.*\)"$/\1/p' } installed_version() { psql_value "$1" \ "SELECT extversion FROM pg_extension WHERE extname = 'cat_tools'" } guard_present() { test "$(psql_value "$1" \ "SELECT count(*) FROM pg_views WHERE schemaname = '$GUARD_SCHEMA' AND viewname = '$GUARD_VIEW'")" = 1 } # Plant the guard and PROVE it blocks a non-CASCADE drop. Call right after # CREATE EXTENSION (and before any update/upgrade) so it persists through them. plant_guard() { local db=$1 psql_do "$db" </dev/null 2>&1; then echo "FAIL: DROP EXTENSION cat_tools (non-CASCADE) unexpectedly SUCCEEDED in '$db' -- dependency guard is ineffective" >&2 exit 1 fi guard_present "$db" \ || { echo "FAIL: dependency guard missing from '$db' after the drop attempt" >&2; exit 1; } test -n "$(installed_version "$db")" \ || { echo "FAIL: cat_tools extension missing from '$db' after the drop attempt" >&2; exit 1; } echo "OK: non-CASCADE DROP EXTENSION is blocked in '$db' (dependency guard effective)" } assert_version() { local db=$1 expected=$2 installed [ "$expected" = current ] && expected=$(current_version) installed=$(installed_version "$db") echo "version check '$db': installed='$installed' expected='$expected'" if [ -z "$installed" ] || [ -z "$expected" ] || [ "$installed" != "$expected" ]; then echo "FAIL: cat_tools in '$db' is '$installed', expected '$expected'" >&2 exit 1 fi } update_ext() { local db=$1 to=${2:-} # Prepend "TO " only when a target version is given, so a single statement # covers both cases (empty $to => bare "ALTER EXTENSION ... UPDATE" to current). # Use `if`, not `&&`: a false test under `set -e` would abort the script. if [ -n "$to" ]; then to="TO '$to'"; fi psql_do "$db" -c "ALTER EXTENSION cat_tools UPDATE $to" } # Structurally compare DB's cat_tools objects (function/view definitions, type # labels, comments, ACLs, extension membership -- see bin/structural_diff.sql) # against a FRESH install of VERSION. An update script only really "supports" # a version if updating to it reaches the SAME objects a fresh install would; # https://github.com/Postgres-Extensions/cat_tools/pull/46 found a real # divergence this way by hand (a pre-0.2.2 update script's trigger__parse body # didn't match the fresh-install body, breaking every trigger parse on # PG11+). Runs in a subshell so the EXIT trap dropping the throwaway reference # database is scoped to this call, not the whole script (see the same pattern, # and why it must be a subshell rather than a plain function-local trap, in # bin/structural_diff's compare()). assert_matches_fresh() { local db=$1 version=$2 # Separate statement: a self-referencing `local a=1 b=$a` is bash-version- # dependent on whether $a is visible yet while computing b's value. local fresh_db="${db}__fresh_ref" ( trap 'dropdb --if-exists "$fresh_db"' EXIT createdb "$fresh_db" psql_do "$fresh_db" -c "CREATE EXTENSION cat_tools VERSION '$version'" bin/structural_diff compare "$db" "$fresh_db" ) } # --------------------------------------------------------------------------- # Subcommand implementations # --------------------------------------------------------------------------- # prepare-old DB INSTALL_VERSION [BRIDGE_TO] # Old-cluster preparation for pg-upgrade-test. Create the database and the # extension at INSTALL_VERSION; if BRIDGE_TO is given, ALTER EXTENSION UPDATE # TO it first. That "bridge" models the real migration path a user on an OLD # PostgreSQL + OLD cat_tools must take: e.g. on PG10, install 0.2.0 then update # to 0.2.3 BEFORE pg_upgrade. The shipped 0.2.0->0.2.2 / 0.2.1->0.2.2 scripts do # NOT fix the views (their omit_column used the no-op `!= ANY`, leaving # relhasoids/relhaspkey in _cat_tools.pg_class_v); the pg_class_v DROP+CREATE # rebuild that strips those columns lives in the 0.2.2->0.2.3 update, so the # bridge must reach 0.2.3. The raw 0.2.0 views reference catalog columns removed # in newer PostgreSQL and would break binary pg_upgrade otherwise. 0.2.3 is also # the furthest a PG10 cluster can reach (0.2.3->0.3.0 uses ALTER TYPE ... ADD # VALUE, unrunnable in an update script before PG12). Then plant + prove the # dependency guard (at the bridged version, so cat_tools.relation_type exists). prepare_old() { local db=$1 install=$2 bridge=${3:-} createdb "$db" psql_do "$db" -c "CREATE EXTENSION cat_tools VERSION '$install'" # Use `if`, not `&&`: under `set -e` a false `[ -n ... ] && ...` would abort. if [ -n "$bridge" ]; then update_ext "$db" "$bridge"; fi plant_guard "$db" } # update-scenario DB FROM_VERSION # Full extension-update flow that runs the existing-mode suite: create the DB # and extension at FROM_VERSION, plant + prove the guard, update to the current # version, structurally compare the result against a fresh install of the # current version (assert_matches_fresh), then run the suite against that # real updated database. update_scenario() { local db=$1 from=$2 createdb "$db" psql_do "$db" -c "CREATE EXTENSION cat_tools VERSION '$from'" plant_guard "$db" update_ext "$db" assert_matches_fresh "$db" "$(current_version)" run_suite "$db" } # update-check-version DB FROM_VERSION TO_VERSION # Lightweight check that a specific update script applies (no suite): used # for the pre-0.2.2 scripts, which only install on PG10 and target 0.2.2 (not # the current version, so the suite cannot run against them). Also used for # ANY update path landing on 0.2.3, regardless of origin -- 0.2.3 is itself # already tagged/published, and a version-specific file is NEVER edited once # tagged (CLAUDE.md's SQL file conventions rule 5), so a divergence at that # landing point can never be repaired in place; the fix has to converge # forward into 0.2.3->0.3.0 instead, the same way PR #46 converged its own # fix forward into 0.2.2->0.2.3 rather than editing 0.2.0/0.2.1's already- # published scripts. Two such divergences exist for 0.2.3: a comment-only # drift between the frozen sql/cat_tools--0.2.3.sql.in fresh-install script # (never got PR #46's comment tweaks) and the 0.2.2->0.2.3 update script # (which did) affects EVERY origin, including a fresh 0.2.2; a type-ACL gap # on five enum types predating 0.2.2 affects only a 0.2.0/0.2.1 origin # (0.2.2->0.2.3 never grants it, since a fresh 0.2.2 already has it and so # never needed the grant added there). Either way, asserting fresh-parity at # a landing point pinned to an already-tagged version fails forever by # design, not from a regression -- see update-check's comment for the # version where full parity IS expected and proven. update_check_version() { local db=$1 from=$2 to=$3 createdb "$db" psql_do "$db" -c "CREATE EXTENSION cat_tools VERSION '$from'" update_ext "$db" "$to" assert_version "$db" "$to" } # update-check DB FROM_VERSION TO_VERSION # Same as update-check-version, plus a structural comparison against a fresh # install of TO_VERSION (assert_matches_fresh) -- this is precisely the shape # of check that would have caught # https://github.com/Postgres-Extensions/cat_tools/pull/46's trigger__parse # divergence: an update script reaching TO_VERSION with a different function # body than a fresh install of it. Only use this where TO_VERSION is a # target the update path is actually expected to converge on. Every # ALREADY-TAGGED version is a poor fit for this: a comment-only tweak PR #46 # made to sql/cat_tools.sql.in and the 0.2.2->0.2.3 update script never # reached the frozen sql/cat_tools--0.2.3.sql.in fresh-install script (a # version-specific file, once tagged, is NEVER edited to fix this kind of # drift -- see CLAUDE.md's SQL file conventions rule 5), so ANY path # reaching 0.2.3 diverges from "fresh install pinned at exactly 0.2.3", # regardless of origin -- use update-check-version for any TO_VERSION that # is already tagged. Reaching the CURRENT version is where convergence is # both expected and proven (see update-scenario, and diff-fresh against # any already-populated database). update_check() { local db=$1 from=$2 to=$3 update_check_version "$db" "$from" "$to" assert_matches_fresh "$db" "$to" } # Run the pgTAP suite against an already-populated database in existing mode. # Verifies the extension is at the current version, re-proves the guard still # blocks a drop (i.e. it survived the update/upgrade), runs the suite via # --use-existing so pg_regress does NOT drop/recreate the database, then confirms # the guard is still present (a CASCADE drop+reinstall would have removed it). run_suite() { local db=$1 assert_version "$db" current assert_drop_blocked "$db" make check-relkind-source # In existing mode pg_regress runs against $db via --use-existing and must NOT # create/drop its own database. Two consequences drive the make args below: # 1. PGXNTOOL_ENABLE_TEST_BUILD=no: base.mk auto-enables the test-build sanity # check whenever test/build/*.sql exist, adding it as a `test` prerequisite. # test-build spawns a recursive `installcheck` that INHERITS this call's # --use-existing (a command-line var propagates to sub-makes) but targets a # fresh `regression` DB it cannot create under --use-existing, so it dies # with "database regression does not exist". test-build is a fresh-install # check already run by the fresh `test` job on every PG version, so it adds # nothing here -- disable it. # 2. verify-results depends on `test`, so it re-runs the suite; it must carry # the SAME existing-mode overrides or it would re-run FRESH (against a new # regression DB) instead of verifying THIS existing database. local existing_args="TEST_LOAD_SOURCE=existing CONTRIB_TESTDB=$db EXTRA_REGRESS_OPTS=--use-existing PGXNTOOL_ENABLE_TEST_BUILD=no" if [ "${TEST_EXISTING_DEPLOY:-}" = pgtle ]; then # pg_tle mode (see the TEST_EXISTING_DEPLOY comment at the top of this # file): run the same underlying work as `test`/`verify-results` directly # -- `testdeps`+`installcheck`, then the pgtap-mode verify-results script # directly (skips its `test` prerequisite) -- but with a throwaway DESTDIR # so installcheck's own (now unconditional, see the comment above) install # prerequisite writes nowhere near the real extension directory. # TODO: drop this DESTDIR redirect once pgxntool grows a real install-skip # override (https://github.com/Postgres-Extensions/pgxntool/issues/55) and # use that instead. pgtle_destdir=$(mktemp -d) # Double-quoted so $pgtle_destdir expands NOW, baking the actual path into the # trap as a literal -- the trap then no longer references the variable at all, # so it can't be affected by whatever value (or lack of one) it holds later when # the trap actually fires at EXIT. trap "rm -rf '$pgtle_destdir'" EXIT # base.mk's `pgtap` target checks for $(DESTDIR)$(datadir)/extension/pgtap.control # but its recipe (`pgxn install pgtap --sudo`) ignores DESTDIR entirely and always # installs for real. Against our empty scratch DESTDIR that control file can never # exist, so without this stub Make would consider `pgtap` stale and re-run a real # `pgxn install pgtap --sudo` on every call (network + sudo, and -- more importantly # for this workaround's whole point -- a real write to the actual system extension # directory, since the recipe itself doesn't honor DESTDIR). pgtap is already # installed for real by an earlier CI step in every caller of this mode, so a stub # is all `installcheck`'s prerequisite check needs. local pgtap_destdir pgtap_destdir="$pgtle_destdir$(make -s print-datadir | sed -n 's/.*set to "\(.*\)"$/\1/p')/extension" mkdir -p "$pgtap_destdir" touch "$pgtap_destdir/pgtap.control" make testdeps $existing_args make installcheck $existing_args DESTDIR="$pgtle_destdir" local testout testout=$(make -s print-TESTOUT 2>/dev/null | sed -n 's/.*set to "\(.*\)"$/\1/p') pgxntool/verify-results-pgtap.sh "$testout" else make test $existing_args make verify-results $existing_args fi # Post-suite guard assertion: a CASCADE drop+reinstall during the run would # have removed the guard view, meaning the suite tested a fresh install, not $db. guard_present "$db" \ || { echo "FAIL: dependency guard vanished during the suite run on '$db' -- extension was dropped+reinstalled (CASCADE)?" >&2; exit 1; } } usage() { echo "usage: bin/test_existing [args]" >&2 echo " plant-guard DB" >&2 echo " update DB [TO_VERSION]" >&2 echo " prepare-old DB INSTALL_VERSION [BRIDGE_TO]" >&2 echo " run-suite DB" >&2 echo " update-scenario DB FROM_VERSION" >&2 echo " update-check DB FROM_VERSION TO_VERSION" >&2 echo " update-check-version DB FROM_VERSION TO_VERSION" >&2 echo " diff-fresh DB VERSION" >&2 exit 2 } # Explicit subcommand dispatch on $1. Defined first for readability; INVOKED at # the very bottom, after every helper it calls is defined (bash resolves calls at # runtime, so main() appearing first is fine). main() { local cmd=${1:-} shift || true case "$cmd" in plant-guard) plant_guard "$@" ;; update) update_ext "$@" ;; prepare-old) prepare_old "$@" ;; run-suite) run_suite "$@" ;; update-scenario) update_scenario "$@" ;; update-check) update_check "$@" ;; update-check-version) update_check_version "$@" ;; diff-fresh) assert_matches_fresh "$@" ;; *) usage ;; esac } main "$@"