#!/usr/bin/env bash # /home/spuser/PassKeeper/scripts/restore_db.sh # # Restore a PassKeeper MySQL backup created by backup_db.sh. # # SAFETY FEATURES: # - Requires explicit confirmation before overwriting the live database. # - Creates a pre-restore safety dump of the current DB before touching it. # - Verifies the backup file is a valid gzip before proceeding. # - Stops the PassKeeper service before restore, restarts it after. # - Runs flask db upgrade after restore to ensure the schema is current. # # Usage: # bash scripts/restore_db.sh /path/to/passkeeper_20260101_020000.sql.gz # # Dry-run (verify only, no changes): # DRY_RUN=1 bash scripts/restore_db.sh /path/to/backup.sql.gz set -euo pipefail # ── Configuration ────────────────────────────────────────────────────────────── SCRIPT_DIR="$(dirname "$(realpath "$0")")" PROJECT_DIR="$(realpath "$SCRIPT_DIR/..")" ENV_FILE="$PROJECT_DIR/.env" SERVICE_NAME="${PASSKEEPER_SERVICE:-passkeeper}" BACKUP_DIR="${BACKUP_DIR:-/home/spuser/backups/passkeeper}" LOG_FILE="${BACKUP_DIR}/restore.log" DRY_RUN="${DRY_RUN:-0}" # ── Helpers ──────────────────────────────────────────────────────────────────── log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; } die() { log "ERROR $*"; exit 1; } info() { log "INFO $*"; } # ── Argument check ───────────────────────────────────────────────────────────── [[ $# -lt 1 ]] && die "Usage: $0 " BACKUP_FILE="$(realpath "$1")" [[ -f "$BACKUP_FILE" ]] || die "Backup file not found: $BACKUP_FILE" # ── Load .env ───────────────────────────────────────────────────────────────── [[ -f "$ENV_FILE" ]] || die ".env file not found at $ENV_FILE" set -o allexport # shellcheck disable=SC1090 source "$ENV_FILE" set +o allexport DB_HOST="${MYSQL_HOST:-127.0.0.1}" DB_PORT="${MYSQL_PORT:-3306}" DB_USER="${MYSQL_USER:-passkeeper}" DB_PASS="${MYSQL_PASSWORD}" DB_NAME="${MYSQL_DB:-passkeeper}" mkdir -p "$BACKUP_DIR" info "=== PassKeeper database restore ===" info "Backup file : $BACKUP_FILE" info "Target DB : $DB_NAME @ $DB_HOST:$DB_PORT" info "Service : $SERVICE_NAME" [[ "$DRY_RUN" == "1" ]] && info "MODE : DRY RUN — no changes will be made" # ── Validate backup file ─────────────────────────────────────────────────────── info "Validating backup file integrity…" if ! gzip -t "$BACKUP_FILE" 2>/dev/null; then die "Backup file failed gzip integrity check: $BACKUP_FILE" fi BACKUP_SIZE="$(du -sh "$BACKUP_FILE" | cut -f1)" info "Backup size : $BACKUP_SIZE — gzip OK" # ── Confirmation ─────────────────────────────────────────────────────────────── if [[ "$DRY_RUN" != "1" ]]; then echo "" echo " ⚠️ WARNING: This will OVERWRITE the live database '$DB_NAME'." echo " All current data will be replaced with the contents of:" echo " $BACKUP_FILE" echo "" read -r -p " Type 'yes' to continue: " CONFIRM [[ "$CONFIRM" == "yes" ]] || { info "Restore cancelled by user."; exit 0; } fi [[ "$DRY_RUN" == "1" ]] && { info "Dry run complete — backup file is valid."; exit 0; } # ── Pre-restore safety dump ──────────────────────────────────────────────────── SAFETY_TIMESTAMP="$(date +%Y%m%d_%H%M%S)" SAFETY_FILE="${BACKUP_DIR}/pre_restore_safety_${SAFETY_TIMESTAMP}.sql.gz" info "Creating pre-restore safety dump → $SAFETY_FILE" MYSQL_PWD="$DB_PASS" mysqldump \ --host="$DB_HOST" \ --port="$DB_PORT" \ --user="$DB_USER" \ --single-transaction \ --no-tablespaces \ "$DB_NAME" \ | gzip -9 > "$SAFETY_FILE" \ || die "Safety dump failed — aborting restore. Database is unchanged." info "Safety dump complete: $(du -sh "$SAFETY_FILE" | cut -f1)" # ── Stop service ─────────────────────────────────────────────────────────────── info "Stopping $SERVICE_NAME…" sudo systemctl stop "$SERVICE_NAME" || die "Failed to stop $SERVICE_NAME" info "$SERVICE_NAME stopped" RESTORE_OK=0 # ── Restore ──────────────────────────────────────────────────────────────────── info "Restoring database from backup…" if MYSQL_PWD="$DB_PASS" gunzip -c "$BACKUP_FILE" | mysql \ --host="$DB_HOST" \ --port="$DB_PORT" \ --user="$DB_USER" \ "$DB_NAME"; then info "Database restore complete" RESTORE_OK=1 else log "ERROR Database restore failed — attempting to roll back from safety dump" if MYSQL_PWD="$DB_PASS" gunzip -c "$SAFETY_FILE" | mysql \ --host="$DB_HOST" \ --port="$DB_PORT" \ --user="$DB_USER" \ "$DB_NAME"; then log "WARN Rolled back to pre-restore state from $SAFETY_FILE" else log "ERROR Rollback also failed — database may be in an inconsistent state" log "ERROR Manual recovery required. Safety dump: $SAFETY_FILE" fi fi # ── Run migrations ───────────────────────────────────────────────────────────── if [[ "$RESTORE_OK" == "1" ]]; then info "Running flask db upgrade to ensure schema is current…" cd "$PROJECT_DIR" if source .venv/bin/activate 2>/dev/null; then flask db upgrade && info "flask db upgrade OK" || \ log "WARN flask db upgrade failed — check migrations manually" deactivate 2>/dev/null || true else log "WARN Could not activate .venv — skipping flask db upgrade" log "WARN Run 'flask db upgrade' manually before restarting the service" fi fi # ── Restart service ──────────────────────────────────────────────────────────── info "Starting $SERVICE_NAME…" sudo systemctl start "$SERVICE_NAME" && info "$SERVICE_NAME started" || \ die "$SERVICE_NAME failed to start — check 'journalctl -u $SERVICE_NAME -n 50'" # ── Summary ──────────────────────────────────────────────────────────────────── echo "" if [[ "$RESTORE_OK" == "1" ]]; then info "=== Restore finished successfully ===" info "Restored from : $BACKUP_FILE" info "Safety dump : $SAFETY_FILE (kept for 24 h — delete manually when satisfied)" else die "=== Restore FAILED — see log for details: $LOG_FILE ===" fi