#!/bin/bash # Laravel Timebank Database Restore Script # Restores MySQL database from compressed backup # Usage: ./restore-database.sh [backup_file] [options] # Options: --confirm, --list-backups, --latest set -e # Exit on any error # Configuration SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" BACKUP_ROOT_DIR="$PROJECT_ROOT/backups" LOG_FILE="$BACKUP_ROOT_DIR/restore.log" # Create log directory mkdir -p "$BACKUP_ROOT_DIR/logs" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Logging function log() { local level="$1" local message="$2" local timestamp="[$(date '+%Y-%m-%d %H:%M:%S')]" case "$level" in "INFO") echo -e "${timestamp} ${BLUE}[INFO]${NC} $message" | tee -a "$LOG_FILE" ;; "SUCCESS") echo -e "${timestamp} ${GREEN}[SUCCESS]${NC} $message" | tee -a "$LOG_FILE" ;; "WARNING") echo -e "${timestamp} ${YELLOW}[WARNING]${NC} $message" | tee -a "$LOG_FILE" ;; "ERROR") echo -e "${timestamp} ${RED}[ERROR]${NC} $message" | tee -a "$LOG_FILE" ;; *) echo "$timestamp $message" | tee -a "$LOG_FILE" ;; esac } # Function to show usage show_usage() { echo "Usage: $0 [backup_file] [options]" echo "" echo "Arguments:" echo " backup_file - Path to backup file (relative to backup directory or absolute)" echo "" echo "Options:" echo " --confirm - Skip confirmation prompt" echo " --list-backups - List available backups" echo " --latest - Restore from latest backup" echo " --help - Show this help message" echo "" echo "Examples:" echo " $0 --list-backups # List available backups" echo " $0 --latest # Restore latest backup" echo " $0 database/daily/mydb_daily_20240101.sql.gz # Restore specific backup" echo "" exit 0 } # Function to list available backups list_backups() { log "INFO" "Available database backups:" echo "" if [ ! -d "$BACKUP_ROOT_DIR/database" ]; then log "WARNING" "No backup directory found" return 0 fi local backup_found=false for backup_type in daily weekly monthly; do local backup_dir="$BACKUP_ROOT_DIR/database/$backup_type" if [ -d "$backup_dir" ]; then local backups=($(find "$backup_dir" -name "*.sql.gz" -type f | sort -r)) if [ ${#backups[@]} -gt 0 ]; then echo -e "${BLUE}$backup_type backups:${NC}" for backup in "${backups[@]}"; do local size=$(du -h "$backup" | cut -f1) local date_created=$(date -r "$backup" '+%Y-%m-%d %H:%M:%S') echo " $(basename "$backup") ($size, $date_created)" done echo "" backup_found=true fi fi done if [ "$backup_found" = false ]; then log "WARNING" "No database backups found" fi } # Function to find latest backup get_latest_backup() { local latest_backup="" local latest_time=0 if [ -d "$BACKUP_ROOT_DIR/database" ]; then while IFS= read -r -d '' backup; do local backup_time=$(stat -c %Y "$backup") if [ "$backup_time" -gt "$latest_time" ]; then latest_time=$backup_time latest_backup=$backup fi done < <(find "$BACKUP_ROOT_DIR/database" -name "*.sql.gz" -type f -print0) fi echo "$latest_backup" } # Function to confirm restore confirm_restore() { local backup_file="$1" echo "" echo -e "${YELLOW}WARNING: This will REPLACE the current database!${NC}" echo -e "Database: ${RED}$DB_DATABASE${NC}" echo -e "Backup file: ${BLUE}$(basename "$backup_file")${NC}" echo -e "Backup size: $(du -h "$backup_file" | cut -f1)" echo -e "Backup date: $(date -r "$backup_file" '+%Y-%m-%d %H:%M:%S')" echo "" read -p "Are you sure you want to continue? [y/N]: " -n 1 -r echo "" if [[ ! $REPLY =~ ^[Yy]$ ]]; then log "INFO" "Restore cancelled by user" exit 0 fi } # Function to create database backup before restore create_pre_restore_backup() { log "INFO" "Creating pre-restore backup of current database" local pre_restore_dir="$BACKUP_ROOT_DIR/database/pre-restore" mkdir -p "$pre_restore_dir" local timestamp=$(date '+%Y%m%d_%H%M%S') local backup_file="$pre_restore_dir/${DB_DATABASE}_pre_restore_${timestamp}.sql.gz" # Use the database backup script if available if [ -x "$SCRIPT_DIR/backup-database.sh" ]; then log "INFO" "Using backup script for pre-restore backup" # Create MySQL configuration file for secure password handling local mysql_cnf_file="/tmp/mysql_pre_restore_$$.cnf" cat > "$mysql_cnf_file" < "${backup_file}.err" | gzip > "$backup_file" local dump_status=$? set +o pipefail set -e # Clean up the temporary config file rm -f "$mysql_cnf_file" if [ $dump_status -ne 0 ]; then log "ERROR" "Pre-restore backup failed: $(tr '\n' ' ' < "${backup_file}.err")" rm -f "$backup_file" "${backup_file}.err" return 1 fi rm -f "${backup_file}.err" # A pre-restore backup that is truncated is worse than none: it would # be trusted at exactly the moment it is needed. if ! gzip -dc "$backup_file" | tail -c 200 | grep -q "Dump completed"; then log "ERROR" "Pre-restore backup is truncated - discarding $backup_file" rm -f "$backup_file" return 1 fi if [ -f "$backup_file" ] && [ -s "$backup_file" ]; then log "SUCCESS" "Pre-restore backup created: $backup_file" else log "ERROR" "Pre-restore backup failed" return 1 fi fi } # Function to restore database restore_database() { local backup_file="$1" log "INFO" "Starting database restore from: $(basename "$backup_file")" # Verify backup file exists and is readable if [ ! -f "$backup_file" ] || [ ! -r "$backup_file" ]; then log "ERROR" "Backup file not found or not readable: $backup_file" return 1 fi # Test if backup file is valid gzip if ! gzip -t "$backup_file" 2>/dev/null; then log "ERROR" "Backup file is corrupted or not a valid gzip file" return 1 fi # Extract and restore log "INFO" "Decompressing and restoring backup..." # The dump drops and recreates the database and its tables. The application user may not do that # (no DROP privilege, and UPDATE/DELETE only per table), so restore as the privileged user deploy.sh # uses for migrations: DB_DEPLOY_USERNAME / DB_DEPLOY_PASSWORD from .env, or ask for one. local restore_user="${DB_DEPLOY_USERNAME:-}" local restore_password="${DB_DEPLOY_PASSWORD:-}" if [ -z "$restore_user" ] && [ -t 0 ]; then echo "A MySQL user that may drop and create the database is needed (root or a deployment user)." read -r -p "MySQL username [root]: " restore_user restore_user="${restore_user:-root}" fi if [ -n "$restore_user" ] && [ -z "$restore_password" ] && [ -t 0 ]; then read -r -s -p "MySQL password for $restore_user: " restore_password echo "" fi if [ -z "$restore_user" ]; then log "WARNING" "No DB_DEPLOY_USERNAME and no terminal to ask for one: restoring as $DB_USERNAME, which usually lacks the DROP privilege" restore_user="$DB_USERNAME" restore_password="$DB_PASSWORD" fi log "INFO" "Restoring as MySQL user $restore_user" # Create MySQL configuration file for secure password handling (quoted: passwords may hold # or ;) local mysql_cnf_file="/tmp/mysql_restore_$$.cnf" local quoted_password="${restore_password//\\/\\\\}" quoted_password="${quoted_password//\"/\\\"}" ( umask 077; printf '[mysql]\nhost=%s\nport=%s\nuser=%s\npassword="%s"\n' \ "${DB_HOST:-127.0.0.1}" "${DB_PORT:-3306}" "$restore_user" "$quoted_password" > "$mysql_cnf_file" ) # Set secure permissions on the config file chmod 600 "$mysql_cnf_file" # Perform the restore using the config file if gzip -dc "$backup_file" | mysql --defaults-extra-file="$mysql_cnf_file"; then log "SUCCESS" "Database restore completed successfully" rm -f "$mysql_cnf_file" return 0 else log "ERROR" "Database restore failed" rm -f "$mysql_cnf_file" return 1 fi } # Parse command line arguments BACKUP_FILE="" CONFIRM=false LIST_BACKUPS=false LATEST=false while [[ $# -gt 0 ]]; do case $1 in --confirm) CONFIRM=true shift ;; --list-backups) LIST_BACKUPS=true shift ;; --latest) LATEST=true shift ;; --help) show_usage ;; -*) log "ERROR" "Unknown option: $1" show_usage ;; *) if [ -z "$BACKUP_FILE" ]; then BACKUP_FILE="$1" else log "ERROR" "Multiple backup files specified" show_usage fi shift ;; esac done # Load environment loader source "$SCRIPT_DIR/load-env.sh" # Load environment variables if ! load_env "$PROJECT_ROOT/.env"; then log "ERROR" ".env file not found in $PROJECT_ROOT" exit 1 fi # Validate required environment variables if [ -z "$DB_DATABASE" ] || [ -z "$DB_USERNAME" ] || [ -z "$DB_HOST" ]; then log "ERROR" "Required database environment variables not found (DB_DATABASE, DB_USERNAME, DB_HOST)" exit 1 fi # Handle list backups option if [ "$LIST_BACKUPS" = true ]; then list_backups exit 0 fi # Handle latest backup option if [ "$LATEST" = true ]; then BACKUP_FILE=$(get_latest_backup) if [ -z "$BACKUP_FILE" ]; then log "ERROR" "No backups found" exit 1 fi log "INFO" "Using latest backup: $(basename "$BACKUP_FILE")" fi # Validate backup file argument if [ -z "$BACKUP_FILE" ]; then log "ERROR" "No backup file specified" show_usage fi # Resolve backup file path if [[ "$BACKUP_FILE" != /* ]]; then # Relative path - check if it exists relative to backup directory if [ -f "$BACKUP_ROOT_DIR/$BACKUP_FILE" ]; then BACKUP_FILE="$BACKUP_ROOT_DIR/$BACKUP_FILE" elif [ -f "$BACKUP_ROOT_DIR/database/daily/$BACKUP_FILE" ]; then BACKUP_FILE="$BACKUP_ROOT_DIR/database/daily/$BACKUP_FILE" elif [ -f "$BACKUP_ROOT_DIR/database/weekly/$BACKUP_FILE" ]; then BACKUP_FILE="$BACKUP_ROOT_DIR/database/weekly/$BACKUP_FILE" elif [ -f "$BACKUP_ROOT_DIR/database/monthly/$BACKUP_FILE" ]; then BACKUP_FILE="$BACKUP_ROOT_DIR/database/monthly/$BACKUP_FILE" elif [ -f "$BACKUP_FILE" ]; then # Exists relative to current directory BACKUP_FILE="$(realpath "$BACKUP_FILE")" else log "ERROR" "Database backup file not found: $BACKUP_FILE" log "INFO" "Checked locations:" log "INFO" " $BACKUP_ROOT_DIR/$BACKUP_FILE" log "INFO" " $BACKUP_ROOT_DIR/database/daily/$BACKUP_FILE" log "INFO" " $BACKUP_ROOT_DIR/database/weekly/$BACKUP_FILE" log "INFO" " $BACKUP_ROOT_DIR/database/monthly/$BACKUP_FILE" log "INFO" " ./$BACKUP_FILE" exit 1 fi fi # Main execution main() { log "INFO" "============================================" log "INFO" "Starting database restore process" log "INFO" "Time: $(date)" log "INFO" "============================================" local start_time=$(date +%s) # Confirm restore unless --confirm was specified if [ "$CONFIRM" = false ]; then confirm_restore "$BACKUP_FILE" fi # Create pre-restore backup if ! create_pre_restore_backup; then log "WARNING" "Pre-restore backup failed, but continuing with restore" fi # Perform restore if restore_database "$BACKUP_FILE"; then local end_time=$(date +%s) local execution_time=$((end_time - start_time)) local execution_time_formatted=$(date -d@$execution_time -u +%H:%M:%S) log "SUCCESS" "Database restore completed successfully in $execution_time_formatted" log "INFO" "Restored from: $(basename "$BACKUP_FILE")" # Send notification if command -v mail >/dev/null 2>&1; then echo "Database restore completed successfully at $(date). Restored from: $(basename "$BACKUP_FILE")" | mail -s "Timebank DB Restore Success" "${BACKUP_NOTIFY_EMAIL:-$USER@localhost}" 2>/dev/null || true fi # Recommend running Laravel commands echo "" log "INFO" "Recommended post-restore steps:" log "INFO" " 1. php artisan migrate:status" log "INFO" " 2. php artisan config:clear" log "INFO" " 3. php artisan cache:clear" log "INFO" " 4. Test application functionality" else log "ERROR" "Database restore failed" exit 1 fi log "INFO" "============================================" log "INFO" "Restore process finished" log "INFO" "============================================" } # Run main function main