Files

156 lines
8.7 KiB
Bash
Raw Permalink Normal View History

#!/bin/bash
# =============================================================================
# Restore Drill — Dump → fresh DB → Restore → Smoke checks (fully local)
# =============================================================================
# Proves the backup→restore path end-to-end WITHOUT production access:
# 1. Build a source DB via alembic migrations + seed rows
# 2. pg_dump it
# 3. Create an empty target DB
# 4. Restore the dump into it
# 5. Smoke-check integrity (table count, alembic version, row parity)
#
# Usage:
# bash scripts/restore_drill.sh
#
# Exit codes: 0 = drill passed, 1 = drill failed
# =============================================================================
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5432}"
PGUSER_SRC="${PGUSER_SRC:-leocrm_test}"
PGPASS_SRC="${PGPASS_SRC:-test123}"
SRC_DB="leocrm_drill_src_$$"
DST_DB="leocrm_drill_dst_$$"
BACKUP_FILE="/tmp/leocrm_drill_$$.sql"
export PGPASSWORD="$PGPASS_SRC"
PSQL=(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER_SRC" -v ON_ERROR_STOP=1 -q)
cleanup() {
echo "[DRILL] Cleanup temp databases..."
"${PSQL[@]}" -d postgres -c "DROP DATABASE IF EXISTS $SRC_DB" >/dev/null 2>&1 || true
"${PSQL[@]}" -d postgres -c "DROP DATABASE IF EXISTS $DST_DB" >/dev/null 2>&1 || true
rm -f "$BACKUP_FILE"
}
trap cleanup EXIT
step() { echo -e "${YELLOW}[DRILL] $1${NC}"; }
pass() { echo -e "${GREEN}[DRILL] PASS: $1${NC}"; }
fail() { echo -e "${RED}[DRILL] FAIL: $1${NC}"; exit 1; }
echo "============================================================"
echo " Restore Drill — local end-to-end proof"
echo "============================================================"
# ── 0. pgvector available? ────────────────────────────────────────────────────
step "0. Prerequisite: template1 has vector extension (for new DBs)"
"${PSQL[@]}" -d postgres -c "CREATE EXTENSION IF NOT EXISTS vector" >/dev/null 2>&1 || true
pass "pgvector ensured"
# ── 1. Source DB via migrations ──────────────────────────────────────────────
step "1. Create source DB + run alembic migrations"
"${PSQL[@]}" -d postgres -c "CREATE DATABASE $SRC_DB OWNER $PGUSER_SRC" >/dev/null
export DATABASE_URL="postgresql+asyncpg://$PGUSER_SRC:$PGPASS_SRC@$PGHOST:$PGPORT/$SRC_DB"
/opt/venv/bin/python -m alembic upgrade head > /tmp/drill_migrate.log 2>&1 \
|| fail "alembic upgrade head failed (see /tmp/drill_migrate.log)"
pass "source DB migrated"
# ── 2. Seed representative data ──────────────────────────────────────────────
step "2. Seed representative rows (tenant, user, contact, audit)"
PY=/opt/venv/bin/python
TENANT_ID=$($PY -c 'import uuid; print(uuid.uuid4())')
USER_ID=$($PY -c 'import uuid; print(uuid.uuid4())')
CONTACT_ID=$($PY -c 'import uuid; print(uuid.uuid4())')
"${PSQL[@]}" -d "$SRC_DB" <<SQL >/dev/null
INSERT INTO tenants (id, name, slug, created_at, updated_at)
VALUES ('$TENANT_ID', 'Drill Tenant', 'drill-tenant-$RANDOM', now(), now());
INSERT INTO users (id, email, name, password_hash, is_active, created_at, updated_at)
VALUES ('$USER_ID', 'drill@example.com', 'Drill User', 'x', true, now(), now());
INSERT INTO user_tenants (user_id, tenant_id, role, status, is_default, created_at)
VALUES ('$USER_ID', '$TENANT_ID', 'admin', 'active', true, now());
INSERT INTO contacts (id, tenant_id, type, displayname, name, owner_id, created_by, updated_by, created_at, updated_at)
VALUES ('$CONTACT_ID', '$TENANT_ID', 'company', 'Drill Corp', 'Drill Corp', '$USER_ID', '$USER_ID', '$USER_ID', now(), now());
INSERT INTO audit_log (id, tenant_id, user_id, action, entity_type, entity_id, timestamp)
VALUES (gen_random_uuid(), '$TENANT_ID', '$USER_ID', 'create', 'contact', '$CONTACT_ID', now());
SQL
pass "seed rows inserted"
# ── 3. Dump source DB ────────────────────────────────────────────────────────
step "3. pg_dump source DB"
PGPASSWORD="$PGPASS_SRC" pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER_SRC" \
--no-owner --no-acl "$SRC_DB" > "$BACKUP_FILE" \
|| fail "pg_dump failed"
SIZE=$(wc -c < "$BACKUP_FILE")
[ "$SIZE" -gt 10000 ] || fail "dump suspiciously small ($SIZE bytes)"
pass "dump written ($SIZE bytes)"
# ── 4. Fresh target DB + restore ─────────────────────────────────────────────
step "4. Create empty target DB and restore dump"
"${PSQL[@]}" -d postgres -c "CREATE DATABASE $DST_DB OWNER $PGUSER_SRC" >/dev/null
PGPASSWORD="$PGPASS_SRC" psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER_SRC" \
-v ON_ERROR_STOP=1 -q -d "$DST_DB" -f "$BACKUP_FILE" \
|| fail "restore into fresh DB failed"
pass "restore completed"
# ── 5. Smoke checks ──────────────────────────────────────────────────────────
step "5. Smoke checks on restored DB"
TABLES_DST=$("${PSQL[@]}" -d "$DST_DB" -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public'")
TABLES_SRC=$("${PSQL[@]}" -d "$SRC_DB" -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public'")
[ "$TABLES_DST" = "$TABLES_SRC" ] || fail "table count mismatch: src=$TABLES_SRC dst=$TABLES_DST"
pass "table count parity: $TABLES_DST tables"
VER_DST=$("${PSQL[@]}" -d "$DST_DB" -tAc "SELECT version_num FROM alembic_version")
VER_SRC=$("${PSQL[@]}" -d "$SRC_DB" -tAc "SELECT version_num FROM alembic_version")
[ "$VER_DST" = "$VER_SRC" ] || fail "alembic version mismatch: src=$VER_SRC dst=$VER_DST"
pass "alembic version parity: $VER_DST"
RLS_DST=$("${PSQL[@]}" -d "$DST_DB" -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND rowsecurity=true")
RLS_SRC=$("${PSQL[@]}" -d "$SRC_DB" -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND rowsecurity=true")
[ "$RLS_DST" = "$RLS_SRC" ] || fail "RLS table count mismatch: src=$RLS_SRC dst=$RLS_DST"
# NOTE: a pure-migration DB has fewer RLS tables than production (plugin
# runtime schema-sync adds more); parity + a sane floor is what we assert.
[ "$RLS_DST" -ge 50 ] || fail "RLS count too low: $RLS_DST"
pass "RLS policies restored: $RLS_DST tables (parity with source)"
C_SRC=$("${PSQL[@]}" -d "$SRC_DB" -tAc "SET app.current_tenant_id='$TENANT_ID'; SELECT count(*) FROM contacts")
C_DST=$("${PSQL[@]}" -d "$DST_DB" -tAc "SET app.current_tenant_id='$TENANT_ID'; SELECT count(*) FROM contacts")
[ "$C_DST" = "$C_SRC" ] && [ "$C_DST" -ge 1 ] || fail "contacts row parity failed: src=$C_SRC dst=$C_DST"
pass "tenant-scoped contacts parity: $C_DST rows"
A_SRC=$("${PSQL[@]}" -d "$SRC_DB" -tAc "SELECT count(*) FROM audit_log")
A_DST=$("${PSQL[@]}" -d "$DST_DB" -tAc "SELECT count(*) FROM audit_log")
[ "$A_DST" = "$A_SRC" ] || fail "audit_log parity failed: src=$A_SRC dst=$A_DST"
pass "audit_log parity: $A_DST rows"
# RLS fail-closed check on restored DB — MUST run as a non-superuser role
# without BYPASSRLS (superusers/owners bypass RLS by design).
step "6. RLS fail-closed as restricted role"
"${PSQL[@]}" -d "$DST_DB" -c "DO \$\$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'drill_rls_user') THEN
CREATE ROLE drill_rls_user LOGIN PASSWORD 'drill123' NOSUPERUSER NOBYPASSRLS;
END IF;
END \$\$;" >/dev/null
"${PSQL[@]}" -d "$DST_DB" -c "GRANT USAGE ON SCHEMA public TO drill_rls_user; GRANT SELECT ON contacts TO drill_rls_user;" >/dev/null
NOCTX=$(PGPASSWORD=drill123 psql -h "$PGHOST" -p "$PGPORT" -U drill_rls_user -d "$DST_DB" -tAc "SELECT count(*) FROM contacts")
[ "$NOCTX" = "0" ] || fail "RLS fail-closed broken on restored DB: $NOCTX rows visible to restricted role without tenant"
pass "RLS fail-closed verified on restored DB (restricted role sees 0 rows)"
# Policies are bound to app roles (crm_api/crm_worker) by design; a foreign
# role is intentionally denied even WITH tenant context (no policy matches).
POLICY_ROLES=$("${PSQL[@]}" -d "$DST_DB" -tAc "SELECT string_agg(roles::text, ',') FROM pg_policies WHERE tablename='contacts'")
echo "[DRILL] contacts RLS policy roles: $POLICY_ROLES"
[[ "$POLICY_ROLES" == *crm_api* ]] || fail "contacts RLS policy not bound to crm_api role"
pass "RLS policies bound to app roles (crm_api) as designed"
echo ""
echo "============================================================"
echo -e " ${GREEN}[DRILL] PASSED — backup→restore path proven end-to-end${NC}"
echo "============================================================"