← back to Dw Pairs Well

scripts/fix-all-stale-dw-admin.sh

153 lines

#!/usr/bin/env bash
# fix-all-stale-dw-admin.sh — idempotent fleet-wide repair of the stale dw_admin
# Postgres password across pm2 services on Kamatera.
#
# Root problem (2026-06): the dw_admin password was rotated, but many service
# .env files still carry the OLD password, so every DB query fails with
# `password authentication failed for user "dw_admin"`. (First seen breaking
# the Pairs-Well coordinates widget; same rot logged in memory
# dw-unified-backup-broken.md — nightly pg_dump 0-byte since 2026-06-13.)
#
# This script: for every pm2 process whose .env DATABASE_URL uses dw_admin,
#   1. test the CURRENT url with `SELECT 1` — if it AUTHENTICATES, skip (idempotent);
#   2. else build a candidate url with the CANONICAL password from secrets-manager
#      and test THAT with `SELECT 1`;
#   3. ONLY if the candidate authenticates: back up .env, swap *only* the password
#      segment, `pm2 restart NAME --update-env`, then re-verify (psql + healthz).
#   4. if neither the current nor the candidate authenticates, report STILL-FAILING
#      and touch nothing (it's not a stale-password problem — host down, db gone,
#      or canon itself wrong).
#
# DRY-RUN by default. Pass --apply to actually write + restart.
# The password is NEVER echoed; it only ever lives in process env vars.
#
# Usage (run ON Kamatera):
#   bash fix-all-stale-dw-admin.sh            # dry-run: show what's stale + the plan
#   bash fix-all-stale-dw-admin.sh --apply    # repair every stale service, one at a time

set -u

APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1

SECRETS_ENV="${SECRETS_ENV:-/root/Projects/secrets-manager/.env}"
# Fall back to the Mac2 path if the Kamatera one isn't present.
[ -f "$SECRETS_ENV" ] || SECRETS_ENV="$HOME/Projects/secrets-manager/.env"

red()  { printf '\033[31m%s\033[0m\n' "$*"; }
grn()  { printf '\033[32m%s\033[0m\n' "$*"; }
ylw()  { printf '\033[33m%s\033[0m\n' "$*"; }
mask() { sed -E 's#(://[^:]+:)[^@]+(@)#\1***\2#g'; }

command -v jq   >/dev/null 2>&1 || { red "jq not found"; exit 1; }
command -v psql >/dev/null 2>&1 || { red "psql not found"; exit 1; }
command -v pm2  >/dev/null 2>&1 || { red "pm2 not found"; exit 1; }

CANON="$(grep -m1 '^DW_ADMIN_PG_PASSWORD=' "$SECRETS_ENV" 2>/dev/null | cut -d= -f2-)"
if [ -z "${CANON:-}" ]; then
  red "FATAL: DW_ADMIN_PG_PASSWORD not found in $SECRETS_ENV — cannot proceed."
  exit 1
fi
export CANON

if [ "$APPLY" = "1" ]; then ylw "=== MODE: APPLY (will edit .env + pm2 restart stale services) ==="
else                        grn  "=== MODE: DRY-RUN (no writes) — pass --apply to repair ==="; fi
echo "canonical pw source: $SECRETS_ENV  (loaded, value hidden)"
echo

# Build "<name>\t<cwd>" for every pm2 process (one per line).
mapfile -t ROWS < <(pm2 jlist 2>/dev/null | jq -r '.[] | [.name, .pm2_env.pm_cwd] | @tsv')

OK=0; FIXED=0; WOULDFIX=0; SKIP=0; FAILED=0; SEEN_ENV=""

# Swap ONLY the password between "://dw_admin:" and the next "@". Password is
# treated as a literal string (no regex interpretation) by reading it from env.
build_url() {  # $1 = old url ; uses $CANON
  OLDURL="$1" python3 - <<'PY'
import os, re, sys
url = os.environ["OLDURL"]; pw = os.environ["CANON"]
m = re.match(r'^(.*?://dw_admin:)([^@]*)(@.*)$', url, re.S)
if not m:
    sys.exit(3)
sys.stdout.write(m.group(1) + pw + m.group(3))
PY
}

write_env() {  # $1 = envfile ; $2 = new url
  F="$1" NEWURL="$2" python3 - <<'PY'
import os
f = os.environ["F"]; nl = os.environ["NEWURL"]
out = []
for ln in open(f).read().splitlines():
    out.append("DATABASE_URL=" + nl if ln.startswith("DATABASE_URL=") else ln)
open(f, "w").write("\n".join(out) + "\n")
PY
}

