#!/bin/bash ################################################################################ # MySQL/MariaDB File-Based Restore to SQL Dump ################################################################################ # Purpose: Convert restored MySQL/MariaDB data files to usable .sql dumps # Use Case: Restore InnoDB databases from file-based backups (Acronis, etc.) # # Features: # - Multi-control panel support (cPanel, Plesk, InterWorx, standalone) # - Interactive guided workflow # - Validates MySQL data directory structure # - Starts second MySQL instance for safe extraction # - Creates SQL dumps from restored files # - Handles InnoDB system tablespace requirements # - Optional force-recovery mode for corrupted databases ################################################################################ # Path resolution (modules/backup/script.sh → ../../) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "$SCRIPT_DIR/lib/common-functions.sh" source "$SCRIPT_DIR/lib/system-detect.sh" # Root check if [ "$EUID" -ne 0 ]; then echo "" print_error "PERMISSION DENIED: This script must be run as root" echo "" echo "Why root is required:" echo " - Read access to live MySQL data directory (/var/lib/mysql)" echo " - Create directories in /home (for temporary restore location)" echo " - Change file ownership to mysql:mysql" echo " - Start MySQL daemon (mysqld) process" echo " - Access system configuration files" echo "" echo "To run this script:" echo " sudo $0 $*" echo "" exit 1 fi # Detect control panel for proper directory paths # This sets SYS_USER_HOME_BASE which determines restore directory location detect_control_panel || true ################################################################################ # GLOBAL VARIABLES ################################################################################ RESTORE_DIR="" DATABASE_NAME="" LIVE_DATADIR="" TEMP_DATADIR="" TICKET_NUMBER="" FORCE_RECOVERY="" MYSQL_VERSION="" MYSQL_VARIANT="" # mysql or mariadb SECOND_INSTANCE_RUNNING=0 # Track if second instance is running # Cleanup trap for interruption/exit cleanup_on_exit() { if [ "$SECOND_INSTANCE_RUNNING" -eq 1 ] && [ -n "$TEMP_DATADIR" ]; then echo "" print_warning "Script interrupted - cleaning up second MySQL instance..." if [ -S "$TEMP_DATADIR/socket.mysql" ]; then # Graceful shutdown with validation mysqladmin -h localhost -S "$TEMP_DATADIR/socket.mysql" shutdown 2>/dev/null || true # Wait for socket to disappear (max 5 seconds in cleanup) local cleanup_wait=0 while [ -S "$TEMP_DATADIR/socket.mysql" ] && [ "$cleanup_wait" -lt 5 ]; do sleep 1 cleanup_wait=$((cleanup_wait + 1)) done # Force cleanup if socket still exists if [ -S "$TEMP_DATADIR/socket.mysql" ]; then # Get PID and force kill if [ -f "$TEMP_DATADIR/mysql.pid" ]; then kill -9 $(cat "$TEMP_DATADIR/mysql.pid" 2>/dev/null) 2>/dev/null || true fi rm -f "$TEMP_DATADIR/socket.mysql" "$TEMP_DATADIR/mysql.lock" 2>/dev/null || true fi # Clean up PID file if it still exists (BUG FIX: stale PID cleanup) rm -f "$TEMP_DATADIR/mysql.pid" 2>/dev/null || true # Clean up error log backups to prevent accumulation (BUG FIX: mysql.err.old cleanup) rm -f "$TEMP_DATADIR/mysql.err.old" 2>/dev/null || true print_success "Second instance cleaned up" fi fi } # Set trap for signals trap cleanup_on_exit EXIT INT TERM ################################################################################ # DEPENDENCY CHECKING ################################################################################ # Verify all required binaries exist before proceeding # Returns 1 if any critical dependency is missing check_dependencies() { local missing_deps=0 local missing_list="" # Critical binaries required for script operation local required_binaries=( "mysqld:MySQL server daemon (required to start second instance)" "mysql:MySQL client (required for database queries)" "mysqldump:MySQL backup tool (required to create SQL dump)" "mysqladmin:MySQL admin tool (required for shutdown)" ) print_info "Verifying required dependencies..." for bin_info in "${required_binaries[@]}"; do local bin="${bin_info%:*}" local description="${bin_info#*:}" # Try to find the binary if ! command -v "$bin" &> /dev/null; then print_error " Missing: $bin - $description" missing_deps=$((missing_deps + 1)) missing_list="$missing_list - $bin\n" else print_success " Found: $bin" fi done if [ "$missing_deps" -gt 0 ]; then echo "" print_error "MISSING $missing_deps REQUIRED DEPENDENCY/IES" echo "" echo "Please install the following packages:" echo -e "$missing_list" echo "" echo "On CentOS/RHEL: yum install mysql mysql-server" echo "On Debian/Ubuntu: apt-get install mysql-client mysql-server" echo "On AlmaLinux: dnf install mysql mysql-server" return 1 fi print_success "All required dependencies found" return 0 } ################################################################################ # UTILITY FUNCTIONS ################################################################################ # Detect MySQL version and variant detect_mysql_version() { print_info "Detecting MySQL version and variant..." # Try to get version from running instance if systemctl is-active --quiet mysqld || systemctl is-active --quiet mariadb; then local version_output=$(mysql -V 2>/dev/null || mysqld --version 2>/dev/null | head -1) if echo "$version_output" | grep -qi "mariadb"; then MYSQL_VARIANT="mariadb" MYSQL_VERSION=$(echo "$version_output" | grep -oP '\d+\.\d+\.\d+' | head -1) echo " Detected: MariaDB $MYSQL_VERSION" else MYSQL_VARIANT="mysql" MYSQL_VERSION=$(echo "$version_output" | grep -oP '\d+\.\d+\.\d+' | head -1) echo " Detected: MySQL $MYSQL_VERSION" fi return 0 fi # Fallback: Check binaries if command -v mariadb --version &> /dev/null; then MYSQL_VARIANT="mariadb" MYSQL_VERSION=$(mariadb --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1) echo " Detected: MariaDB $MYSQL_VERSION" elif command -v mysqld &> /dev/null; then MYSQL_VARIANT="mysql" MYSQL_VERSION=$(mysqld --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1) echo " Detected: MySQL $MYSQL_VERSION" else print_warning "Could not detect MySQL variant/version" MYSQL_VARIANT="unknown" MYSQL_VERSION="unknown" return 1 fi return 0 } # Detect live MySQL data directory detect_mysql_datadir() { print_info "Detecting MySQL data directory..." # Method 1: Check if MySQL is running if systemctl is-active --quiet mysqld || systemctl is-active --quiet mariadb; then LIVE_DATADIR=$(mysql -NBe 'SELECT @@datadir;' 2>/dev/null) if [ -n "$LIVE_DATADIR" ]; then echo " Detected from running MySQL: $LIVE_DATADIR" # Verify we can read this directory if [ ! -r "$LIVE_DATADIR" ]; then print_error "Cannot read MySQL data directory: Permission denied" print_info "Try running this script with: sudo $0" return 1 fi return 0 fi fi # Method 2: Check configuration files local config_dir=$(grep -r "^datadir" /etc/my.cnf /etc/my.cnf.d/* 2>/dev/null | head -1 | cut -d'=' -f2 | tr -d ' ') if [ -n "$config_dir" ]; then LIVE_DATADIR="$config_dir" echo " Detected from config: $LIVE_DATADIR" # Verify we can read this directory if [ ! -r "$LIVE_DATADIR" ]; then print_error "Cannot read MySQL data directory: Permission denied" print_info "Try running this script with: sudo $0" return 1 fi return 0 fi # Method 3: Default location if [ -d "/var/lib/mysql" ]; then LIVE_DATADIR="/var/lib/mysql" echo " Using default: $LIVE_DATADIR" # Verify we can read this directory if [ ! -r "$LIVE_DATADIR" ]; then print_error "Cannot read MySQL data directory: Permission denied" print_info "Try running this script with: sudo $0" return 1 fi return 0 fi print_warning "Could not auto-detect MySQL data directory" return 1 } # Validate restored data directory structure validate_restore_structure() { local dir="$1" local missing_files=() print_info "Validating restored data structure..." # Check for InnoDB system tablespace if [ ! -f "$dir/ibdata1" ]; then missing_files+=("ibdata1") fi # Check for redo logs (version-specific) # IMPORTANT: MySQL 8.0.30+ changed redo log architecture # MySQL 8.0.30+: #innodb_redo directory with #ib_redoN files # MySQL 8.0.0-8.0.29: ib_logfile0/ib_logfile1 # MySQL 5.7 and MariaDB: ib_logfile0/ib_logfile1 local major_version=$(echo "$MYSQL_VERSION" | cut -d'.' -f1) local minor_version=$(echo "$MYSQL_VERSION" | cut -d'.' -f2) if [ "$MYSQL_VARIANT" = "mysql" ] && [ -n "$major_version" ] && [ "$major_version" -ge 8 ]; then # Check if MySQL 8.0.30+ (new redo log format) if [ -n "$minor_version" ] && [ "$major_version" -eq 8 ] && [ "$minor_version" -ge 0 ]; then # Try to detect 8.0.30+ by checking patch version or directory existence if [ -d "$dir/#innodb_redo" ]; then # MySQL 8.0.30+: #innodb_redo directory exists print_info "Detected MySQL 8.0.30+ redo log format (#innodb_redo)" elif [ -f "$dir/ib_logfile0" ]; then # MySQL 8.0.0-8.0.29: old format print_info "Detected MySQL 8.0.0-8.0.29 redo log format (ib_logfile)" else missing_files+=("ib_logfile0 OR #innodb_redo directory (MySQL 8.0)") fi else # MySQL 9.0+ or other major versions if [ ! -d "$dir/#innodb_redo" ]; then missing_files+=("#innodb_redo directory (MySQL 8.0.30+/9.0+)") fi fi else # MySQL 5.7 and MariaDB: Always use ib_logfile0/ib_logfile1 if [ ! -f "$dir/ib_logfile0" ]; then missing_files+=("ib_logfile0 (MySQL 5.7/MariaDB)") fi if [ ! -f "$dir/ib_logfile1" ]; then print_warning "ib_logfile1 not found (may be optional)" fi fi # Check for mysql system database if [ ! -d "$dir/mysql" ] && [ ! -f "$dir/mysql.ibd" ]; then missing_files+=("mysql directory or mysql.ibd") fi # Check for target database if [ -n "$DATABASE_NAME" ] && [ ! -d "$dir/$DATABASE_NAME" ]; then missing_files+=("$DATABASE_NAME directory") fi if [ ${#missing_files[@]} -gt 0 ]; then print_error "Missing required files/directories:" for file in "${missing_files[@]}"; do echo " - $file" done return 1 fi print_success "Data structure validation passed" return 0 } # Detect error type from InnoDB log and recommend recovery level detect_recovery_level_from_errors() { local error_log="$1" local last_recovery_level="${2:-0}" if [ ! -f "$error_log" ]; then echo "0" # No errors = no recovery needed return 0 fi local log_content=$(cat "$error_log" 2>/dev/null) # Error type detection (in order of severity/recovery level needed) local error_type="" local recommended_level=0 # Check for MISSING FILES (missing tablespaces, unopenable files) # These need Level 1 (ignore corrupt pages) - missing files aren't corrupt, just absent if echo "$log_content" | grep -qiE "Cannot open tablespace|Tablespace.*missing|was not found at|Cannot find space id"; then error_type="missing_files" recommended_level=1 # Check for REDO LOG INCOMPATIBILITY (version mismatch, format issues) # These need Level 5 (skip log redo) or higher elif echo "$log_content" | grep -qiE "redo log.*incompatible|redo log.*different|redo log format.*does not match"; then error_type="redo_incompatible" recommended_level=5 # Check for CORRUPTION (page corruption, corrupted data) # These need Level 1-4 depending on severity elif echo "$log_content" | grep -qiE "Corrupted|Database page corruption|Corruption detected"; then error_type="corruption" # Start with Level 1 if fresh, escalate if retry if [ "$last_recovery_level" -eq 0 ]; then recommended_level=1 elif [ "$last_recovery_level" -eq 1 ]; then recommended_level=4 else recommended_level=6 fi # Check for INSERT BUFFER ISSUES (insert buffer merge failures) # These need Level 4 (prevent insert buffer merge) elif echo "$log_content" | grep -qiE "insert buffer|ibuf|buffer pool.*error"; then error_type="insert_buffer" recommended_level=4 # Check for MEMORY ISSUES (allocation failures, OOM) # These need system fix, not recovery mode elif echo "$log_content" | grep -qiE "Cannot allocate memory|Out of memory|memory error"; then error_type="memory_issue" recommended_level=0 # Check for ROLLBACK ISSUES (transaction rollback problems) # These need Level 3 (prevent transaction rollbacks) elif echo "$log_content" | grep -qiE "rollback.*error|Cannot rollback|Rollback failed"; then error_type="rollback_issue" recommended_level=3 fi # Auto-escalate if retry at same level if [ "$last_recovery_level" -gt 0 ] && [ "$recommended_level" -eq "$last_recovery_level" ]; then recommended_level=$((last_recovery_level + 1)) if [ "$recommended_level" -gt 6 ]; then recommended_level=6 fi fi echo "$recommended_level|$error_type" } # Check error log for InnoDB startup issues (returns error type) check_innodb_errors() { local error_log="$1" local check_recent="${2:-no}" # "yes" = only check recent errors, "no" = full check if [ ! -f "$error_log" ]; then return 0 # No error log yet, assume OK fi local errors_found=0 local critical_errors=() # InnoDB critical error patterns local error_patterns=( "InnoDB: Corrupted" "InnoDB: Database page corruption" "InnoDB: Unable to open" "InnoDB: Cannot allocate memory" "InnoDB: Tablespace.*missing" "InnoDB: Redo log.*corrupt" "InnoDB:.*redo log.*incompatible" "InnoDB: Plugin initialization aborted" "\[ERROR\].*InnoDB" ) # If checking recent errors, only look at last 50 lines local log_content if [ "$check_recent" = "yes" ]; then log_content=$(tail -50 "$error_log" 2>/dev/null) else log_content=$(cat "$error_log" 2>/dev/null) fi # Check each pattern for pattern in "${error_patterns[@]}"; do if echo "$log_content" | grep -qE "$pattern"; then local error_line=$(echo "$log_content" | grep -E "$pattern" | tail -1) critical_errors+=("$error_line") errors_found=$((errors_found + 1)) fi done if [ -n "$errors_found" ] && [ "$errors_found" -gt 0 ]; then print_error "InnoDB errors detected in $error_log:" for err in "${critical_errors[@]}"; do echo " - ${err:0:120}..." done return 1 fi return 0 } # Show intelligent recovery options based on error type show_recovery_options() { local datadir="$1" local current_recovery="${2:-0}" local selected_database="${3:-}" # The database user wants to restore print_banner "Recovery Options" # Analyze the error log to determine failure type local error_log="$datadir/mysql.err" # First, use error-based detection to determine root cause and recommended level if [ -f "$error_log" ]; then local detection_result=$(detect_recovery_level_from_errors "$error_log" "$current_recovery") local recommended_level=$(echo "$detection_result" | cut -d'|' -f1) local error_type=$(echo "$detection_result" | cut -d'|' -f2) if [ -n "$error_type" ]; then echo "Based on error log analysis:" echo " Error Type: $error_type" echo " Recommended Recovery Level: $recommended_level" echo "" fi fi local missing_files="" local corruption_detected="" local redo_incompatible="" local memory_issue="" local missing_from_selected_db="" local missing_from_other_dbs="" if [ -f "$error_log" ]; then if grep -qE "Cannot open tablespace|Tablespace.*missing|Tablespace.*was not found|Unable to open" "$error_log"; then missing_files="yes" # Check if missing files are from the selected database or other databases if [ -n "$selected_database" ]; then if grep -q "was not found at \./$selected_database/" "$error_log"; then missing_from_selected_db="yes" fi # Check if any missing files are NOT from the selected database while IFS= read -r line; do if ! echo "$line" | grep -q "\./$selected_database/"; then missing_from_other_dbs="yes" break fi done < <(grep "was not found at" "$error_log") fi fi if grep -qE "Corrupted|Database page corruption" "$error_log"; then corruption_detected="yes" fi if grep -qE "redo log.*incompatible|redo log.*different|redo log format" "$error_log"; then redo_incompatible="yes" fi if grep -qE "Cannot allocate memory|Out of memory" "$error_log"; then memory_issue="yes" fi fi # Provide targeted guidance based on error type if [ -n "$missing_files" ]; then # Smart detection: if missing files are ONLY from other databases (not the selected one) if [ -z "$missing_from_selected_db" ] && [ -n "$missing_from_other_dbs" ] && [ -n "$selected_database" ]; then print_warning "SMART DETECTION: Missing files are from OTHER databases, not '$selected_database'" echo "" print_success "Your selected database '$selected_database' appears to have all files!" echo "" print_warning "RECOMMENDED ACTION: Use Force Recovery Level 1" echo "" echo "The ibdata1 file contains references to databases you didn't restore." echo "Force Recovery Level 1 will:" echo " ✓ Ignore missing databases (safe - you don't have them anyway)" echo " ✓ Start MySQL successfully" echo " ✓ Allow you to dump '$selected_database' with NO data loss" echo "" echo "This is the CORRECT approach for selective database restoration." echo "" print_info "Re-run this script and select Force Recovery Level 1 in Step 4" echo "" return 0 fi print_error "DIAGNOSIS: Missing or unopenable tablespace files" echo "" # Use error-based detection to confirm recovery level recommendation local detection_result=$(detect_recovery_level_from_errors "$error_log" "$current_recovery") local recommended_level=$(echo "$detection_result" | cut -d'|' -f1) echo "Error analysis recommends: Force Recovery Level $recommended_level" echo "" # Parse error log to find EXACT missing files echo "Analyzing error log for missing files..." echo "" local missing_list=() local missing_count=0 # Extract tablespace names from various error patterns while IFS= read -r error_line; do # Pattern 1: "Cannot open tablespace 'db/table'" or "Tablespace N was not found at ./db/table.ibd" if echo "$error_line" | grep -qE "Cannot open|Unable to open|Tablespace.*missing|was not found at"; then # Try to extract path from "was not found at ./path/table.ibd" local tablespace=$(echo "$error_line" | grep -oE '\./[^/]+/[^.]+\.ibd' | sed 's|\./||;s|\.ibd||' | head -1) # Fallback: try to extract from quoted tablespace name if [ -z "$tablespace" ]; then tablespace=$(echo "$error_line" | grep -oE "'[^']+'" | tr -d "'" | head -1) fi if [ -n "$tablespace" ]; then missing_list+=("$tablespace") missing_count=$((missing_count + 1)) fi fi # Pattern 2: "Cannot find space id N in the tablespace memory cache" if echo "$error_line" | grep -qE "Cannot find space id.*in the tablespace"; then local space_id=$(echo "$error_line" | grep -oE "space id [0-9]+" | awk '{print $3}') if [ -n "$space_id" ]; then missing_list+=("space_id_$space_id") missing_count=$((missing_count + 1)) fi fi done < <(grep -iE "Cannot open|Unable to open|Tablespace.*missing|was not found at|Cannot find space id" "$error_log" 2>/dev/null) if [ "$missing_count" -gt 0 ]; then print_warning "MISSING FILES DETECTED ($missing_count found):" echo "" # Remove duplicates and display local unique_missing=($(printf '%s\n' "${missing_list[@]}" | sort -u)) local file_num=1 for item in "${unique_missing[@]}"; do if [[ "$item" == *"/"* ]]; then # Format: database/table local db_name=$(echo "$item" | cut -d'/' -f1) local table_name=$(echo "$item" | cut -d'/' -f2) echo " $file_num) Table: $table_name (in database: $db_name)" echo " File needed: $datadir/$db_name/${table_name}.ibd" echo " Backup path: /var/lib/mysql/$db_name/${table_name}.ibd" elif [[ "$item" == space_id_* ]]; then echo " $file_num) Unknown tablespace (space ID: ${item#space_id_})" echo " Action: Restore entire database directory to ensure all files present" else echo " $file_num) $item" fi echo "" file_num=$((file_num + 1)) done else print_warning "Could not parse specific missing files from error log" echo "Showing raw error lines:" echo "" grep -iE "Cannot open|Unable to open|Tablespace.*missing|was not found at" "$error_log" 2>/dev/null | head -10 echo "" fi echo "Common causes:" echo " - Not all database table files (.ibd) were restored" echo " - Table files are in wrong location" echo " - Permissions issue (not mysql:mysql)" echo "" print_warning "RECOMMENDED ACTIONS:" echo "" echo " Option 1: Restore Missing Files (RECOMMENDED)" echo " ────────────────────────────────────────────────" if [ "$missing_count" -gt 0 ]; then echo " 1. Restore the $missing_count file(s) listed above from your backup" echo "" echo " 2. Use Acronis/rsync/cp to copy .ibd files:" echo " Example commands:" for item in "${unique_missing[@]}"; do if [[ "$item" == *"/"* ]]; then local db_name=$(echo "$item" | cut -d'/' -f1) local table_name=$(echo "$item" | cut -d'/' -f2) echo " cp /backup/path/$db_name/${table_name}.ibd $datadir/$db_name/" fi done echo "" echo " 3. Fix ownership:" echo " chown mysql:mysql \"$datadir/$DATABASE_NAME\"/*.ibd" else echo " 1. Check error log manually:" echo " grep -i 'cannot open\\|missing' $error_log" echo "" echo " 2. Restore identified .ibd files from backup" fi echo "" echo " 4. Re-run this script (will detect newly added files)" echo "" echo " Option 2: Restore Entire Database Directory" echo " ────────────────────────────────────────────────" echo " If you're missing many files, easier to restore all:" echo "" echo " 1. Remove partial database directory:" echo " rm -rf \"$datadir/$DATABASE_NAME\"" echo "" echo " 2. Restore complete database directory from backup:" echo " cp -r /backup/path/$DATABASE_NAME \"$datadir/\"" echo "" echo " 3. Fix ownership:" echo " chown -R mysql:mysql \"$datadir/$DATABASE_NAME\"" echo "" echo " 4. Re-run this script" echo "" echo " Option 3: Start Completely Fresh" echo " ────────────────────────────────────────────────" echo " 1. Clear the entire restore directory:" echo " rm -rf $datadir/*" echo "" echo " 2. Restore ALL files from backup (complete set):" echo " - ibdata1" echo " - redo logs (ib_logfile* or #innodb_redo/)" echo " - mysql/ directory" echo " - All database directories" echo "" echo " 3. Re-run this script from the beginning" echo "" elif [ -n "$redo_incompatible" ]; then print_error "DIAGNOSIS: Redo log incompatibility" echo "" # Use error-based detection to recommend appropriate recovery level local detection_result=$(detect_recovery_level_from_errors "$error_log" "$current_recovery") local recommended_level=$(echo "$detection_result" | cut -d'|' -f1) local error_type=$(echo "$detection_result" | cut -d'|' -f2) echo "Common causes:" echo " - Backup from different MySQL version" echo " - Mixed redo log formats (8.0.30 vs older)" echo " - Partial restore (old + new redo logs mixed)" echo "" print_warning "RECOMMENDED ACTIONS:" echo "" echo " Option 1: Start Fresh with Correct Redo Logs (PREFERRED)" echo " ────────────────────────────────────────────────" echo " 1. Remove current redo logs:" if [ -d "$datadir/#innodb_redo" ]; then echo " rm -rf $datadir/#innodb_redo" else echo " rm -f $datadir/ib_logfile*" fi echo "" echo " 2. Restore redo logs from SAME backup date:" echo " - Must match the ibdata1 file exactly" echo " - Check backup timestamp carefully" echo "" echo " 3. Re-run this script" echo "" echo " Option 2: Force Recovery (if redo logs are lost/unavailable)" echo " ────────────────────────────────────────────────" echo " Error analysis recommends: Force Recovery Level $recommended_level" echo " Re-run script and select recovery mode $recommended_level" echo "" if [ "$recommended_level" -ge 5 ]; then echo " WARNING: This recovery level will skip log redo operations" echo " Some recent transactions may be lost or incomplete" fi echo "" elif [ -n "$corruption_detected" ]; then print_error "DIAGNOSIS: InnoDB corruption detected" echo "" # Use error-based detection to recommend appropriate recovery level local detection_result=$(detect_recovery_level_from_errors "$error_log" "$current_recovery") local recommended_level=$(echo "$detection_result" | cut -d'|' -f1) local error_type=$(echo "$detection_result" | cut -d'|' -f2) # Build escalation path based on corruption type local level_1_desc="Ignores corrupt pages (most conservative)" local level_4_desc="Prevents insert buffer merge operations" local level_6_desc="Skips page checksums (maximum recovery, most data loss risk)" print_warning "RECOMMENDED ACTION (from error analysis):" echo " ✓ Try Force Recovery Level $recommended_level" echo "" print_warning "STEP-BY-STEP PROGRESSION:" echo "" # Show all levels up to and including the recommended level # This helps user understand the escalation path if needed # Level 1 if [ "$recommended_level" -ge 1 ]; then echo " Step 1: Try Force Recovery Level 1" echo " ────────────────────────────────────────────────" echo " Re-run script → Step 4 → Select recovery mode 1" echo " $level_1_desc" if [ "$recommended_level" -eq 1 ]; then echo " ^ RECOMMENDED (error analysis suggests this level)" fi echo "" fi # Level 4 (skip 2 and 3 as they're less commonly needed for corruption) if [ "$recommended_level" -ge 4 ]; then echo " Step 2: If Level 1 Fails, Try Force Recovery Level 4" echo " ────────────────────────────────────────────────" echo " Re-run script → Step 4 → Select recovery mode 4" echo " $level_4_desc" if [ "$recommended_level" -eq 4 ]; then echo " ^ RECOMMENDED (error analysis suggests this level)" fi echo "" fi # Level 6 (last resort) if [ "$recommended_level" -ge 6 ]; then echo " Step 3: If Level 4 Fails, Try Force Recovery Level 6 (LAST RESORT)" echo " ────────────────────────────────────────────────" echo " Re-run script → Step 4 → Select recovery mode 6" echo " $level_6_desc" echo " ^ RECOMMENDED (error analysis suggests this level - MAX DATA RISK)" echo "" fi echo " Step 4: If All Recovery Levels Fail" echo " ────────────────────────────────────────────────" echo " 1. Corruption may be in the backup itself" echo " 2. Try restoring from an older backup date" echo " 3. Clear directory: rm -rf $datadir/*" echo " 4. Restore from different backup snapshot" echo "" elif [ -n "$memory_issue" ]; then print_error "DIAGNOSIS: Memory allocation failure" echo "" print_warning "RECOMMENDED ACTIONS:" echo "" echo " 1. Check available memory: free -h" echo " 2. Stop other MySQL instances: systemctl stop mysqld" echo " 3. Re-run this script" echo "" else # Generic troubleshooting print_warning "TROUBLESHOOTING STEPS:" echo "" echo " 1. Review Error Log" echo " ────────────────────────────────────────────────" echo " tail -100 $error_log | less" echo "" echo " 2. Verify File Structure" echo " ────────────────────────────────────────────────" echo " ls -laR $datadir/ | less" echo " Look for: ibdata1, redo logs, mysql/, database/" echo "" echo " 3. Check Ownership" echo " ────────────────────────────────────────────────" echo " stat -c '%U:%G' $datadir/ibdata1" echo " Should be: mysql:mysql" echo "" echo " 4. Try Force Recovery" echo " ────────────────────────────────────────────────" echo " Re-run script → Step 4 → Select recovery mode 1-6" echo "" echo " 5. Start Fresh" echo " ────────────────────────────────────────────────" echo " rm -rf $datadir/*" echo " Restore complete file set from backup" echo " Re-run script" echo "" fi # Always show the error log location echo "" print_info "Full error log location:" echo " $error_log" echo "" # Offer to show recent errors echo -n "View recent errors from log now? (y/n): " read -r view_errors if [ "$view_errors" = "y" ]; then echo "" echo "════════════════════════════════════════════════════════════════" echo "LAST 50 LINES OF ERROR LOG" echo "════════════════════════════════════════════════════════════════" tail -50 "$error_log" 2>/dev/null || echo "Error log not found" echo "════════════════════════════════════════════════════════════════" echo "" fi # NOTE: After showing recovery options, the script will exit and user must # re-run it with the selected recovery level in Step 4. # This is intentional to avoid automatic retries with different recovery levels # which could cause data corruption if blindly escalating through levels. } # Check available disk space (CRITICAL SAFETY CHECK #3) check_disk_space() { local target_dir="$1" local estimated_size_mb="${2:-100}" # Minimum 100MB default print_info "Checking available disk space..." # Get available space in MB local available_mb=$(df -BM "$target_dir" | awk 'NR==2 {print $4}' | tr -d 'M') local available_gb=$(awk "BEGIN {printf \"%.2f\", $available_mb/1024}") # Require at least 2x the estimated size (safety margin) local required_mb=$((estimated_size_mb * 2)) local required_gb=$(awk "BEGIN {printf \"%.2f\", $required_mb/1024}") echo " Available space: ${available_gb}GB (${available_mb}MB)" echo " Recommended minimum: ${required_gb}GB" # Check if we have enough space if [ -n "$available_mb" ] && [ "$available_mb" -lt "$required_mb" ]; then print_error "Insufficient disk space!" echo "" echo " Available: ${available_gb}GB" echo " Required: ${required_gb}GB" echo " Shortage: $(awk "BEGIN {printf \"%.2f\", ($required_mb - $available_mb)/1024}")GB" echo "" echo "Free up space or use a different directory." return 1 fi print_success "Sufficient disk space available (${available_gb}GB free)" return 0 } # Validate force recovery level warnings (CRITICAL SAFETY CHECK #6) warn_force_recovery() { local level="${1:-0}" if [ -z "$level" ] || [ "$level" -eq 0 ]; then return 0 fi echo "" print_warning "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" print_warning " FORCE RECOVERY MODE ACTIVE: Level $level" print_warning "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" case $level in 1) echo "Level 1: Ignore Corrupt Pages" echo " - Data Loss: Minimal (only corrupt pages skipped)" echo " - Safety: Relatively safe" ;; 2) echo "Level 2: Prevent Background Operations" echo " - Data Loss: Minimal" echo " - Safety: Moderate" ;; 3) echo "Level 3: Prevent Transaction Rollbacks" echo " - Data Loss: Some uncommitted data may persist" echo " - Safety: Moderate risk" ;; 4) echo "Level 4: Prevent Insert Buffer Merge" echo " - Data Loss: Moderate (recent inserts may be lost)" echo " - Safety: Higher risk" ;; 5) echo "Level 5: Skip Log Redo" echo " - Data Loss: HIGH (recent transactions may be incomplete)" echo " - Safety: HIGH RISK" print_error " WARNING: May result in inconsistent data!" ;; 6) echo "Level 6: Skip Page Checksums (MAXIMUM RECOVERY)" echo " - Data Loss: VERY HIGH (corrupted data WILL be included)" echo " - Safety: VERY HIGH RISK" print_error " CRITICAL: Use only as LAST RESORT!" print_error " Exported data WILL contain corrupted/invalid records!" ;; esac echo "" if [ "$level" -ge 5 ]; then print_error "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" print_error " DANGEROUS RECOVERY LEVEL!" print_error "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "The resulting SQL dump may contain:" echo " ✗ Incomplete transactions" echo " ✗ Corrupted data" echo " ✗ Invalid foreign key relationships" echo " ✗ Inconsistent table states" echo "" print_warning "Type 'I UNDERSTAND THE RISKS' to continue:" echo -n "> " read -r risk_confirm if [ "$risk_confirm" != "I UNDERSTAND THE RISKS" ]; then print_error "Recovery cancelled for safety" echo "Consider using a lower recovery level or older backup." return 1 fi echo "" print_success "Risk acknowledgment confirmed" fi echo "" return 0 } # Start second MySQL instance start_second_instance() { local datadir="$1" local force_recovery="${2:-}" print_info "Starting second MySQL instance..." print_warning "This may take a few moments..." # CRITICAL SAFETY CHECK: Ensure we're not using live MySQL data directory if [ "$datadir" = "/var/lib/mysql" ] || [ "$datadir" = "$LIVE_DATADIR" ]; then print_error "CRITICAL SAFETY ERROR: Attempting to use LIVE MySQL data directory!" echo "" echo "Data directory specified: $datadir" echo "Live MySQL directory: $LIVE_DATADIR" echo "" print_error "This script must use a SEPARATE restore directory" print_error "NEVER run on the live MySQL data directory" echo "" echo "Expected restore directory format:" echo " /home/temp/restore*/mysql" echo " /root/restore*/mysql" echo " Any path OTHER than $LIVE_DATADIR" echo "" return 1 fi # Display isolation confirmation echo "" print_success "Safety checks passed:" echo " Second instance datadir: $datadir" echo " Second instance socket: $datadir/socket.mysql" echo " Live MySQL datadir: $LIVE_DATADIR" echo " Live MySQL socket: /var/lib/mysql/mysql.sock (PROTECTED)" echo "" # Clear or backup old error log if [ -f "$datadir/mysql.err" ]; then print_info "Backing up old error log..." mv "$datadir/mysql.err" "$datadir/mysql.err.old" 2>/dev/null || true fi # Check if socket already exists (instance already running) if [ -S "$datadir/socket.mysql" ]; then print_warning "Socket file already exists. Attempting to shut down existing instance..." # Use proper shutdown validation (same as stop_second_instance) mysqladmin -h localhost -S "$datadir/socket.mysql" shutdown 2>/dev/null || true # Wait for socket to disappear (up to 5 seconds) local cleanup_wait=0 while [ -S "$datadir/socket.mysql" ] && [ "$cleanup_wait" -lt 5 ]; do sleep 1 cleanup_wait=$((cleanup_wait + 1)) done # If socket still exists, try force removal if [ -S "$datadir/socket.mysql" ]; then print_warning "Existing instance didn't shut down cleanly. Force removing socket..." rm -f "$datadir/socket.mysql" "$datadir/mysql.lock" 2>/dev/null || true fi fi # Build mysqld command local mysqld_cmd="/usr/libexec/mysqld" if ! command -v "$mysqld_cmd" &> /dev/null; then mysqld_cmd="mysqld" fi # Start in background - build args array to avoid eval local mysqld_args=( "--datadir=$datadir" "--socket=$datadir/socket.mysql" "--pid-file=$datadir/mysql.pid" "--log-error=$datadir/mysql.err" "--skip-grant-tables" "--skip-networking" "--user=mysql" ) if [ -n "$force_recovery" ]; then mysqld_args+=("--innodb-force-recovery=$force_recovery") print_warning "Using InnoDB force recovery mode: $force_recovery" fi # Start in background "$mysqld_cmd" "${mysqld_args[@]}" & local pid=$! # Wait for instance to start (max 30 seconds) local count=0 while [ "$count" -lt 30 ]; do if [ -S "$datadir/socket.mysql" ]; then print_success "Second MySQL instance started (PID: $pid)" # Give InnoDB a moment to initialize sleep 2 # Check for InnoDB errors in the error log echo "" print_info "Checking InnoDB startup status..." if ! check_innodb_errors "$datadir/mysql.err" "yes"; then print_error "InnoDB initialization encountered errors" echo "" print_warning "Attempting to shut down second instance..." # Use proper shutdown validation instead of fire-and-forget mysqladmin -h localhost -S "$datadir/socket.mysql" shutdown 2>/dev/null || true # Wait for socket to disappear (up to 5 seconds) local error_cleanup_wait=0 while [ -S "$datadir/socket.mysql" ] && [ "$error_cleanup_wait" -lt 5 ]; do sleep 1 error_cleanup_wait=$((error_cleanup_wait + 1)) done # Remove stale socket/lock if still present if [ -S "$datadir/socket.mysql" ]; then rm -f "$datadir/socket.mysql" "$datadir/mysql.lock" 2>/dev/null || true fi echo "" print_info "Review full error log:" echo " tail -100 $datadir/mysql.err" echo "" print_info "Consider using force recovery mode (re-run script, option 1-6)" return 1 fi print_success "InnoDB initialized successfully - no critical errors detected" # Mark second instance as running for cleanup trap SECOND_INSTANCE_RUNNING=1 return 0 fi sleep 1 count=$((count + 1)) done # Check if process is still running if ! kill -0 "$pid" 2>/dev/null; then print_error "Second MySQL instance failed to start" echo "" # Check for InnoDB errors if ! check_innodb_errors "$datadir/mysql.err" "no"; then echo "" fi print_info "Full error log (last 50 lines):" if [ -f "$datadir/mysql.err" ]; then tail -50 "$datadir/mysql.err" fi return 1 fi print_warning "Instance started but socket not detected. Check error log:" echo " tail -50 $datadir/mysql.err" return 1 } # Stop second MySQL instance with proper validation stop_second_instance() { local datadir="$1" if [ ! -S "$datadir/socket.mysql" ]; then # Socket doesn't exist, instance likely already stopped SECOND_INSTANCE_RUNNING=0 return 0 fi print_info "Shutting down second MySQL instance..." # Get the PID from pid file if available local pid="" if [ -f "$datadir/mysql.pid" ]; then pid=$(cat "$datadir/mysql.pid" 2>/dev/null) fi # Send graceful shutdown mysqladmin -h localhost -S "$datadir/socket.mysql" shutdown 2>/dev/null || true # CRITICAL FIX: Verify shutdown actually happened (not just fire-and-forget) # Wait up to 15 seconds for socket to disappear (indicates clean shutdown) local wait_count=0 while [ -S "$datadir/socket.mysql" ] && [ "$wait_count" -lt 15 ]; do sleep 1 wait_count=$((wait_count + 1)) done # If socket still exists, attempt force kill if [ -S "$datadir/socket.mysql" ]; then print_warning "Socket still exists after shutdown. Forcing termination..." # Try to kill the process if we have the PID if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then kill -9 "$pid" 2>/dev/null || true sleep 1 fi # Remove stale socket and lock files rm -f "$datadir/socket.mysql" "$datadir/mysql.lock" 2>/dev/null || true fi # Verify process is actually dead if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then print_warning "MySQL process still running after shutdown attempt. Will retry on exit." else print_success "Second instance shut down successfully" fi # Mark as no longer running SECOND_INSTANCE_RUNNING=0 } # Validate SQL dump integrity validate_sql_dump() { local sql_file="$1" local dbname="$2" local datadir="$3" print_info "Validating SQL dump integrity..." local validation_errors=0 # Check 1: File exists and is not empty if [ ! -f "$sql_file" ]; then print_error "SQL dump file not found: $sql_file" return 1 fi local file_size=$(stat -c%s "$sql_file" 2>/dev/null || stat -f%z "$sql_file" 2>/dev/null) if [ -z "$file_size" ] || [ "$file_size" -lt 100 ]; then print_error "SQL dump file is too small ($file_size bytes) - likely incomplete" return 1 fi print_success " File size: $(du -h "$sql_file" | awk '{print $1}')" # Check 2: Completion marker if grep -q "Dump completed on" "$sql_file"; then local completion_date=$(grep "Dump completed on" "$sql_file" | tail -1) print_success " Dump completion marker found: ${completion_date:0:60}..." else print_error " Missing 'Dump completed' marker - dump may be incomplete" validation_errors=$((validation_errors + 1)) fi # Check 3: Database name in dump if grep -q "^-- Database: \`$dbname\`" "$sql_file" || grep -q "^USE \`$dbname\`" "$sql_file"; then print_success " Database name '$dbname' found in dump" else print_warning " Database name '$dbname' not explicitly found in dump (may be OK)" fi # Check 4: Count CREATE TABLE statements local table_count=$(grep -c "^CREATE TABLE" "$sql_file" || echo "0") if [ "$table_count" -gt 0 ]; then print_success " Found $table_count CREATE TABLE statements" else print_warning " No CREATE TABLE statements found - database may be empty or dump incomplete" fi # Check 5: Count INSERT statements (data) local insert_count=$(grep -c "^INSERT INTO" "$sql_file" || echo "0") if [ "$insert_count" -gt 0 ]; then print_success " Found $insert_count INSERT INTO statements" else print_warning " No INSERT statements found - database may be empty or tables have no data" fi # Check 6: SQL syntax spot check (no unclosed quotes in first 100 lines) local syntax_errors=$(head -100 "$sql_file" | grep -E "^\s*['\"].*[^'\"];?\s*$" | wc -l || echo "0") if [ "$syntax_errors" -eq 0 ]; then print_success " SQL syntax spot check passed" else print_warning " Potential SQL syntax issues detected (may be false positive)" fi # Check 7: Compare with source database (if second instance still running) if [ -S "$datadir/socket.mysql" ]; then print_info " Comparing dump with source database..." # Get table count from source local source_tables=$(mysql -h localhost -S "$datadir/socket.mysql" -NBe "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=\`$dbname\`;" 2>/dev/null || echo "0") if [ -n "$source_tables" ] && [ "$source_tables" -gt 0 ]; then if [ "$table_count" -eq "$source_tables" ]; then print_success " Table count matches: $table_count tables" else print_error " Table count mismatch: Dump has $table_count, source has $source_tables" validation_errors=$((validation_errors + 1)) fi # Get approximate data size from source local source_size=$(mysql -h localhost -S "$datadir/socket.mysql" -NBe "SELECT ROUND(SUM(data_length + index_length)/1024/1024, 2) FROM information_schema.TABLES WHERE TABLE_SCHEMA=\`$dbname\`;" 2>/dev/null || echo "0") local dump_size_mb=$(awk "BEGIN {printf \"%.2f\", $file_size/1024/1024}") if [ -n "$source_size" ]; then print_info " Source database size: ${source_size}MB (data+indexes)" print_info " Dump file size: ${dump_size_mb}MB (uncompressed SQL)" # Dump is usually 1-3x the data size (reasonable range) local size_ratio=$(awk "BEGIN {if ($source_size > 0) printf \"%.1f\", $dump_size_mb/$source_size; else print 0}") if [ -n "$size_ratio" ]; then print_info " Size ratio: ${size_ratio}x (1-3x is normal for text SQL)" fi fi fi fi if [ -n "$validation_errors" ] && [ "$validation_errors" -gt 0 ]; then echo "" print_error "Validation completed with $validation_errors errors" return 1 fi echo "" print_success "SQL dump validation PASSED - dump appears clean and complete" return 0 } # Dump database from second instance dump_database() { local datadir="$1" local dbname="$2" local output_file="$3" print_info "Creating SQL dump of database: $dbname" print_warning "This may take some time for large databases..." # Check if database exists in second instance local db_check=$(mysql -h localhost -S "$datadir/socket.mysql" -NBe "SHOW DATABASES LIKE \`$dbname\`;" 2>/dev/null) if [ -z "$db_check" ]; then print_error "Database '$dbname' not found in second instance" return 1 fi # Get table count before dump local table_count=$(mysql -h localhost -S "$datadir/socket.mysql" -NBe "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=\`$dbname\`;" 2>/dev/null || echo "0") print_info "Database contains $table_count tables" # Perform dump echo "" # BUG FIX: Capture mysqldump stderr to show errors if dump fails local dump_stderr=$(mktemp) if mysqldump -h localhost -S "$datadir/socket.mysql" --single-transaction "$dbname" > "$output_file" 2>"$dump_stderr"; then rm -f "$dump_stderr" # Verify dump completed if grep -q "Dump completed on" "$output_file"; then local size=$(du -h "$output_file" | awk '{print $1}') print_success "Dump created: $output_file ($size)" # Validate the dump echo "" if ! validate_sql_dump "$output_file" "$dbname" "$datadir"; then print_warning "Dump created but validation found issues" echo "" echo -n "Continue anyway? (y/n): " read -r continue_choice if [ "$continue_choice" != "y" ]; then return 1 fi fi return 0 else print_error "Dump appears incomplete (missing completion marker)" return 1 fi else # BUG FIX: Show mysqldump errors instead of silently failing print_error "mysqldump failed with exit code $?" if [ -f "$dump_stderr" ] && [ -s "$dump_stderr" ]; then print_error "Error details:" while IFS= read -r line; do echo " $line" | sed 's/^[[:space:]]*/ /' done < "$dump_stderr" rm -f "$dump_stderr" fi return 1 fi } ################################################################################ # INTERACTIVE WORKFLOW ################################################################################ # Display the welcome banner and script overview to the user # Explains what the script does and shows required steps show_intro() { clear print_banner "MySQL/MariaDB File-Based Restore" # Detect MySQL version first detect_mysql_version echo "" echo "This tool helps restore MySQL/MariaDB databases from file-based backups" echo "(such as Acronis) when InnoDB tables are involved." echo "" if [ "$MYSQL_VARIANT" != "unknown" ]; then echo "Detected Database: $MYSQL_VARIANT $MYSQL_VERSION" echo "" fi echo "Process Overview:" echo " 1. Detect live MySQL data directory (read-only check)" echo " 2. Validate restored data files" echo " 3. Start SECOND MySQL instance using restored files" echo " 4. Create SQL dump from second instance" echo " 5. Shutdown second instance and output .sql file" echo "" print_success "SAFETY GUARANTEES:" echo " ✓ Uses SEPARATE MySQL instance (isolated socket/pid/datadir)" echo " ✓ Your LIVE MySQL is NEVER touched, stopped, or modified" echo " ✓ Second instance uses --skip-networking (no port 3306 conflicts)" echo " ✓ Automatic shutdown of second instance on completion or failure" echo " ✓ Second instance only reads restored files, never touches live data" echo "" print_warning "PREREQUISITES:" echo " - Restored MySQL data files must already be on this server" # Version-specific file requirements (2025 updated) if [ "$MYSQL_VARIANT" = "mysql" ]; then local major_ver=$(echo "$MYSQL_VERSION" | cut -d'.' -f1) local patch_ver=$(echo "$MYSQL_VERSION" | cut -d'.' -f3) if [ -n "$major_ver" ] && [ "$major_ver" -ge 9 ]; then echo " - Files: ibdata1, #innodb_redo/, #innodb_temp/ (opt), mysql/, sys/ (opt), target DB/" elif [ -n "$major_ver" ] && [ "$major_ver" -eq 8 ] && [ -n "$patch_ver" ] && [ "$patch_ver" -ge 30 ]; then echo " - Files: ibdata1, #innodb_redo/, #innodb_temp/ (opt), mysql/, sys/ (opt), target DB/" elif [ -n "$major_ver" ] && [ "$major_ver" -ge 8 ]; then echo " - Files: ibdata1, ib_logfile0/1, #innodb_temp/ (opt), mysql/, sys/ (opt), target DB/" else echo " - Files: ibdata1, ib_logfile0/1, mysql/, sys/ (opt), target DB/" fi else echo " - Files: ibdata1, ib_logfile0/1, mysql/, sys/ (opt), target DB/" fi echo " - Files must be owned by mysql:mysql" echo " - Sufficient disk space for SQL dumps" echo "" } # Step 1: Auto-detect or prompt for live MySQL data directory # Looks for running MySQL instance or attempts to find config file # Sets LIVE_DATADIR variable for use in later steps step1_detect_datadir() { print_banner "Step 1: Detect Live MySQL Data Directory" detect_mysql_datadir echo "" echo "Live MySQL Data Directory: $LIVE_DATADIR" echo "" echo -n "Is this correct? (y/n, or 0 to cancel): " read -r confirm if [ "$confirm" = "0" ]; then echo "Operation cancelled." press_enter exit 0 fi if [ "$confirm" != "y" ]; then echo "" echo -n "Enter MySQL data directory path (or 0 to cancel): " read -r custom_dir if [ -z "$custom_dir" ] || [ "$custom_dir" = "0" ]; then echo "Operation cancelled." press_enter exit 0 fi # SECURITY: Validate path to prevent traversal if [[ "$custom_dir" == *"../"* ]] || [[ "$custom_dir" == *"/.."* ]]; then print_error "Invalid path: contains path traversal sequence (..)" press_enter return 1 fi if [ ! -d "$custom_dir" ]; then print_error "Directory does not exist: $custom_dir" press_enter return 1 fi # Resolve to absolute path local resolved_custom=$(cd "$custom_dir" && pwd) LIVE_DATADIR="$resolved_custom" print_success "Updated data directory: $LIVE_DATADIR" fi echo "" press_enter } # Step 2: Configure temporary location for restored MySQL data # Allows user to choose suggested directory or provide custom path # Validates path for safety (no traversal, not live MySQL dir) # Sets TEMP_DATADIR variable for second MySQL instance step2_set_restore_location() { print_banner "Step 2: Set Restored Data Location" echo "Let's set up the restore directory." echo "" # Use control panel-specific home base, fallback to /home local home_base="${SYS_USER_HOME_BASE:-/home}" # Offer to create a timestamped directory local suggested_dir="${home_base}/temp/restore$(date +%Y%m%d)/mysql" echo "Suggested directory: $suggested_dir" echo "" echo " 1) Use suggested directory (will create if needed)" echo " 2) Enter custom path" echo " 0) Cancel" echo "" echo -n "Select option: " read -r dir_choice case $dir_choice in 0) echo "Operation cancelled." press_enter exit 0 ;; 1) TEMP_DATADIR="$suggested_dir" ;; 2) echo "" echo -n "Enter path to restored data directory (or 0 to cancel): " read -r restore_path if [ -z "$restore_path" ] || [ "$restore_path" = "0" ]; then echo "Operation cancelled." press_enter exit 0 fi # SECURITY: Validate path to prevent traversal and system directory access if [[ "$restore_path" == *"../"* ]] || [[ "$restore_path" == *"/.."* ]]; then print_error "Invalid path: contains path traversal sequence (..)" press_enter return 1 fi # Prevent using live database directories if [ "$restore_path" = "/var/lib/mysql" ] || [[ "$restore_path" == "/var/lib/mysql/"* ]]; then print_error "Invalid path: cannot use live MySQL data directory (/var/lib/mysql)" press_enter return 1 fi # Get absolute path for validation local resolved_path if [ -d "$restore_path" ]; then resolved_path=$(cd "$restore_path" && pwd) else # Path doesn't exist yet, resolve parent directory local parent_path=$(dirname "$restore_path") if [ ! -d "$parent_path" ]; then print_error "Parent directory does not exist: $parent_path" press_enter return 1 fi resolved_path=$(cd "$parent_path" && pwd)/$(basename "$restore_path") fi TEMP_DATADIR="$resolved_path" ;; *) print_error "Invalid option" press_enter return 1 ;; esac # Create directory if it doesn't exist if [ ! -d "$TEMP_DATADIR" ]; then echo "" print_info "Creating directory: $TEMP_DATADIR" if mkdir -p "$TEMP_DATADIR"; then chown mysql:mysql "$TEMP_DATADIR" chmod 751 "$TEMP_DATADIR" # Also ensure parent temp directory has correct permissions local parent_temp="${home_base}/temp" if [ -d "$parent_temp" ]; then chmod 751 "$parent_temp" 2>/dev/null || true fi print_success "Directory created with mysql:mysql ownership" else print_error "Failed to create directory" press_enter return 1 fi fi # CRITICAL: Verify directory has write permissions before using it if [ ! -w "$TEMP_DATADIR" ]; then print_error "Directory exists but is not writable: $TEMP_DATADIR" print_info "Please check permissions or choose a different directory" press_enter return 1 fi # Show required files list echo "" print_banner "Required Files to Restore" echo "" echo "You need to restore the following files from your backup to:" echo " $TEMP_DATADIR" echo "" print_warning "REQUIRED FILES:" echo "" echo "1. InnoDB System Tablespace:" echo " 📁 $TEMP_DATADIR/ibdata1" echo "" # Version-specific redo log files (2025 updated) local major_version=$(echo "$MYSQL_VERSION" | cut -d'.' -f1) local minor_version=$(echo "$MYSQL_VERSION" | cut -d'.' -f2) local patch_version=$(echo "$MYSQL_VERSION" | cut -d'.' -f3) if [ "$MYSQL_VARIANT" = "mysql" ] && [ -n "$major_version" ] && [ "$major_version" -ge 8 ]; then # Detect if MySQL 8.0.30+ or MySQL 9.0+ local use_new_redo=0 if [ "$major_version" -ge 9 ]; then use_new_redo=1 elif [ "$major_version" -eq 8 ] && [ -n "$patch_version" ] && [ "$patch_version" -ge 30 ]; then use_new_redo=1 fi if [ "$use_new_redo" -eq 1 ]; then echo "2. InnoDB Redo Logs (MySQL 8.0.30+/9.0+):" echo " 📁 $TEMP_DATADIR/#innodb_redo/ (entire directory)" echo " Contains: #ib_redo0, #ib_redo1, ... #ib_redoN files" else echo "2. InnoDB Redo Logs (MySQL 8.0.0-8.0.29):" echo " 📁 $TEMP_DATADIR/ib_logfile0" echo " 📁 $TEMP_DATADIR/ib_logfile1" fi else echo "2. InnoDB Redo Logs (MySQL 5.7/MariaDB):" echo " 📁 $TEMP_DATADIR/ib_logfile0" echo " 📁 $TEMP_DATADIR/ib_logfile1" fi echo "" echo "3. InnoDB Temporary Tablespace (if exists):" echo " 📁 $TEMP_DATADIR/#innodb_temp/ (optional, contains temp_N.ibt files)" echo " 📁 $TEMP_DATADIR/ibtmp1 (optional, global temp tablespace)" echo "" echo "4. MySQL System Database:" echo " 📁 $TEMP_DATADIR/mysql/ (entire directory)" echo " OR" echo " 📁 $TEMP_DATADIR/mysql.ibd (single file, if using)" echo "" echo "5. Optional: System Schema (if exists in backup):" echo " 📁 $TEMP_DATADIR/sys/ (entire directory - recommended)" echo "" echo "6. Your Target Database(s):" echo " 📁 $TEMP_DATADIR// (entire directory)" echo " Example: $TEMP_DATADIR/myuser_wordpress/" echo "" print_info "NOTE: performance_schema is NOT needed (recreated automatically)" echo "" print_info "TIP: Use Acronis, rsync, or cp to restore these files" echo "" echo -n "Have you finished restoring all required files? (y/n, or 0 to cancel): " read -r files_ready if [ "$files_ready" = "0" ]; then echo "Operation cancelled." press_enter exit 0 fi if [ "$files_ready" != "y" ]; then echo "" print_warning "Please restore the files listed above, then re-run this script." press_enter return 1 fi # Validate structure echo "" if ! validate_restore_structure "$TEMP_DATADIR"; then echo "" print_error "Data structure validation failed" echo "" print_info "Required files:" echo " - ibdata1 (InnoDB system tablespace)" echo " - ib_logfile0 and ib_logfile1 (MySQL 5.7/MariaDB)" echo " OR #innodb_redo/ directory (MySQL 8.0+)" echo " - mysql/ directory (system database)" echo " - / directory (your target database)" echo "" press_enter return 1 fi # Check ownership echo "" print_info "Checking file ownership..." local owner=$(stat -c '%U:%G' "$TEMP_DATADIR/ibdata1" 2>/dev/null || echo "unknown") if [ "$owner" != "mysql:mysql" ]; then print_warning "Files are not owned by mysql:mysql (current: $owner)" echo "" echo -n "Fix ownership now? (y/n, or 0 to cancel): " read -r fix_ownership if [ "$fix_ownership" = "0" ]; then echo "Operation cancelled." press_enter exit 0 fi if [ "$fix_ownership" = "y" ]; then print_info "Running: chown -R mysql:mysql $TEMP_DATADIR" chown -R mysql:mysql "$TEMP_DATADIR" print_success "Ownership updated" fi else print_success "File ownership is correct (mysql:mysql)" fi echo "" press_enter } # Step 3: Allow user to select which database to extract from the restored data # Lists available databases from TEMP_DATADIR and prompts for selection # Validates database directory exists before proceeding # Sets DATABASE_NAME variable for dump operation step3_select_database() { print_banner "Step 3: Select Database to Restore" echo "Available databases in restored data:" echo "" # List directories (exclude system databases and special files) local databases=() while IFS= read -r dir; do local dbname=$(basename "$dir") # Skip system databases and special directories if [[ "$dbname" != "mysql" ]] && [[ "$dbname" != "sys" ]] && \ [[ "$dbname" != "performance_schema" ]] && [[ "$dbname" != "information_schema" ]] && \ [[ "$dbname" != "#"* ]]; then databases+=("$dbname") fi done < <(find "$TEMP_DATADIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null) if [ ${#databases[@]} -eq 0 ]; then print_error "No user databases found in $TEMP_DATADIR" press_enter return 1 fi local i=1 for db in "${databases[@]}"; do echo " $i) $db" i=$((i + 1)) done echo "" echo " 0) Cancel" echo "" echo -n "Select database number (or enter name manually): " read -r selection if [ "$selection" = "0" ]; then echo "Operation cancelled." press_enter exit 0 fi # Check if numeric selection if [[ "$selection" =~ ^[0-9]+$ ]] && [ "$selection" -ge 1 ] && [ "$selection" -le "${#databases[@]}" ]; then DATABASE_NAME="${databases[$((selection - 1))]}" else # Manual entry - validate to prevent path traversal if [[ "$selection" == *"/"* ]] || [[ "$selection" == *".."* ]]; then print_error "Invalid database name: contains invalid characters (/, ..)" press_enter return 1 fi DATABASE_NAME="$selection" fi # Validate database exists if [ ! -d "$TEMP_DATADIR/$DATABASE_NAME" ]; then print_error "Database directory not found: $TEMP_DATADIR/$DATABASE_NAME" press_enter return 1 fi print_success "Selected database: $DATABASE_NAME" echo "" press_enter } # Step 4: Configure InnoDB recovery options and ticket information # Allows user to set InnoDB force recovery level if needed (0-6) # Prompts for optional ticket number for tracking purposes # Shows analysis-based recovery recommendations from error logs step4_configure_options() { print_banner "Step 4: Configure Restore Options" echo "Database: $DATABASE_NAME" echo "Data Directory: $TEMP_DATADIR" echo "" echo "Optional Settings:" echo "" # Ticket number (optional) echo -n "Ticket number (optional, press Enter to skip): " read -r ticket if [ -n "$ticket" ]; then # SECURITY: Validate ticket contains only alphanumeric and common safe chars if [[ "$ticket" =~ ^[a-zA-Z0-9_\-]+$ ]]; then TICKET_NUMBER="$ticket" else print_warning "Ticket number contains invalid characters, skipping" fi fi # Force recovery mode echo "" echo "InnoDB Force Recovery Mode:" echo " 0) No force recovery (default)" echo " 1) Ignore corrupt pages" echo " 2) Prevent background operations" echo " 3) Prevent transaction rollbacks" echo " 4) Prevent insert buffer merge" echo " 5) Skip log redo" echo " 6) Skip page checksums" echo "" echo -n "Select recovery mode (0-6, or press Enter for 0): " read -r recovery_mode if [ -n "$recovery_mode" ]; then # CRITICAL: Validate recovery mode is numeric and in valid range (0-6) if ! { [ "$recovery_mode" -ge 0 ] && [ "$recovery_mode" -le 6 ]; } 2>/dev/null; then print_error "Invalid recovery mode: $recovery_mode" print_warning "Recovery mode must be numeric value between 0 and 6" FORCE_RECOVERY="" elif [ "$recovery_mode" != "0" ]; then FORCE_RECOVERY="$recovery_mode" print_warning "Will use --innodb-force-recovery=$FORCE_RECOVERY" echo "" # Show force recovery warnings and get confirmation if ! warn_force_recovery "$FORCE_RECOVERY"; then echo "" print_info "Recovery mode cancelled. Returning to default (level 0)." FORCE_RECOVERY="" fi fi fi echo "" press_enter } # Step 5: Create SQL dump from the restored database using second MySQL instance # Starts isolated MySQL instance, dumps selected database, validates integrity # Generates .sql file with optional ticket number in filename # Cleans up second instance and provides import instructions step5_create_dump() { print_banner "Step 5: Create SQL Dump" echo "Summary:" echo " Database: $DATABASE_NAME" echo " Data Directory: $TEMP_DATADIR" if [ -n "$TICKET_NUMBER" ]; then echo " Ticket: $TICKET_NUMBER" fi if [ -n "$FORCE_RECOVERY" ]; then echo " Force Recovery: Level $FORCE_RECOVERY" fi echo "" echo "This will:" echo " 1. Start a second MySQL instance using the restored data" echo " 2. Create an SQL dump of the database" echo " 3. Save the dump to the current directory" echo "" print_warning "The second MySQL instance will run on a separate socket." print_warning "Your live MySQL instance will NOT be affected." echo "" echo -n "Proceed with dump creation? (y/n): " read -r confirm if [ "$confirm" != "y" ]; then echo "Operation cancelled." press_enter exit 0 fi echo "" echo "════════════════════════════════════════════════════════════════" echo "STARTING RESTORE PROCESS" echo "════════════════════════════════════════════════════════════════" echo "" # Check disk space before proceeding print_info "Checking available disk space..." if ! check_disk_space "$(pwd)" 500; then press_enter return 1 fi echo "" # Start second instance if ! start_second_instance "$TEMP_DATADIR" "$FORCE_RECOVERY"; then print_error "Failed to start second MySQL instance" echo "" # Provide intelligent recovery guidance (pass selected database name) show_recovery_options "$TEMP_DATADIR" "$FORCE_RECOVERY" "$DATABASE_NAME" press_enter return 1 fi echo "" # Generate output filename - save to parent directory of TEMP_DATADIR # e.g., if TEMP_DATADIR is /home/temp/restore20251210/mysql # then output goes to /home/temp/restore20251210/ local timestamp=$(date +%Y%m%d_%H%M%S) local output_dir="$(dirname "$TEMP_DATADIR")" local output_file="${output_dir}/${DATABASE_NAME}_restored_${timestamp}.sql" if [ -n "$TICKET_NUMBER" ]; then output_file="${output_dir}/${DATABASE_NAME}_ticket${TICKET_NUMBER}_${timestamp}.sql" fi print_info "SQL dump will be saved to: $output_file" echo "" # Create dump if ! dump_database "$TEMP_DATADIR" "$DATABASE_NAME" "$output_file"; then print_error "Failed to create dump" stop_second_instance "$TEMP_DATADIR" press_enter return 1 fi echo "" # Stop second instance stop_second_instance "$TEMP_DATADIR" echo "" echo "════════════════════════════════════════════════════════════════" print_success "RESTORE COMPLETE!" echo "════════════════════════════════════════════════════════════════" echo "" echo "SQL Dump Created: $output_file" echo "" echo "Next Steps:" echo " 1. Verify dump integrity:" echo " grep 'Dump completed on' '$output_file'" echo "" echo " 2. Import to live database:" echo " mysql $DATABASE_NAME < '$output_file'" echo "" echo " 3. Or create fresh database first:" echo " mysql -e 'DROP DATABASE IF EXISTS $DATABASE_NAME;'" echo " mysql -e 'CREATE DATABASE $DATABASE_NAME;'" echo " mysql $DATABASE_NAME < '$output_file'" echo "" press_enter } ################################################################################ # MAIN EXECUTION ################################################################################ # Main entry point: orchestrates the 5-step workflow to extract SQL from restored backup # Detects MySQL location, validates restore files, starts second instance, # creates SQL dump, and provides usage instructions # Handles errors and signal interrupts with proper cleanup main() { # CRITICAL: Check all required dependencies before proceeding if ! check_dependencies; then press_enter exit 1 fi echo "" show_intro echo -n "Continue? (y/n, or 0 to cancel): " read -r start if [ "$start" = "0" ] || [ "$start" != "y" ]; then echo "Operation cancelled." press_enter exit 0 fi # Step 1: Detect live data directory while ! step1_detect_datadir; do echo "" echo -n "Retry? (y/n): " read -r retry if [ "$retry" != "y" ]; then exit 0 fi done # Step 2: Set restore location while ! step2_set_restore_location; do echo "" echo -n "Retry? (y/n): " read -r retry if [ "$retry" != "y" ]; then exit 0 fi done # Step 3: Select database while ! step3_select_database; do echo "" echo -n "Retry? (y/n): " read -r retry if [ "$retry" != "y" ]; then exit 0 fi done # Step 4: Configure options step4_configure_options # Step 5: Create dump step5_create_dump } # Run main function main