#!/usr/bin/env bash # P9 dry-run — runbook §3 (T-3d gate) executed LOCALLY. # # Reproduces the dry-run pipeline so we can verify the migration story # before T+0 cutover. Idempotent: re-running with --skip-restore reuses # whatever data is already loaded into mongo. # # Usage: # ./scripts/p9-dry-run.sh --dump-source=/tmp/prod-mongo.gz # ./scripts/p9-dry-run.sh --dump-source=/tmp/prod-mongo.gz --skip-restore # # Env: # CI_AUTO=1 — skip interactive confirmations (for cron / CI) # # Exit codes: # 0 — full pipeline OK, failures.jsonl == 0 lines (gate passes) # 1 — any step failed, or failures.jsonl > 0 (gate fails) # 2 — bad arguments / preflight failure # # Schema note (P9 cutover blocker fix): # The Postgres volume is wiped+recreated in STEP 2, so it boots with NO # schema. Nothing in the codebase auto-applies migrations on server boot # (cmd/migrate is an empty stub; dev.sh's `go run ./cmd/migrate up` was # never wired). go-api's River queue (river_job/river_leader/river_queue) # crash-loops on an empty DB. So STEP 4 below applies the FULL schema with # golang-migrate BEFORE the go-api stack-up in STEP 8. River is NOT a # separate migrator — its tables are bundled into go-api/migrations/0007+ # 0008, so one `migrate ... up` creates app schema AND River schema. set -euo pipefail # --- repo root (script lives in scripts/) ------------------------------ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # --- compose files ----------------------------------------------------- # Step 5 runs `go run migrate-mongo` on the HOST against localhost:27017 / # localhost:5432. The base docker-compose.yml deliberately does NOT publish # mongo/postgres ports (prod safety); docker-compose.ci.yml does. So a local # dry-run MUST layer in the ci overlay or the migrate step can't connect. # COMPOSE_FILE makes every `docker compose` call in this script use both. # Absolute paths so it holds regardless of CWD (the script cd's around). export COMPOSE_FILE="${COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml:$REPO_ROOT/docker-compose.ci.yml}" # --- log file ---------------------------------------------------------- TS="$(date +%Y%m%d-%H%M%S)" LOG_FILE="/tmp/p9-dry-run-${TS}.log" # --- color helpers (TTY-aware) ---------------------------------------- if [ -t 1 ] && command -v tput >/dev/null 2>&1 && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then C_GREEN="$(tput setaf 2)" C_RED="$(tput setaf 1)" C_YELLOW="$(tput setaf 3)" C_BOLD="$(tput bold)" C_RESET="$(tput sgr0)" else C_GREEN=""; C_RED=""; C_YELLOW=""; C_BOLD=""; C_RESET="" fi ok() { printf "%s[OK]%s %s\n" "$C_GREEN" "$C_RESET" "$1"; } warn() { printf "%s[WARN]%s %s\n" "$C_YELLOW" "$C_RESET" "$1"; } fail() { printf "%s[FAIL]%s %s\n" "$C_RED" "$C_RESET" "$1"; } info() { printf "%s[..]%s %s\n" "$C_BOLD" "$C_RESET" "$1"; } # --- redirect everything through tee ---------------------------------- # Must run AFTER color helpers so the log file gets ANSI-free text via # `tee` — but our color codes only fire on TTY, so the log will be clean # when this script is invoked via cron. exec > >(tee -a "$LOG_FILE") 2>&1 info "logging to $LOG_FILE" # --- args -------------------------------------------------------------- DUMP_SOURCE="" SKIP_RESTORE=0 for arg in "$@"; do case "$arg" in --dump-source=*) DUMP_SOURCE="${arg#--dump-source=}" ;; --skip-restore) SKIP_RESTORE=1 ;; --help|-h) printf "usage: %s --dump-source= [--skip-restore]\n" "$0" exit 0 ;; *) fail "unknown arg: $arg"; exit 2 ;; esac done if [ "$SKIP_RESTORE" -eq 0 ] && [ -z "$DUMP_SOURCE" ]; then fail "--dump-source= is required unless --skip-restore is set" exit 2 fi if [ "$SKIP_RESTORE" -eq 0 ] && [ ! -f "$DUMP_SOURCE" ]; then fail "dump source not found: $DUMP_SOURCE" exit 2 fi # --- confirm helper --------------------------------------------------- confirm() { local action="$1" printf "%s[ABOUT TO]%s %s\n" "$C_YELLOW" "$C_RESET" "$action" if [ "${CI_AUTO:-0}" = "1" ]; then info "CI_AUTO=1 — auto-continuing" return 0 fi sleep 3 local reply read -p "Continue? (y/N) " -r reply if [[ ! "$reply" =~ ^[Yy]$ ]]; then fail "user aborted" exit 1 fi } # --- runner that prints command before running ------------------------ run() { info "\$ $*" "$@" } # --- timer helper ----------------------------------------------------- time_step() { local label="$1"; shift local start end elapsed rc start="$(date +%s)" # Preserve the wrapped command's exit code. Without this, a failing # step (e.g. smoke returning 1) was masked because the function's # last command (printf) returned 0, so the caller's `|| RC=$?` never # fired and the gate falsely reported PASS. "$@" && rc=0 || rc=$? end="$(date +%s)" elapsed=$((end - start)) ok "$label finished in ${elapsed}s" printf "%s\t%ds\n" "$label" "$elapsed" >> "/tmp/p9-dry-run-timing-${TS}.txt" return "$rc" } # ====================================================================== # STEP 1 — preflight # ====================================================================== info "STEP 1/9 — preflight" ENV_FILE="$REPO_ROOT/.env.production" if [ ! -f "$ENV_FILE" ]; then fail "$ENV_FILE not found — runbook §3 step 1 requires it" exit 2 fi ok "$ENV_FILE exists" # pull POSTGRES_PASSWORD out of .env.production WITHOUT sourcing the # whole file (avoid leaking secrets into env if something else is set). PG_PASS="$(grep -E '^POSTGRES_PASSWORD=' "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" || true)" if [ -z "$PG_PASS" ]; then fail "POSTGRES_PASSWORD missing or empty in $ENV_FILE" exit 2 fi ok "POSTGRES_PASSWORD is set in .env.production" # golang-migrate is required for STEP 4 (apply Postgres schema). It is a # host tool (installed via scripts/p0-bootstrap.sh — `brew install # golang-migrate`), NOT bundled into the go-api runtime image. Fail fast # here rather than 3 steps in. if ! command -v migrate >/dev/null 2>&1; then fail "golang-migrate ('migrate') not on PATH — install it: brew install golang-migrate" exit 2 fi ok "golang-migrate present: $(migrate -version 2>&1 | head -1)" cd "$REPO_ROOT" # ====================================================================== # STEP 2 — reset mongo + postgres # ====================================================================== if [ "$SKIP_RESTORE" -eq 0 ]; then info "STEP 2/9 — reset mongodb + postgres volumes" confirm "docker compose down mongodb postgres + volume rm (DESTROYS local data)" run docker compose down mongodb postgres || warn "down had non-zero exit (probably not running yet, ok)" # Volume names follow the standard compose convention _. # If the operator overrode COMPOSE_PROJECT_NAME the rm will say "no such # volume" — harmless; the next `up` will recreate clean volumes. run docker volume rm animego_mongo-data 2>/dev/null || warn "animego_mongo-data already gone" run docker volume rm animego_postgres-data 2>/dev/null || warn "animego_postgres-data already gone" ok "volumes wiped" else info "STEP 2/9 — skipped (--skip-restore)" fi # ====================================================================== # STEP 3 — bring up mongo + postgres, wait healthy # ====================================================================== info "STEP 3/9 — start mongodb + postgres" run docker compose --env-file=.env.production up -d mongodb postgres info "waiting up to 60s for mongo + postgres to become healthy..." HEALTHY=0 for i in $(seq 1 60); do # `docker compose ps --format json` is one row per service; grep for # both being healthy or running-without-healthcheck. M_STATE="$(docker compose ps --format '{{.Service}}={{.Status}}' 2>/dev/null | grep '^mongodb=' || true)" P_STATE="$(docker compose ps --format '{{.Service}}={{.Status}}' 2>/dev/null | grep '^postgres=' || true)" if echo "$M_STATE" | grep -qiE "(healthy|up)" && echo "$P_STATE" | grep -qiE "(healthy|up)"; then HEALTHY=1 break fi sleep 1 done if [ "$HEALTHY" -ne 1 ]; then fail "mongo + postgres did not come healthy in 60s" info "current state:" docker compose ps mongodb postgres || true exit 1 fi ok "mongo + postgres up" # ====================================================================== # STEP 4 — apply Postgres schema (app migrations + River) [P9 fix] # ====================================================================== # CRITICAL for cutover: STEP 2 wiped the postgres volume, so it has NO # schema. The go-api server does NOT migrate on boot — its River queue # (river_job/river_leader/river_queue) crash-loops against an empty DB # ("relation \"river_queue\" does not exist"). We must apply the schema # here, BEFORE the go-api stack-up in STEP 8, or step 8's smoke fails. # # River is NOT a separate migrator: river v0.37 internal migrations are # baked into go-api/migrations/0007_river_initial + 0008_river_pending_use, # so a SINGLE golang-migrate `up` creates BOTH the app tables (anime_cache, # users, subscriptions, danmakus, episode_windows, ...) AND the River # tables. Order within that one command is fixed by file numbering 0001..N. # # DATA NOTE: this dry-run keeps STEP 6 as migrate-mongo --dry-run (gate # semantics unchanged — it counts/validates but does NOT write PG). So # go-api in STEP 8 boots against schema-only, EMPTY tables. That is fine: # the smoke checks go-api /health (DB connectivity + River boot), which # only needs the schema to exist, not rows. To also exercise a real data # endpoint here you would flip STEP 6 to --commit — deliberately NOT done, # to preserve the T-3d "no writes" gate. info "STEP 4/9 — apply Postgres schema (golang-migrate: app + River)" # pg is published on 127.0.0.1:5432 by the ci overlay (see COMPOSE_FILE # header). Pin 127.0.0.1, NOT localhost: localhost resolves to ::1 first, and # golang-migrate fails fast when the freshly-booted postgres resets that IPv6 # connection during its pg_cron first-init restart. Mirror STEP 6's wiring. # golang-migrate is idempotent: re-running with --skip-restore on an # already-migrated DB is a no-op. PG_URI_LOCAL="postgres://animego:${PG_PASS}@127.0.0.1:5432/animego?sslmode=disable" MIGRATE_SCHEMA_CMD=(migrate -path "$REPO_ROOT/go-api/migrations" -database "$PG_URI_LOCAL" up) # Mask POSTGRES_PASSWORD in the echoed command (see STEP 6 for rationale). MIGRATE_SCHEMA_DISPLAY="${MIGRATE_SCHEMA_CMD[*]}" info "\$ ${MIGRATE_SCHEMA_DISPLAY//$PG_PASS/***}" # `migrate ... up` prints "no change" + exits 0 when already at head, so # this is safe under --skip-restore. Any real failure (bad SQL, dirty # state) is fatal — the cutover must not proceed on a half-applied schema. # Retry the schema migrate: postgres can report container-healthy (STEP 3) # while the host-published :5432 still resets connections during first-boot # init (pg_cron shared_preload restart). This IS the exact host->:5432 path # STEP 6's migrate-mongo uses, so a green here also proves STEP 6 can connect. schema_ok=0 for attempt in $(seq 1 15); do if time_step "schema" "${MIGRATE_SCHEMA_CMD[@]}"; then schema_ok=1 break fi warn "schema migrate attempt ${attempt}/15 failed (postgres host-TCP not ready yet) — retrying in 2s" sleep 2 done [ "$schema_ok" -eq 1 ] || { fail "Postgres schema migrate failed after 15 attempts (~30s)"; exit 1; } ok "Postgres schema applied (app tables + River queue tables)" # ====================================================================== # STEP 5 — mongorestore from dump # ====================================================================== if [ "$SKIP_RESTORE" -eq 0 ]; then info "STEP 5/9 — mongorestore from $DUMP_SOURCE" confirm "stream $DUMP_SOURCE into docker compose exec mongodb mongorestore (will overwrite collections)" time_step "restore" bash -c "docker compose exec -T mongodb mongorestore --gzip --archive --drop < '$DUMP_SOURCE'" ok "mongorestore done" else info "STEP 5/9 — skipped (--skip-restore)" fi # ====================================================================== # STEP 6 — migrate-mongo --dry-run # ====================================================================== info "STEP 6/9 — migrate-mongo dry-run" # IMPORTANT: the runbook §3 step 3 snippet OMITS --dry-run/--commit but # go-api/cmd/migrate-mongo/main.go REQUIRES exactly one of them # (validateFlags). We use --dry-run here since this is the T-3d gate. # Runbook ambiguity reported in the wrap-up. MIGRATE_FAILURES="$REPO_ROOT/go-api/migrate-failures.jsonl" rm -f "$MIGRATE_FAILURES" # Run the migrator from inside go-api/ via `go run` so we don't need a # pre-built binary. URI hostnames are `localhost` because mongo + pg are # port-forwarded on the host by docker compose for local dev. cd "$REPO_ROOT/go-api" MIGRATE_CMD=( go run ./cmd/migrate-mongo --mongo-uri="mongodb://127.0.0.1:27017/animego" --pg-uri="postgres://animego:${PG_PASS}@127.0.0.1:5432/animego?sslmode=disable" --dry-run --log-failed="$MIGRATE_FAILURES" ) # Mask POSTGRES_PASSWORD in the echoed command so it never lands in the # tee'd log or CI output. The migrator's own structured log already masks # it (pg_uri shows ***); only this command-echo leaked the cleartext. MIGRATE_DISPLAY="${MIGRATE_CMD[*]}" info "\$ ${MIGRATE_DISPLAY//$PG_PASS/***}" time_step "migrate" "${MIGRATE_CMD[@]}" cd "$REPO_ROOT" # ====================================================================== # STEP 7 — inspect failures.jsonl # ====================================================================== info "STEP 7/9 — inspect failures.jsonl" if [ -f "$MIGRATE_FAILURES" ]; then LINE_COUNT="$(wc -l < "$MIGRATE_FAILURES" | tr -d ' ')" else LINE_COUNT=0 fi info "failures.jsonl line count: $LINE_COUNT" if [ "$LINE_COUNT" -gt 0 ]; then warn "first 5 failure lines:" head -5 "$MIGRATE_FAILURES" fail "gate: failures.jsonl must be 0 for T-3d gate to pass" GATE_PASS=0 else ok "gate: failures.jsonl is empty" GATE_PASS=1 fi # ====================================================================== # STEP 8 — full Go stack + smoke # ====================================================================== info "STEP 8/9 — bring up Go stack + smoke" run docker compose --env-file=.env.production up -d postgres go-api ws-server next-app nginx info "waiting 20s for stack to settle..." sleep 20 # go-api health gate (validates the STEP 4 schema fix): before the schema # was applied, go-api crash-looped on River's missing tables. We poll its # compose healthcheck (Up ... (healthy)) and hit /health directly. The ci # overlay does NOT publish go-api's port, so probe via `compose exec`. # Failure here means the schema/River boot is broken — fail the gate. GO_API_RC=0 GO_API_HEALTHY=0 for i in $(seq 1 30); do GA_STATE="$(docker compose ps --format '{{.Service}}={{.Status}}' 2>/dev/null | grep '^go-api=' || true)" if echo "$GA_STATE" | grep -qi "healthy"; then GO_API_HEALTHY=1 break fi sleep 2 done if [ "$GO_API_HEALTHY" -eq 1 ] && \ docker compose exec -T go-api wget -qO- http://localhost:8080/health 2>/dev/null | grep -q '"db":"up"'; then ok "go-api healthy + /health reports db:up (River boot OK on migrated schema)" else fail "go-api did NOT come healthy — schema/River boot likely broken" info "go-api state: ${GA_STATE:-}" docker compose logs go-api --tail 20 2>&1 | grep -iE "river|relation|error" || true GO_API_RC=1 fi # nginx restart (NOT reload): STEP 8's `up -d` recreated go-api/next-app/ # ws-server/app with NEW container IPs, but left nginx Running on its OLD # cached upstream IPs, so every proxied route 503s. nginx only re-resolves # its upstreams on a full restart (reload keeps the stale resolver cache). # This is the exact prod-cutover gotcha: after any force-recreate, restart # nginx before trusting a smoke result. run docker compose restart nginx sleep 2 SMOKE_SCRIPT="$REPO_ROOT/scripts/smoke-p8.1.sh" if [ ! -x "$SMOKE_SCRIPT" ]; then chmod +x "$SMOKE_SCRIPT" || true fi SMOKE_RC=0 time_step "smoke" bash "$SMOKE_SCRIPT" "https://localhost" || SMOKE_RC=$? if [ "$SMOKE_RC" -ne 0 ]; then fail "smoke had $SMOKE_RC failures" else ok "smoke passed" fi # ====================================================================== # STEP 9 — timing summary # ====================================================================== info "STEP 9/9 — timing summary" if [ -f "/tmp/p9-dry-run-timing-${TS}.txt" ]; then printf "\n%s%-12s %s%s\n" "$C_BOLD" "STEP" "ELAPSED" "$C_RESET" cat "/tmp/p9-dry-run-timing-${TS}.txt" fi printf "\n" if [ "$GATE_PASS" -eq 1 ] && [ "$SMOKE_RC" -eq 0 ] && [ "$GO_API_RC" -eq 0 ]; then ok "DRY-RUN GATE: PASS — clear to schedule T+0" info "log: $LOG_FILE" exit 0 else fail "DRY-RUN GATE: FAIL — do NOT proceed to T+0 until failures resolved" info "log: $LOG_FILE" exit 1 fi