healthz_of() {  # $1 = cwd -> echo HEALTH_URL from .deploy.conf if present
  [ -f "$1/.deploy.conf" ] && grep -m1 '^HEALTH_URL=' "$1/.deploy.conf" | cut -d= -f2-
}

for row in "${ROWS[@]}"; do
  NAME="${row%%$'\t'*}"
  CWD="${row#*$'\t'}"
  ENVF="$CWD/.env"
  [ -f "$ENVF" ] || continue

  URL="$(grep -m1 '^DATABASE_URL=' "$ENVF" 2>/dev/null | cut -d= -f2-)"
  [ -n "$URL" ] || continue
  case "$URL" in *dw_admin:*) ;; *) continue ;; esac   # only password'd dw_admin urls

  # de-dupe: several pm2 apps may share one .env
  case "$SEEN_ENV" in *"|$ENVF|"*) continue ;; esac
  SEEN_ENV="$SEEN_ENV|$ENVF|"

  # 1) current password good?  -> idempotent skip
  if psql "$URL" -tAc 'SELECT 1' >/dev/null 2>&1; then
    grn "OK     $NAME  ($ENVF)"; OK=$((OK+1)); continue
  fi

  # 2) build + test the canonical-password candidate
  NEWURL="$(build_url "$URL")" || { red "SKIP   $NAME  — unparseable DATABASE_URL shape"; SKIP=$((SKIP+1)); continue; }
  if [ "$NEWURL" = "$URL" ]; then
    red "FAIL   $NAME  — already on canonical pw but auth still fails (host/db issue, not stale pw)"; FAILED=$((FAILED+1)); continue
  fi
  if ! psql "$NEWURL" -tAc 'SELECT 1' >/dev/null 2>&1; then
    red "FAIL   $NAME  — canonical pw ALSO fails (host down / db gone / canon wrong) — leaving untouched"; FAILED=$((FAILED+1)); continue
  fi

  # 3) candidate authenticates — stale password confirmed
  if [ "$APPLY" = "0" ]; then
    ylw "STALE  $NAME  — WOULD fix ($ENVF) + pm2 restart"; WOULDFIX=$((WOULDFIX+1)); continue
  fi

  BAK="$ENVF.bak.$(date +%Y%m%d-%H%M%S)"
  cp "$ENVF" "$BAK"
  write_env "$ENVF" "$NEWURL"
  # verify the write took and now authenticates before bouncing the process
  CHK="$(grep -m1 '^DATABASE_URL=' "$ENVF" | cut -d= -f2-)"
  if ! psql "$CHK" -tAc 'SELECT 1' >/dev/null 2>&1; then
    red "ERROR  $NAME  — post-write psql failed; rolling back"; cp "$BAK" "$ENVF"; FAILED=$((FAILED+1)); continue
  fi
  pm2 restart "$NAME" --update-env >/dev/null 2>&1
  sleep 2
  HU="$(healthz_of "$CWD")"
  if [ -n "$HU" ]; then
    CODE="$(curl -s -o /dev/null -m 15 -w '%{http_code}' "$HU")"
    if [ "$CODE" = "200" ]; then grn "FIXED  $NAME  — restarted, healthz 200 ($BAK)"; FIXED=$((FIXED+1))
    else ylw "FIXED? $NAME  — restarted + db OK, but healthz=$CODE ($HU) — check app log"; FIXED=$((FIXED+1)); fi
  else
    grn "FIXED  $NAME  — restarted, db auth OK (no HEALTH_URL to probe) ($BAK)"; FIXED=$((FIXED+1))
  fi
done

echo
echo "──────── summary ────────"
echo "already OK : $OK"
[ "$APPLY" = "0" ] && echo "stale      : $WOULDFIX  (run with --apply to fix)" || echo "fixed      : $FIXED"
echo "failed     : $FAILED  (host/db/canon issue — NOT touched)"
echo "skipped    : $SKIP  (unparseable url shape)"
[ "$FAILED" -gt 0 ] && ylw "Note: 'failed' services have a non-password problem — investigate before any further action."
[ "$APPLY" = "0" ] && [ "$WOULDFIX" -gt 0 ] && echo "Next: re-run with  --apply  to repair the $WOULDFIX stale service(s), one at a time."
exit 0