#!/bin/bash # Database Rollback Script # Usage: ./rollback-database.sh [environment] # Environments: Development, SandboxRemote, ProductionRemote, Oda set -e # Exit on any error ENVIRONMENT=${1:-"Development"} # Default to Development for safer initial testing TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_DIR_NAME="backups" # Just the directory name LOGS_DIR_NAME="logs" # Just the directory name # Get the directory where the script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" # Create logs directory first (before LOG_FILE is used) LOGS_DIR="$SCRIPT_DIR/$LOGS_DIR_NAME" mkdir -p "$LOGS_DIR" || { echo "Failed to create logs directory: $LOGS_DIR"; exit 1; } LOG_FILE="$SCRIPT_DIR/logs/rollback_${ENVIRONMENT}_${TIMESTAMP}.log" # 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() { echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')] $1${NC}" | tee -a "$LOG_FILE" } warn() { echo -e "${YELLOW}[$(date +'%Y-%m-%d %H:%M:%S')] WARNING: $1${NC}" | tee -a "$LOG_FILE" } error() { echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $1${NC}" | tee -a "$LOG_FILE" exit 1 } info() { echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')] INFO: $1${NC}" | tee -a "$LOG_FILE" } # --- Determine Base Paths --- # Get the directory where the script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" log "Script is located in: $SCRIPT_DIR" # Define absolute paths for projects and common directories relative to the script # Assuming the project structure is: # your_repo/ # ├── scripts/rollback-database.sh # └── src/ # ├── Managing.Api/ # └── Managing.Docker/ PROJECT_ROOT_DIR="$(dirname "$SCRIPT_DIR")" # One level up from scripts/ SRC_DIR="$PROJECT_ROOT_DIR/src" API_PROJECT_PATH="$SRC_DIR/Managing.Api" DOCKER_DIR="$SRC_DIR/Managing.Docker" # Adjust if your docker-compose files are elsewhere # Define absolute path for backup directory with environment subfolder BACKUP_DIR="$SCRIPT_DIR/$BACKUP_DIR_NAME/$ENVIRONMENT" # --- Pre-checks and Setup --- info "Pre-flight checks..." command -v dotnet >/dev/null 2>&1 || error ".NET SDK is not installed. Please install .NET SDK to run this script." command -v docker >/dev/null 2>&1 || warn "Docker is not installed. This is fine if not running Development or Oda environment with Docker." command -v psql >/dev/null 2>&1 || error "PostgreSQL CLI (psql) is required for database rollback. Please install PostgreSQL client tools." command -v pg_restore >/dev/null 2>&1 || warn "pg_restore not available. Will use psql for SQL script restoration." # Create backup directory (with environment subfolder) - for storing rollback logs mkdir -p "$BACKUP_DIR" || error "Failed to create backup directory: $BACKUP_DIR" log "Backup directory created/verified: $BACKUP_DIR" log "🔄 Starting database rollback for environment: $ENVIRONMENT" # Validate environment case $ENVIRONMENT in "Development"|"SandboxRemote"|"ProductionRemote"|"Oda") log "✅ Environment '$ENVIRONMENT' is valid" ;; *) error "❌ Invalid environment '$ENVIRONMENT'. Use: Development, SandboxRemote, ProductionRemote, or Oda" ;; esac # Helper function to start PostgreSQL for Development (if still using Docker Compose) start_postgres_if_needed() { if [ "$ENVIRONMENT" = "Development" ] || [ "$ENVIRONMENT" = "Oda" ]; then # Assuming Oda also uses local Docker log "🔍 Checking if PostgreSQL is running for $ENVIRONMENT..." if ! docker ps --filter "name=postgres" --format "{{.Names}}" | grep -q "postgres"; then log "🐳 Starting PostgreSQL container for $ENVIRONMENT from $DOCKER_DIR..." # Execute docker-compose from the DOCKER_DIR (cd "$DOCKER_DIR" && docker-compose -f docker-compose.yml -f docker-compose.local.yml up -d postgres) || error "Failed to start PostgreSQL container." log "⏳ Waiting for PostgreSQL to be ready (15 seconds)..." sleep 15 else log "✅ PostgreSQL container is already running." fi fi } # Helper function to extract connection details from appsettings extract_connection_details() { local appsettings_file="$API_PROJECT_PATH/appsettings.$ENVIRONMENT.json" local default_appsettings="$API_PROJECT_PATH/appsettings.json" # Try environment-specific file first, then default if [ -f "$appsettings_file" ]; then log "📋 Reading connection string from: appsettings.$ENVIRONMENT.json" # Look for PostgreSql.ConnectionString first, then fallback to ConnectionString CONNECTION_STRING=$(grep -A 3 '"PostgreSql"' "$appsettings_file" | grep -o '"ConnectionString": *"[^"]*"' | cut -d'"' -f4) if [ -z "$CONNECTION_STRING" ]; then CONNECTION_STRING=$(grep -o '"ConnectionString": *"[^"]*"' "$appsettings_file" | cut -d'"' -f4) fi elif [ -f "$default_appsettings" ]; then log "📋 Reading connection string from: appsettings.json (default)" # Look for PostgreSql.ConnectionString first, then fallback to ConnectionString CONNECTION_STRING=$(grep -A 3 '"PostgreSql"' "$default_appsettings" | grep -o '"ConnectionString": *"[^"]*"' | cut -d'"' -f4) if [ -z "$CONNECTION_STRING" ]; then CONNECTION_STRING=$(grep -o '"ConnectionString": *"[^"]*"' "$default_appsettings" | cut -d'"' -f4) fi else warn "⚠️ Could not find appsettings file for environment $ENVIRONMENT" return 1 fi if [ -z "$CONNECTION_STRING" ]; then error "❌ Could not extract connection string from appsettings file" return 1 fi log "📋 Found connection string: $CONNECTION_STRING" # Parse connection string DB_HOST=$(echo "$CONNECTION_STRING" | grep -o 'Host=[^;]*' | cut -d'=' -f2) DB_PORT=$(echo "$CONNECTION_STRING" | grep -o 'Port=[^;]*' | cut -d'=' -f2) DB_NAME=$(echo "$CONNECTION_STRING" | grep -o 'Database=[^;]*' | cut -d'=' -f2) DB_USER=$(echo "$CONNECTION_STRING" | grep -o 'Username=[^;]*' | cut -d'=' -f2) DB_PASSWORD=$(echo "$CONNECTION_STRING" | grep -o 'Password=[^;]*' | cut -d'=' -f2) # Set defaults if not found DB_HOST=${DB_HOST:-"localhost"} DB_PORT=${DB_PORT:-"5432"} DB_NAME=${DB_NAME:-"postgres"} DB_USER=${DB_USER:-"postgres"} DB_PASSWORD=${DB_PASSWORD:-"postgres"} log "📋 Extracted connection details: $DB_HOST:$DB_PORT/$DB_NAME (user: $DB_USER)" } # Helper function to test PostgreSQL connectivity test_postgres_connectivity() { log "🔍 Testing PostgreSQL connectivity with psql..." # Test basic connectivity if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "SELECT version();" >/dev/null 2>&1; then log "✅ PostgreSQL connectivity test passed" # Get database info log "📊 Database Information:" DB_INFO=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c " SELECT 'Database: ' || current_database() || ' (Size: ' || pg_size_pretty(pg_database_size(current_database())) || ')', 'PostgreSQL Version: ' || version(), 'Connection: ' || inet_server_addr() || ':' || inet_server_port() " 2>/dev/null | tr '\n' ' ') log " $DB_INFO" return 0 else error "❌ PostgreSQL connectivity test failed" error " Host: $DB_HOST, Port: $DB_PORT, Database: $DB_NAME, User: $DB_USER" return 1 fi } # --- Core Logic --- # Set ASPNETCORE_ENVIRONMENT to load the correct appsettings export ASPNETCORE_ENVIRONMENT="$ENVIRONMENT" log "ASPNETCORE_ENVIRONMENT set to: $ASPNETCORE_ENVIRONMENT" # If Development or Oda, start local PostgreSQL start_postgres_if_needed # Extract connection details from appsettings extract_connection_details # Step 1: Check Database Connection log "🔧 Step 1: Checking database connection..." # Test connectivity test_postgres_connectivity # Step 2: Find and list available backups log "🔍 Step 2: Finding available backups..." # Look for backup files in the environment-specific backup directory BACKUP_FILES=$(ls -t "$BACKUP_DIR"/managing_${ENVIRONMENT}_backup_*.sql 2>/dev/null || true) if [ -z "$BACKUP_FILES" ]; then error "❌ No backup files found for environment '$ENVIRONMENT'" error " Expected backup files in: $BACKUP_DIR/managing_${ENVIRONMENT}_backup_*.sql" error " Please ensure backups exist before attempting rollback." error " You can create a backup using: ./apply-migrations.sh $ENVIRONMENT" fi # Get the last 5 backups (most recent first) RECENT_BACKUPS=$(echo "$BACKUP_FILES" | head -5) BACKUP_COUNT=$(echo "$RECENT_BACKUPS" | wc -l | tr -d ' ') log "✅ Found $BACKUP_COUNT backup(s) for environment '$ENVIRONMENT'" # Display available backups echo "" echo "==========================================" echo "📋 AVAILABLE BACKUPS FOR $ENVIRONMENT" echo "==========================================" echo "Last 5 backups (most recent first):" echo "" BACKUP_ARRAY=() INDEX=1 echo "$RECENT_BACKUPS" | while read -r backup_file; do if [ -f "$backup_file" ]; then BACKUP_FILENAME=$(basename "$backup_file") BACKUP_SIZE=$(ls -lh "$backup_file" | awk '{print $5}') BACKUP_LINES=$(wc -l < "$backup_file") BACKUP_TIMESTAMP=$(echo "$BACKUP_FILENAME" | sed "s/managing_${ENVIRONMENT}_backup_\(.*\)\.sql/\1/") BACKUP_DATE=$(date -r "$backup_file" "+%Y-%m-%d %H:%M:%S") echo "[$INDEX] $BACKUP_FILENAME" echo " Date: $BACKUP_DATE" echo " Size: $BACKUP_SIZE" echo " Lines: $BACKUP_LINES" echo "" BACKUP_ARRAY+=("$backup_file") ((INDEX++)) fi done echo "==========================================" echo "" # Let user choose which backup to use read -p "🔄 Enter the number of the backup to rollback to (1-$BACKUP_COUNT, or 'cancel' to abort): " user_choice if [ "$user_choice" = "cancel" ]; then log "❌ Rollback cancelled by user." exit 0 fi # Validate user choice if ! [[ "$user_choice" =~ ^[0-9]+$ ]] || [ "$user_choice" -lt 1 ] || [ "$user_choice" -gt "$BACKUP_COUNT" ]; then error "❌ Invalid choice '$user_choice'. Please enter a number between 1 and $BACKUP_COUNT, or 'cancel' to abort." fi # Get the selected backup file SELECTED_BACKUP=$(echo "$RECENT_BACKUPS" | sed -n "${user_choice}p") BACKUP_FILENAME=$(basename "$SELECTED_BACKUP") BACKUP_TIMESTAMP=$(echo "$BACKUP_FILENAME" | sed "s/managing_${ENVIRONMENT}_backup_\(.*\)\.sql/\1/") log "✅ Selected backup: $BACKUP_FILENAME" log " Location: $SELECTED_BACKUP" log " Timestamp: $BACKUP_TIMESTAMP" # Get backup file info if [ -f "$SELECTED_BACKUP" ]; then BACKUP_SIZE=$(ls -lh "$SELECTED_BACKUP" | awk '{print $5}') BACKUP_LINES=$(wc -l < "$SELECTED_BACKUP") log "📄 Selected backup file details:" log " Size: $BACKUP_SIZE" log " Lines: $BACKUP_LINES" else error "❌ Selected backup file does not exist or is not readable: $SELECTED_BACKUP" fi # Step 3: Show backup preview and get user confirmation echo "" echo "==========================================" echo "🔄 DATABASE ROLLBACK CONFIRMATION" echo "==========================================" echo "Environment: $ENVIRONMENT" echo "Database: $DB_HOST:$DB_PORT/$DB_NAME" echo "Selected Backup: $BACKUP_FILENAME" echo "Backup Size: $BACKUP_SIZE" echo "Backup Lines: $BACKUP_LINES" echo "" echo "⚠️ WARNING: This will DROP and RECREATE the database!" echo " All current data will be lost and replaced with the backup." echo " This action cannot be undone!" echo "" echo "📋 BACKUP PREVIEW (first 20 lines):" echo "----------------------------------------" head -20 "$SELECTED_BACKUP" | sed 's/^/ /' if [ "$BACKUP_LINES" -gt 20 ]; then echo " ... (showing first 20 lines of $BACKUP_LINES total)" fi echo "----------------------------------------" echo "" read -p "🔄 Are you sure you want to rollback to this backup? Type 'yes' to proceed: " user_confirmation if [ "$user_confirmation" != "yes" ]; then log "❌ Rollback cancelled by user." exit 0 fi log "✅ User confirmed rollback. Proceeding with database restoration..." # Step 4: Create a final backup before rollback (safety measure) log "📦 Step 4: Creating final backup before rollback..." FINAL_BACKUP_FILE="$BACKUP_DIR/managing_${ENVIRONMENT}_pre_rollback_backup_${TIMESTAMP}.sql" log "Creating final backup of current state..." if PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" --no-password --verbose --clean --if-exists --create --format=plain > "$FINAL_BACKUP_FILE" 2>/dev/null; then log "✅ Pre-rollback backup created: $(basename "$FINAL_BACKUP_FILE")" else warn "⚠️ Failed to create pre-rollback backup. Proceeding anyway..." fi # Step 5: Perform the rollback log "🔄 Step 5: Performing database rollback..." # Terminate active connections to the database (except our own) log "🔌 Terminating active connections to database '$DB_NAME'..." TERMINATE_QUERY=" SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid(); " if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "postgres" -c "$TERMINATE_QUERY" >/dev/null 2>&1; then log "✅ Active connections terminated" else warn "⚠️ Could not terminate active connections. This may cause issues." fi # Drop and recreate the database log "💥 Dropping and recreating database '$DB_NAME'..." if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "postgres" -c "DROP DATABASE IF EXISTS \"$DB_NAME\";" >/dev/null 2>&1; then log "✅ Database '$DB_NAME' dropped successfully" else error "❌ Failed to drop database '$DB_NAME'" fi if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "postgres" -c "CREATE DATABASE \"$DB_NAME\";" >/dev/null 2>&1; then log "✅ Database '$DB_NAME' created successfully" else error "❌ Failed to create database '$DB_NAME'" fi # Restore from backup log "📥 Restoring database from backup..." if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$SELECTED_BACKUP" >/dev/null 2>&1; then log "✅ Database successfully restored from backup" else ERROR_OUTPUT=$( (PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$SELECTED_BACKUP") 2>&1 || true ) error "❌ Failed to restore database from backup" error " PSQL Output: $ERROR_OUTPUT" error " Backup file: $SELECTED_BACKUP" error " Pre-rollback backup available at: $(basename "$FINAL_BACKUP_FILE")" fi # Step 6: Verify rollback log "🔍 Step 6: Verifying rollback..." # Test connectivity after restore if test_postgres_connectivity; then log "✅ Database connectivity verified after rollback" # Get basic database stats TABLE_COUNT=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public';" 2>/dev/null | tr -d ' ' || echo "0") log "📊 Post-rollback database stats:" log " Tables: $TABLE_COUNT" if [ "$TABLE_COUNT" -gt 0 ]; then log " Sample tables:" SAMPLE_TABLES=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c " SELECT tablename FROM information_schema.tables WHERE table_schema = 'public' ORDER BY tablename LIMIT 5; " 2>/dev/null | sed 's/^/ /') echo "$SAMPLE_TABLES" fi else error "❌ Database connectivity test failed after rollback" error " The rollback may have completed but the database is not accessible." error " Pre-rollback backup available at: $(basename "$FINAL_BACKUP_FILE")" fi # --- Step 7: Cleanup old backups (keep only 5 rollbacks max) --- log "🧹 Step 7: Cleaning up old rollback backups..." # Keep only the last 5 pre-rollback backups for this environment ls -t "$BACKUP_DIR"/managing_${ENVIRONMENT}_pre_rollback_backup_*.sql 2>/dev/null | tail -n +6 | xargs -r rm -f || true log "✅ Kept last 5 pre-rollback backups for $ENVIRONMENT environment in $BACKUP_DIR_NAME/$ENVIRONMENT/" # Success Summary log "🎉 Database rollback completed successfully for environment: $ENVIRONMENT!" log "📁 Restored from backup: $BACKUP_FILENAME" if [ -f "$FINAL_BACKUP_FILE" ]; then log "📁 Pre-rollback backup: $(basename "$FINAL_BACKUP_FILE")" fi log "📝 Full Log file: $LOG_FILE" echo "" echo "==========================================" echo "📋 ROLLBACK SUMMARY" echo "==========================================" echo "Environment: $ENVIRONMENT" echo "Timestamp: $TIMESTAMP" echo "Status: ✅ SUCCESS" echo "Restored from: $BACKUP_FILENAME" if [ -f "$FINAL_BACKUP_FILE" ]; then echo "Pre-rollback backup: $(basename "$FINAL_BACKUP_FILE")" fi echo "Database: $DB_HOST:$DB_PORT/$DB_NAME" echo "Log: $LOG_FILE" echo "=========================================="