#!/usr/bin/env bash # ============================================================================= # Changemaker Lite V2 — Restore Script # Restores from a backup archive created by backup.sh. # Usage: ./scripts/restore.sh --archive PATH [--skip-db] [--skip-uploads] # [--skip-listmonk] [--dry-run] [--force] # ============================================================================= set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # --- Colors --- RED='\033[0;31m' YELLOW='\033[1;33m' GREEN='\033[0;32m' CYAN='\033[0;36m' NC='\033[0m' error() { echo -e "${RED}ERROR:${NC} $1"; } warn() { echo -e "${YELLOW}WARN:${NC} $1"; } info() { echo -e "${CYAN}INFO:${NC} $1"; } ok() { echo -e "${GREEN}OK:${NC} $1"; } # --- Defaults --- ARCHIVE="" SKIP_DB=false SKIP_UPLOADS=false SKIP_LISTMONK=false DRY_RUN=false FORCE=false # --- Parse args --- while [[ $# -gt 0 ]]; do case "$1" in --archive) ARCHIVE="$2"; shift 2 ;; --skip-db) SKIP_DB=true; shift ;; --skip-uploads) SKIP_UPLOADS=true; shift ;; --skip-listmonk) SKIP_LISTMONK=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --force) FORCE=true; shift ;; --help) echo "Usage: $0 --archive PATH [OPTIONS]" echo "" echo "Options:" echo " --archive PATH Path to backup .tar.gz archive (required)" echo " --skip-db Skip main PostgreSQL restore" echo " --skip-uploads Skip uploads directory restore" echo " --skip-listmonk Skip Listmonk database restore" echo " --dry-run Validate archive without restoring" echo " --force Skip confirmation prompt" echo "" exit 0 ;; *) error "Unknown option: $1"; exit 1 ;; esac done if [[ -z "$ARCHIVE" ]]; then error "Missing --archive PATH argument" echo " Usage: $0 --archive /path/to/backup.tar.gz" exit 1 fi if [[ ! -f "$ARCHIVE" ]]; then error "Archive not found: $ARCHIVE" exit 1 fi # --- Load .env --- if [ -f "$PROJECT_DIR/.env" ]; then while IFS='=' read -r key value; do [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue key="$(echo "$key" | xargs)" value="${value%\"}" ; value="${value#\"}" value="${value%\'}" ; value="${value#\'}" 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" APP_CONTAINERS="changemaker-v2-api changemaker-v2-admin changemaker-media-api changemaker-v2-nginx" echo "" echo "==========================================" echo " Changemaker Lite V2 — Restore" echo "==========================================" echo "" # --- 1. Extract and validate archive --- info "Extracting archive: $(basename "$ARCHIVE")" TEMP_DIR="$(mktemp -d)" trap 'rm -rf "$TEMP_DIR"' EXIT tar -xzf "$ARCHIVE" -C "$TEMP_DIR" # Find the extracted backup directory (single child of temp dir) BACKUP_DIR="$(find "$TEMP_DIR" -mindepth 1 -maxdepth 1 -type d | head -1)" if [[ -z "$BACKUP_DIR" ]]; then error "Archive does not contain a backup directory" exit 1 fi # Validate manifest MANIFEST="${BACKUP_DIR}/manifest.json" if [[ ! -f "$MANIFEST" ]]; then error "manifest.json not found in archive" exit 1 fi ok "Manifest found" echo " Backup: $(python3 -c "import json,sys; m=json.load(open(sys.argv[1])); print(m.get('backup_name','unknown'))" "$MANIFEST" 2>/dev/null || basename "$BACKUP_DIR")" # --- 2. Verify checksums --- info "Verifying file integrity..." INTEGRITY_OK=true while IFS= read -r entry; do FILE=$(echo "$entry" | python3 -c "import json,sys; print(json.load(sys.stdin)['file'])") EXPECTED=$(echo "$entry" | python3 -c "import json,sys; print(json.load(sys.stdin)['sha256'])") FILE_PATH="${BACKUP_DIR}/${FILE}" if [[ ! -f "$FILE_PATH" ]]; then warn "Missing file: $FILE" continue fi ACTUAL="$(sha256sum "$FILE_PATH" 2>/dev/null | cut -d' ' -f1 || shasum -a 256 "$FILE_PATH" | cut -d' ' -f1)" if [[ "$EXPECTED" != "$ACTUAL" ]]; then error "Checksum mismatch: $FILE" INTEGRITY_OK=false else ok " $FILE checksum verified" fi done < <(python3 -c "import json,sys; [print(json.dumps(f)) for f in json.load(open(sys.argv[1]))['files']]" "$MANIFEST") if ! $INTEGRITY_OK; then error "Archive integrity check failed. Aborting." exit 1 fi # --- Show what will be restored --- echo "" info "Components to restore:" [[ -f "${BACKUP_DIR}/v2-postgres.sql.gz" ]] && ! $SKIP_DB && echo " - V2 PostgreSQL (${PG_DB})" [[ -f "${BACKUP_DIR}/gancio-postgres.sql.gz" ]] && ! $SKIP_DB && echo " - Gancio PostgreSQL" [[ -f "${BACKUP_DIR}/listmonk-postgres.sql.gz" ]] && ! $SKIP_LISTMONK && echo " - Listmonk PostgreSQL (${LISTMONK_PG_DB})" [[ -f "${BACKUP_DIR}/uploads.tar.gz" ]] && ! $SKIP_UPLOADS && echo " - Uploads directory" echo "" if $DRY_RUN; then ok "Dry run complete. Archive is valid." exit 0 fi # --- Confirmation --- if ! $FORCE; then echo -e "${RED}WARNING: This will OVERWRITE existing databases and uploads!${NC}" read -p "Continue with restore? (y/N): " CONFIRM if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then echo "Aborted." exit 0 fi fi # --- 3. Stop application containers --- info "Stopping application containers..." for container in $APP_CONTAINERS; do if docker ps --format '{{.Names}}' | grep -q "^${container}$"; then docker stop "$container" >/dev/null 2>&1 && echo " Stopped $container" || true fi done echo "" # --- 4. Restore V2 PostgreSQL --- if [[ -f "${BACKUP_DIR}/v2-postgres.sql.gz" ]] && ! $SKIP_DB; then info "Restoring V2 PostgreSQL (${PG_DB})..." if docker ps --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then # Terminate existing connections and recreate database docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${PG_DB}' AND pid <> pg_backend_pid();" >/dev/null 2>&1 || true docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \ "DROP DATABASE IF EXISTS \"${PG_DB}\";" >/dev/null 2>&1 docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \ "CREATE DATABASE \"${PG_DB}\";" >/dev/null 2>&1 # Restore dump gunzip -c "${BACKUP_DIR}/v2-postgres.sql.gz" | docker exec -i "$PG_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" >/dev/null 2>&1 ok "V2 PostgreSQL restored" else error "Container ${PG_CONTAINER} not running" fi fi # --- 4b. Restore Gancio PostgreSQL --- if [[ -f "${BACKUP_DIR}/gancio-postgres.sql.gz" ]] && ! $SKIP_DB; then info "Restoring Gancio PostgreSQL..." if docker ps --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='gancio' AND pid <> pg_backend_pid();" >/dev/null 2>&1 || true docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \ "DROP DATABASE IF EXISTS gancio;" >/dev/null 2>&1 docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \ "CREATE DATABASE gancio;" >/dev/null 2>&1 gunzip -c "${BACKUP_DIR}/gancio-postgres.sql.gz" | docker exec -i "$PG_CONTAINER" psql -U "$PG_USER" -d gancio >/dev/null 2>&1 ok "Gancio PostgreSQL restored" else warn "V2 PostgreSQL container not running, skipping Gancio restore" fi fi # --- 5. Restore Listmonk PostgreSQL --- if [[ -f "${BACKUP_DIR}/listmonk-postgres.sql.gz" ]] && ! $SKIP_LISTMONK; then info "Restoring Listmonk PostgreSQL (${LISTMONK_PG_DB})..." if docker ps --format '{{.Names}}' | grep -q "^${LISTMONK_PG_CONTAINER}$"; then docker exec "$LISTMONK_PG_CONTAINER" psql -U "$LISTMONK_PG_USER" -d postgres -c \ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${LISTMONK_PG_DB}' AND pid <> pg_backend_pid();" >/dev/null 2>&1 || true docker exec "$LISTMONK_PG_CONTAINER" psql -U "$LISTMONK_PG_USER" -d postgres -c \ "DROP DATABASE IF EXISTS \"${LISTMONK_PG_DB}\";" >/dev/null 2>&1 docker exec "$LISTMONK_PG_CONTAINER" psql -U "$LISTMONK_PG_USER" -d postgres -c \ "CREATE DATABASE \"${LISTMONK_PG_DB}\";" >/dev/null 2>&1 gunzip -c "${BACKUP_DIR}/listmonk-postgres.sql.gz" | docker exec -i "$LISTMONK_PG_CONTAINER" psql -U "$LISTMONK_PG_USER" -d "$LISTMONK_PG_DB" >/dev/null 2>&1 ok "Listmonk PostgreSQL restored" else warn "Container ${LISTMONK_PG_CONTAINER} not running, skipping" fi fi # --- 6. Restore uploads --- if [[ -f "${BACKUP_DIR}/uploads.tar.gz" ]] && ! $SKIP_UPLOADS; then info "Restoring uploads..." UPLOADS_PARENT="$(dirname "$UPLOADS_DIR")" mkdir -p "$UPLOADS_PARENT" tar -xzf "${BACKUP_DIR}/uploads.tar.gz" -C "$UPLOADS_PARENT" ok "Uploads restored to $UPLOADS_DIR" fi echo "" # --- 7. Run migrations (catch up if code is ahead of backup) --- info "Running Prisma migrations..." cd "$PROJECT_DIR" docker compose run --rm --no-deps --entrypoint "" api npx prisma migrate deploy 2>&1 \ && ok "Migrations applied" \ || warn "Migration apply had warnings" # --- 8. Restart application containers --- info "Restarting application containers..." docker compose up -d 2>&1 | tail -5 echo "" # --- 9. Health check --- info "Waiting for API health check..." HEALTHY=false for i in $(seq 1 20); do if docker compose exec -T api wget -q --spider http://localhost:4000/api/health 2>/dev/null; then HEALTHY=true break fi sleep 3 done if $HEALTHY; then ok "API is healthy" else warn "API health check timed out (60s). Check logs: docker compose logs api" fi echo "" echo "==========================================" echo -e " ${GREEN}Restore complete!${NC}" echo " Archive: $(basename "$ARCHIVE")" echo "=========================================="