scripts/p9-dry-run.sh
Rama main · réplica de lectura del repositorio original.
#!/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=<path-to-mongodump.gz> [--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=<path> 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 <project>_<volname>.
# 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:-<none>}"
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