bunker-admin a77306fac2 Initial v2 commit: complete rebuild with unified API + React admin
Phase 1-14 complete:
- Unified Express.js API (TypeScript, Prisma ORM, PostgreSQL 16)
- React Admin GUI (Vite + Ant Design + Zustand)
- JWT auth with refresh tokens
- Influence: Campaigns, Representatives, Responses, Email Queue
- Map: Locations, Cuts, Shifts, Canvassing System
- NAR data import infrastructure (2025 format)
- Listmonk newsletter integration
- Landing page builder (GrapesJS)
- MkDocs + Code Server integration
- Volunteer portal with GPS tracking
- Monitoring stack (Prometheus, Grafana, Alertmanager)
- Pangolin tunnel integration

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 10:05:04 -07:00

173 lines
6.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# Changemaker Lite V2 — Backup Script
# Backs up PostgreSQL databases, uploads, and generates a manifest.
# Usage: ./scripts/backup.sh [--s3] [--retention DAYS]
# =============================================================================
set -euo pipefail
# --- Configuration ---
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}"
RETENTION_DAYS="${RETENTION_DAYS:-30}"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP_NAME="changemaker-v2-backup-${TIMESTAMP}"
BACKUP_PATH="${BACKUP_DIR}/${BACKUP_NAME}"
S3_UPLOAD=false
# --- Parse args ---
while [[ $# -gt 0 ]]; do
case "$1" in
--s3) S3_UPLOAD=true; shift ;;
--retention) RETENTION_DAYS="$2"; shift 2 ;;
--help)
echo "Usage: $0 [--s3] [--retention DAYS]"
echo " --s3 Upload backup to S3 (requires AWS CLI + S3_BUCKET env var)"
echo " --retention N Delete local backups older than N days (default: 30)"
exit 0 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
# --- Load .env if present (safe parsing to handle special characters) ---
if [ -f "$PROJECT_DIR/.env" ]; then
while IFS='=' read -r key value; do
# Skip comments and empty lines
[[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
# Trim leading/trailing whitespace from key
key="$(echo "$key" | xargs)"
# Strip surrounding quotes from value if present
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
# Only export valid variable names
if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
export "$key=$value"
fi
done < "$PROJECT_DIR/.env"
fi
# --- Derived vars ---
PG_CONTAINER="${PG_CONTAINER:-changemaker-v2-postgres}"
PG_USER="${V2_POSTGRES_USER:-changemaker}"
PG_DB="${V2_POSTGRES_DB:-changemaker_v2}"
LISTMONK_PG_CONTAINER="${LISTMONK_PG_CONTAINER:-listmonk-db}"
LISTMONK_PG_USER="${LISTMONK_DB_USER:-listmonk}"
LISTMONK_PG_DB="${LISTMONK_DB_NAME:-listmonk}"
UPLOADS_DIR="${PROJECT_DIR}/assets/uploads"
echo "=========================================="
echo " Changemaker Lite V2 — Backup"
echo " ${TIMESTAMP}"
echo "=========================================="
echo ""
# --- Create backup directory ---
mkdir -p "$BACKUP_PATH"
# --- 1. V2 PostgreSQL Dump ---
echo "[1/4] Dumping V2 PostgreSQL (${PG_DB})..."
if docker ps --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then
docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" -d "$PG_DB" --no-owner --no-acl \
| gzip > "${BACKUP_PATH}/v2-postgres.sql.gz"
echo " -> v2-postgres.sql.gz ($(du -h "${BACKUP_PATH}/v2-postgres.sql.gz" | cut -f1))"
else
echo " [WARN] Container ${PG_CONTAINER} not running, skipping V2 DB dump."
fi
# --- 2. Listmonk PostgreSQL Dump (optional) ---
echo "[2/4] Dumping Listmonk PostgreSQL (${LISTMONK_PG_DB})..."
if docker ps --format '{{.Names}}' | grep -q "^${LISTMONK_PG_CONTAINER}$"; then
docker exec "$LISTMONK_PG_CONTAINER" pg_dump -U "$LISTMONK_PG_USER" -d "$LISTMONK_PG_DB" --no-owner --no-acl \
| gzip > "${BACKUP_PATH}/listmonk-postgres.sql.gz"
echo " -> listmonk-postgres.sql.gz ($(du -h "${BACKUP_PATH}/listmonk-postgres.sql.gz" | cut -f1))"
else
echo " [WARN] Container ${LISTMONK_PG_CONTAINER} not running, skipping Listmonk dump."
fi
# --- 3. Uploads Archive ---
echo "[3/4] Archiving uploads..."
if [ -d "$UPLOADS_DIR" ] && [ "$(ls -A "$UPLOADS_DIR" 2>/dev/null)" ]; then
tar -czf "${BACKUP_PATH}/uploads.tar.gz" -C "$(dirname "$UPLOADS_DIR")" "$(basename "$UPLOADS_DIR")"
echo " -> uploads.tar.gz ($(du -h "${BACKUP_PATH}/uploads.tar.gz" | cut -f1))"
else
echo " [INFO] No uploads directory or empty, skipping."
fi
# --- 4. Manifest ---
echo "[4/4] Generating manifest..."
MANIFEST_FILE="${BACKUP_PATH}/manifest.json"
{
echo "{"
echo " \"timestamp\": \"${TIMESTAMP}\","
echo " \"backup_name\": \"${BACKUP_NAME}\","
echo " \"files\": ["
FIRST=true
for f in "${BACKUP_PATH}"/*.{sql.gz,tar.gz}; do
[ -f "$f" ] || continue
SIZE="$(stat --printf='%s' "$f" 2>/dev/null || stat -f '%z' "$f" 2>/dev/null || echo 0)"
SHA256="$(sha256sum "$f" 2>/dev/null | cut -d' ' -f1 || shasum -a 256 "$f" | cut -d' ' -f1)"
BASENAME="$(basename "$f")"
if [ "$FIRST" = true ]; then
FIRST=false
else
echo ","
fi
printf ' {"file": "%s", "size_bytes": %s, "sha256": "%s"}' "$BASENAME" "$SIZE" "$SHA256"
done
echo ""
echo " ],"
echo " \"v2_database\": \"${PG_DB}\","
echo " \"listmonk_database\": \"${LISTMONK_PG_DB}\","
echo " \"retention_days\": ${RETENTION_DAYS}"
echo "}"
} > "$MANIFEST_FILE"
echo " -> manifest.json"
echo ""
# --- Create single archive ---
ARCHIVE_FILE="${BACKUP_DIR}/${BACKUP_NAME}.tar.gz"
tar -czf "$ARCHIVE_FILE" -C "$BACKUP_DIR" "$BACKUP_NAME"
rm -rf "$BACKUP_PATH"
echo "Archive: ${ARCHIVE_FILE} ($(du -h "$ARCHIVE_FILE" | cut -f1))"
echo ""
# --- Optional S3 Upload ---
if [ "$S3_UPLOAD" = true ]; then
S3_BUCKET="${S3_BUCKET:-}"
S3_PREFIX="${S3_PREFIX:-changemaker-backups}"
if [ -z "$S3_BUCKET" ]; then
echo "[WARN] S3_BUCKET not set, skipping S3 upload."
elif ! command -v aws &>/dev/null; then
echo "[WARN] AWS CLI not found, skipping S3 upload."
else
echo "Uploading to s3://${S3_BUCKET}/${S3_PREFIX}/${BACKUP_NAME}.tar.gz..."
aws s3 cp "$ARCHIVE_FILE" "s3://${S3_BUCKET}/${S3_PREFIX}/${BACKUP_NAME}.tar.gz"
echo "S3 upload complete."
fi
echo ""
fi
# --- Retention Cleanup ---
echo "Cleaning up backups older than ${RETENTION_DAYS} days..."
DELETED=0
for old in "${BACKUP_DIR}"/changemaker-v2-backup-*.tar.gz; do
[ -f "$old" ] || continue
if [ "$(find "$old" -mtime +"$RETENTION_DAYS" -print 2>/dev/null)" ]; then
rm -f "$old"
DELETED=$((DELETED + 1))
fi
done
echo " Deleted ${DELETED} old backup(s)."
echo ""
echo "=========================================="
echo " Backup complete!"
echo " Archive: ${ARCHIVE_FILE}"
echo "=========================================="