#!/usr/bin/env bash # ADR-0001 Phase 3 Cutover — cutover.sh # # Run DIRECTLY on docker-2 via SSH (NOT via the LXC-exec MCP tool — several # phases here run well past its ~30s timeout): # # ssh root@192.168.4.32 # bash /volume1/docker/compose-files/postgresql/cutover/cutover.sh # # Implements RUNBOOK.md's 11 phases. Any failed check triggers immediate, # automatic invocation of rollback.sh and the script exits nonzero — no # manual intervention should be required to reach a safe state. Designed # to be run unattended, but given the blast radius (production DB, this # chat's own dependency on it, ~13 consumers) watching it live the first # time is strongly recommended anyway. # # ── Design notes on HOW checks are implemented (read before editing) ────── # # Node locality: plain `docker exec` only works for containers whose task # is actually scheduled on the node you run it from — Swarm does NOT proxy # exec across nodes. patroni-0/1/haproxy/etcd-1/3 may land on docker-1 or # docker-3, not docker-2. Two techniques are used throughout to work # around this, both already validated in the ADR-0001 dry run: # 1. Legacy's own container IS pinned to docker-2 (see postgresql.yaml: # node.hostname == docker-2) and shares postgresql_db-backend with # every HA-stack container — so `docker exec ...` is # always available locally and can reach any HA container by its # Swarm-DNS hostname (patroni-0, patroni-1, haproxy, etcd-2, etc.). # This is the SAME proxy pattern documented in the ADR-0001 note # ("docker exec psql -h patroni-1 -p 5432 ..."). # 2. `docker service ps ` and `docker service logs ` # work cluster-wide from any manager regardless of which node the # task landed on — used for stability/health signal checks that # don't need to reach inside the container. # Patroni REST calls (port 8008) are made via bash's `/dev/tcp` raw-socket # trick executed inside the legacy container, NOT via curl — this avoids # assuming curl is present in the (Debian-slim) postgres:17 legacy image. # # ⚠️ DEVIATION FLAGGED FOR OPERATOR REVIEW — Phase 5 (promotion): # The ADR-0001 dry run promoted by removing the standby_cluster key # directly via etcdctl (patronictl had an unrelated config-loader quirk # in that environment). This script instead uses Patroni's own official, # documented REST API mechanism: `PATCH /config` with `{"standby_cluster": # null}`, which is Patroni's sanctioned way to remove a dynamic-config # key (see Patroni docs, REST API section). This avoids needing jq/python # inside the minimal etcd image to hand-edit YAML, and avoids the # patronictl quirk entirely — but it was NOT the exact mechanism proven # in the dry run. A manual fallback (raw etcdctl, matching the dry-run # method) is documented below and in RUNBOOK.md in case this PATCH # doesn't behave as expected. Recommend a low-stakes smoke test (e.g. a # GET /config call, done automatically below before the real PATCH) and # operator attention specifically during this phase. # # Manual fallback for Phase 5 if the PATCH approach ever needs bypassing: # docker exec etcdctl --endpoints=http://etcd-1:2379,http://etcd-2:2379,http://etcd-3:2379 \ # get /service/postgres-ha/config --print-value-only # (hand-edit the returned YAML to remove the standby_cluster block, then) # docker exec etcdctl --endpoints=... put /service/postgres-ha/config -- "" # etcd-2 is guaranteed to be on docker-2 (node.hostname == docker-2 # constraint in both staging/final yaml), so this exec always works # locally if needed. # # ── FIX LOG (post-first-real-run) ────────────────────────────────────── # 2026 run #1 aborted at Phase 1's node-label check with a false negative # despite labels genuinely being set (docker-2=primary, docker-3=replica, # confirmed via `docker node inspect --format '{{json .Spec.Labels}}'`). # Root cause: the original check used # docker node inspect {} --format '{{.Spec.Labels.pg-role}}' # — Go's template engine cannot parse a bare `.pg-role` field reference # (the hyphen is invalid in that position), so the command errored # silently (stderr redirected to /dev/null) and always returned empty, # regardless of actual label state. FIXED below by switching to # `docker node ls --filter "node.label=pg-role=primary"`, which uses # Docker's own filter syntax instead of Go template field access and # sidesteps the hyphen problem entirely. # Separately, rollback.sh's automatic consumer re-verification (triggered # by this false-negative rollback) flagged woodpecker's healthz as # "failed" because it returned HTTP 204 — a legitimate, common # "healthy, no content" response for that kind of endpoint. The health # checks below (and the matching ones in rollback.sh) now accept any 2xx # status code, not just literal 200. # # 2026 run #2 hit TWO further real bugs in Phase 1/2, both fixed below: # a) Phase 1 hardcoded patroni-1 as the expected standby_leader. The # Patroni/etcd bootstrap-race winner is actually nondeterministic — # it's a lock race, not something the compose file controls. The # ADR-0001 dry run happened to have patroni-1 win it, but the # earlier real production attempt (see ADR-0001 note) had patroni-0 # win instead — proof both outcomes genuinely occur. A check that # only ever accepts patroni-1 fails outright half the time even when # the cluster is perfectly healthy. FIXED by polling BOTH patroni-0 # AND patroni-1 every iteration; whichever reports standby_leader # first is captured as $LEADER_HOST, the other becomes # $REPLICA_HOST. Every phase from here on (2,3,4,5,6,8,10) uses # these two variables instead of a hardcoded hostname. # b) Phase 1's wait window (240s) and Phase 2's (300s) were both far # shorter than the real observed basebackup duration for this # cluster's ~42GB dataset — prior polling (both the dry run and the # earlier real attempt) showed a full basebackup_fast_xlog copy # takes roughly 8-11 minutes. The role only flips to # standby_leader/replica AFTER the copy fully completes, so both # timeouts could fire (and did) while a completely legitimate # basebackup was still in progress, triggering a false-negative # auto-rollback mid-copy. FIXED: both windows extended (and later # re-tuned further by direct operator edits in git — see commit # history for exact current values of PHASE1_MAX_WAIT/ # PHASE2_MAX_WAIT) with a per-poll `du -sh` (timeout-guarded, since # du has been observed to hang on CephFS under heavy concurrent # write load) logged on every iteration, so progress is directly # observable instead of a silent binary wait. # # 2026 run #3 follow-up (operator request, no new failure observed yet): # Phase 2's cascade-replica bootstrap adds an EXTRA network/streaming # hop on top of the same basebackup workload Phase 1 already does # (patroni-0/1's loser bootstraps FROM the winner, not from legacy # directly) — plausibly slower than Phase 1's copy, not just equal to # it. Phase 2's window was extended further than Phase 1's for this # reason (see current PHASE2_MAX_WAIT value below). # # 2026 run #4 — Phase 2's lag check was a SILENT NO-OP; found after a # real run passed Phase 1/2 cleanly (patroni-0 reached standby_leader, # patroni-1 basebackup completed, role flipped to "replica") but then # failed Phase 3's canary write with "Canary row did not propagate to # patroni-1 within 5s" moments later. Root cause: `patroni_lag()` queried # the REPLICA's OWN `/patroni` endpoint for lag/replication_state — but # Patroni only exposes per-connection replication lag (derived from # pg_stat_replication) on the LEADER/standby_leader side; a plain # replica's own `/patroni` response has no such field at all. So # $LAG_INFO was ALWAYS empty, and Phase 2's gate: # [ "$REPLICA_ROLE" = "replica" ] && { [ -z "$LAG_INFO" ] || ... ; } # short-circuited permanently true on the `[ -z "$LAG_INFO" ]` branch — # the lag check never actually ran. Phase 2 degraded to "did the role # flip to replica 3 times in a row?", which can be true BEFORE the # replica has actually caught up on WAL backlog from its own basebackup, # especially right after a fresh cascade connection. Phase 3's real # canary write then landed in exactly that still-catching-up window. # FIXED: replaced patroni_lag() with cluster_member_lag_state(), which # queries the LEADER's `/cluster` endpoint (works from either node, # reflects live DCS state — same endpoint/shape directly verified in the # original ADR-0001 dry run: member entries look like # {"name": "patroni-1", "role": "replica", "state": "streaming", "lag": 0}) # and extracts the SPECIFIC replica's "state"/"lag" fields from its own # member object (members are flat JSON objects with no nested braces, so # a bounded grep safely isolates exactly one member's fragment). Phase # 2's gate now requires literal state=streaming AND (lag=0 or lag field # absent — some responses omit it when lag is genuinely 0, per the # ADR-0001 note), not just "field happened to be empty because we asked # the wrong node." Phase 4's (informational, non-gating) lag log line # updated to use the same corrected function for consistency. # # 2026 run #5 — BOTH nodes stuck forever on "waiting for standby_leader # to bootstrap", never even attempting to race for the role, on a run # that started completely fresh (operator confirmed no other changes). # Root cause: a PRIOR run had been interrupted (operator stopped it # short) without a clean rollback.sh pass, leaving the etcd data # directories (etcd-1/2/3-data) non-empty on disk even though the # postgresqlha STACK itself was already gone. etcd's own persisted raft # state still had /service/postgres-ha/initialize set (a real system # identifier from the earlier attempt) plus a /service/postgres-ha/status # key listing old replication slots — so when the new run's patroni-0/1 # containers (with genuinely fresh, empty PGDATA) came up against this # NOT-actually-fresh etcd, Patroni correctly concluded "this cluster # already exists, someone else must already be the leader" and both # nodes deferred forever waiting for a leader that could never appear # (nobody actually holds the lock). rollback.sh's own data-dir wipe only # runs when it detects the postgresqlha stack IS currently present (its # Case D/E paths) — if the stack was already gone by the time rollback # logic ran (e.g. an operator-initiated stop before rollback.sh got a # chance to run), its Case A path does NOT wipe data dirs, leaving # exactly this trap for the next run. FIXED: added a "Pre-Phase-0" check # (below, before preflight.sh even runs) that detects a still-present # postgresqlha stack OR non-empty etcd-*/patroni-*-data directories left # over from a prior run, and — since this is destructive (wipes data) and # the operator explicitly asked for this to be an interactive decision, # not silently automatic — PROMPTS before cleaning up. See that section # for the exact detection logic, the prompt itself, and the # AUTO_CLEANUP=yes escape hatch for unattended re-runs where the operator # has already decided cleanup is always wanted. # # 2026 run #6 (addition, no new run yet) — SESSION LOGGING added. Every # prior investigation into a failed run depended entirely on someone # having a live terminal open and scrolled back far enough, or on the # operator's own memory. This is fragile, especially for the one # genuinely dangerous scenario every phase above is built to avoid ever # reaching manually: rollback.sh itself failing partway (see # trigger_rollback() below — this is the one case RUNBOOK.md section 4 # says requires manual intervention). Without a persisted transcript, # investigating THAT scenario after the fact means reconstructing what # happened from partial memory of a scrollback buffer that may already be # gone. FIXED: this script now writes its ENTIRE stdout/stderr transcript # (all 11 phases, plus Pre-Phase-0 and Phase 0's preflight.sh output, plus # any triggered rollback.sh output) to a single timestamped file in # BACKUP_DIR — the SAME directory the pg_dumpall backup itself lands in, # so the backup and the transcript of the run that produced/needed it are # always sitting right next to each other. See "SESSION LOGGING" comment # below for implementation detail. See ADR-0001 note, Session Update 9. # # 2026 run #7 — After the underlying Ceph IOPS problem (tracked/fixed # separately, not a script issue) was resolved, a real run finally got # CLEANLY past Phase 1 AND Phase 2 (basebackup ~690s, streaming lag=0 # confirmed) but failed Phase 3 within 6 seconds of Phase 2 passing: # "Canary row did not propagate to patroni-1 within 5s." This is the # SECOND time this exact Phase 3 failure has been seen with Phase 1/2 # genuinely healthy beforehand (see 2026 run #4 above for the first, # which had a different — since-fixed — root cause in the lag check # itself). Investigating this run's session log revealed a diagnostic # gap: the per-attempt `psql -h ${REPLICA_HOST} ... SELECT 1 FROM # _cutover_canary ...` check discarded stderr entirely (`2>/dev/null`), # so when the row failed to appear, the log showed only "row not found" # with NO indication of WHY — a genuine multi-second replication delay # on this specific DDL+INSERT and an outright connection/auth/permission # error to patroni-1 look IDENTICAL in that log, and there was no way to # tell them apart after the fact. FIXED: each attempt's stderr is now # captured to /tmp/cutover_phase3_attempt_.stderr (mirrors the # existing Phase 8 pattern below) AND echoed into the log per-attempt # alongside the (possibly empty) result, so a future failure's log alone # should show definitively whether this is a genuine propagation delay # or a real connection-level error. The final trigger_rollback() message # on failure also now includes the last non-empty stderr seen, so it's # visible directly in the FAIL line without needing to scroll back. # # 2026 run #8 — The 2026 run #7 stderr-capture fix immediately paid off: # the very next run's Phase 3 failure log showed, on every single one of # 5 attempts: # Password for user PGadmin: # psql: error: connection to server at "patroni-1" (...), port 5432 # failed: fe_sendauth: no password supplied # This was NEVER a replication/timing issue at all. ROOT CAUSE: legacy's # pg_hba.conf requires scram-sha-256 for any non-local connection ("host # all all all scram-sha-256"), and patroni-0/patroni-1 (per # postgresql-ha-staging.yaml) use PGUSER_SUPERUSER=PGadmin with the SAME # postgresql_password Docker secret as legacy's own PGadmin. Every remote # `psql -h -U "$POSTGRES_USER" ...` call in this script # had ALWAYS omitted a password entirely — this bug existed from the # script's very first version, it simply had never been reached with # working diagnostics until the run #7 fix exposed it. Checked the WHOLE # script for this pattern, not just Phase 3 — found the IDENTICAL missing # password in Phase 5 (the post-promotion `pg_is_in_recovery()` check on # $LEADER_HOST) and Phase 8 (the alias write + both visibility checks on # $LEADER_HOST/$REPLICA_HOST) — all three would have failed the exact # same way if ever reached. FIXED: read the postgresql_password secret # ONCE (via `docker exec "$LEGACY_CID" cat /run/secrets/postgresql_password`, # same secret/mechanism legacy itself already uses) into $ADMIN_PGPASSWORD # early in Phase 3, then pass it to every remote `psql -h ` call via # `docker exec -e PGPASSWORD="$ADMIN_PGPASSWORD" ...` — using docker exec's # own -e flag rather than splicing the password into the bash -c string, # specifically to avoid quoting/escaping hazards and to avoid the value # ever needing to appear inside this script's own string-construction # logic. NOT yet re-validated by an actual successful run past Phase 3. set -uo pipefail # NOTE: deliberately not -e — every phase below checks # its own command's exit status explicitly so we can # invoke rollback with a specific, accurate reason # rather than an opaque `set -e` abort mid-phase. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="/volume1/docker/compose-files" CUTOVER_DIR="${REPO_ROOT}/postgresql/cutover" HA_STACK="postgresqlha" LEGACY_STACK="postgresql" LEGACY_SERVICE="postgresql_postgresql" NETWORK_NAME="postgresql_db-backend" PATRONI_SCOPE="postgres-ha" BACKUP_DIR="/volume1/SMB-docker/backup" # ── SESSION LOGGING (added — see ADR-0001 note, Session Update 9) ──────── # Persists this run's ENTIRE multi-phase stdout/stderr transcript to a # single timestamped file in BACKUP_DIR — the SAME location preflight.sh # writes the pg_dumpall backup to, per explicit operator request: the # backup and the transcript of the run that needed it always end up # sitting right next to each other. CUTOVER_SESSION_LOG is exported so # preflight.sh and rollback.sh (both invoked below as plain # `bash