-
-
Save BeshoyAtef/b4d2f99e2980f9e78d59e2824653b367 to your computer and use it in GitHub Desktop.
Bash scripts to backup all databases in a MySQL server with the option to exclude some databases.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
# Combined MySQL Database Management Script | |
# Allows EITHER deleting selected databases OR importing selected/all databases from .sql.gz files. | |
# WARNING: The delete function is EXTREMELY DANGEROUS and IRREVERSIBLE. Use with caution. | |
# REQUIREMENTS | |
## mysql client, gunzip, find, grep, sed (standard Linux tools) | |
## Bash v4+ recommended for mapfile (can be adapted for older Bash if needed) | |
## Script assumes MySQL is running locally. | |
# --- Configuration --- | |
# TODO: Review and update these settings before running! | |
# Set MySQL User (Needs sufficient privileges for chosen action) | |
# - DROP for delete action | |
# - CREATE DATABASE, privileges on imported DBs for import action | |
DBUSER="root" | |
# Set MySQL Password (leave empty for no password or prompt) | |
# WARNING: Hardcoding passwords is insecure. Consider ~/.my.cnf or environment variables. | |
DBPASS="" | |
# Set path to the folder containing the .sql.gz backup files (for IMPORT action) | |
IMPORTPATH="/backups/mysql/" # TODO: Update this path if importing | |
# Set file extension pattern to look for (for IMPORT action) | |
# Used to find files and extract DB name (filename minus this extension) | |
EXT=".sql.gz" | |
# --- Critical System Databases to Exclude (for DELETE action) --- | |
# DO NOT REMOVE SYSTEM DBS UNLESS YOU ARE AN EXPERT. | |
# Add any other databases you MUST keep to this list. | |
excludedDatabases=( mysql information_schema performance_schema sys Database ) | |
# Example: excludedDatabases=( mysql information_schema performance_schema sys Database my_important_app_db ) | |
# --- Global Variables --- | |
# Build MySQL command options dynamically later | |
MYSQL_OPTS="" | |
# --- Script Enhancements --- | |
# Exit immediately if a command exits with a non-zero status. | |
# set -e # Optional: uncomment for stricter error handling, but can sometimes mask issues in loops. | |
# Treat unset variables as an error when substituting. | |
set -u | |
# Ensure pipeline commands fail if any command in the pipe fails | |
set -o pipefail | |
# --- Function Definitions --- | |
# Function to print errors to stderr | |
error_exit() { | |
echo "ERROR: $1" >&2 | |
exit 1 | |
} | |
# Function to setup and test MySQL connection | |
# Sets the global MYSQL_OPTS variable | |
setup_mysql_connection() { | |
echo "Attempting to configure MySQL connection for user '${DBUSER}'..." | |
MYSQL_OPTS="-u${DBUSER}" | |
local connection_ok=false | |
# Prefer ~/.my.cnf if it exists and no password is explicitly set | |
if [[ -z "$DBPASS" && -f "$HOME/.my.cnf" ]]; then | |
# Check if .my.cnf defines user/password for the client section | |
if grep -q "user=" "$HOME/.my.cnf" && grep -q "password=" "$HOME/.my.cnf"; then | |
echo "Using credentials from ~/.my.cnf" | |
# Unset DBPASS if we rely on .my.cnf to avoid confusion later | |
DBPASS="" # Ensure it's empty | |
MYSQL_OPTS="" # Reset opts, rely entirely on .my.cnf default group [client] | |
# Test connection using .my.cnf | |
if mysql ${MYSQL_OPTS} -e "SELECT 1;" &>/dev/null; then | |
connection_ok=true | |
else | |
echo "WARN: ~/.my.cnf found, but connection failed. Check config or try explicit user/pass." >&2 | |
# Fall through to try explicit user/prompt method | |
fi | |
fi | |
fi | |
# If not using .my.cnf or it failed, proceed with configured user/pass/prompt | |
if [[ "$connection_ok" == "false" ]]; then | |
MYSQL_OPTS="-u${DBUSER}" # Re-initialize if .my.cnf failed | |
if [ -n "$DBPASS" ]; then | |
MYSQL_OPTS+=" -p${DBPASS}" | |
# Test connection with password | |
if mysql ${MYSQL_OPTS} -e "SELECT 1;" &>/dev/null; then | |
connection_ok=true | |
fi | |
else | |
# Test connection without password first (if no DBPASS set and .my.cnf wasn't successfully used) | |
if mysql ${MYSQL_OPTS} -e "SELECT 1;" &>/dev/null; then | |
connection_ok=true | |
echo "Connection successful without password." | |
else | |
# If no password provided AND connection failed, add prompt flag and test again | |
echo "Connection without password failed. Will try prompting for password..." | |
MYSQL_OPTS+=" -p" | |
# Test connection with prompt (will hang if no interactive terminal) | |
if mysql ${MYSQL_OPTS} -e "SELECT 1;" &>/dev/null; then | |
connection_ok=true | |
echo "Connection successful (likely after password prompt)." | |
fi | |
fi | |
fi | |
fi | |
if [[ "$connection_ok" == "false" ]]; then | |
echo "------------------------------------------------------------------" >&2 | |
echo "ERROR: Failed to connect to MySQL as user '${DBUSER}'." >&2 | |
echo "Please check:" >&2 | |
echo " - MySQL server status." >&2 | |
echo " - User '${DBUSER}' exists and has correct privileges." >&2 | |
echo " - Password (if required) is correct (set in DBPASS, ~/.my.cnf or entered at prompt)." >&2 | |
echo " - Host connection settings (script assumes local connection)." >&2 | |
echo "------------------------------------------------------------------" >&2 | |
return 1 # Indicate failure | |
fi | |
# Add batch mode for cleaner output during operations | |
MYSQL_OPTS+=" -B" | |
echo "MySQL connection configured successfully." | |
return 0 # Indicate success | |
} | |
# Function to delete selected databases | |
perform_delete() { | |
echo "########################################### Start Database Deletion ##############################################" | |
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!! WARNING !!!!!!!!!!!!!!!!!!!!!!!!!!!!" | |
echo "This function will list databases for potential permanent deletion." | |
echo "It will EXCLUDE the following system/critical databases:" | |
printf " - %s\n" "${excludedDatabases[@]}" | |
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" | |
# Get list of all databases using mapfile for safety with names | |
echo "Fetching list of all databases..." | |
mapfile -t allDatabases < <(mysql $MYSQL_OPTS -Nse "SHOW DATABASES;") | |
# Capture exit codes immediately after mapfile/process substitution | |
local mapfile_exit_code=$? | |
local mysql_show_exit_code=${PIPESTATUS[0]} | |
if [[ $mapfile_exit_code -ne 0 || $mysql_show_exit_code -ne 0 ]]; then | |
echo "ERROR: Failed to retrieve database list (mysql exit: $mysql_show_exit_code, mapfile exit: $mapfile_exit_code)." >&2 | |
return 1 | |
fi | |
# Determine which databases are candidates for dropping | |
candidateDatabases=() | |
echo "Identifying databases eligible for deletion (excluding configured list):" | |
for db in "${allDatabases[@]}"; do | |
isExcluded="false" | |
for excluded in "${excludedDatabases[@]}"; do | |
if [[ "$db" == "$excluded" ]]; then | |
isExcluded="true" | |
break | |
fi | |
done | |
if [[ "$isExcluded" == "false" ]]; then | |
candidateDatabases+=("$db") | |
fi | |
done | |
# Check if any databases are candidates for deletion | |
if [[ ${#candidateDatabases[@]} -eq 0 ]]; then | |
echo "No user databases found to delete (only system/excluded databases exist)." | |
echo "Deletion process finished. No databases were dropped." | |
echo "############################################ End Database Deletion ###############################################" | |
return 0 | |
fi | |
# --- Interactive Selection --- | |
echo | |
echo "Select databases to DELETE:" | |
local i=1 | |
declare -A selected_indices # Use associative array for easy lookup | |
for db in "${candidateDatabases[@]}"; do | |
echo " $i) $db" | |
((i++)) | |
done | |
echo " all) Delete ALL listed databases" | |
echo " n) No deletion (cancel)" | |
local selection=() | |
local choice | |
while true; do | |
read -p "Enter numbers (space-separated), 'all', or 'n': " choice | |
choice=$(echo "$choice" | tr '[:upper:]' '[:lower:]') # Lowercase for easier matching | |
if [[ "$choice" == "n" ]]; then | |
echo "Deletion cancelled by user." | |
echo "############################################ End Database Deletion ###############################################" | |
return 0 | |
elif [[ "$choice" == "all" ]]; then | |
selection=("${candidateDatabases[@]}") | |
echo "Selected ALL eligible databases for deletion." | |
break | |
# Use POSIX character classes for broader compatibility with =~ | |
elif [[ "$choice" =~ ^[[:digit:][:space:]]+$ ]]; then | |
# Validate and collect selected numbers | |
selection=() | |
local invalid_choice=false | |
for num in $choice; do | |
# Ensure it's a valid number within the range | |
if [[ "$num" -ge 1 && "$num" -le ${#candidateDatabases[@]} ]]; then | |
# Avoid duplicates if user enters same number twice | |
# Use :- default value construct for safety with set -u if key doesn't exist yet | |
if [[ -z "${selected_indices[$num]:-}" ]]; then | |
selection+=("${candidateDatabases[$((num-1))]}") | |
selected_indices[$num]=1 # Mark as selected | |
fi | |
else | |
echo "Invalid selection: '$num'. Please enter numbers between 1 and ${#candidateDatabases[@]}." >&2 | |
invalid_choice=true | |
break # Exit inner loop on first invalid number | |
fi | |
done | |
# If no invalid choices were found in the loop, break the outer loop | |
if [[ "$invalid_choice" == "false" ]]; then | |
if [[ ${#selection[@]} -gt 0 ]]; then | |
echo "Selected databases for deletion:" | |
printf " - %s\n" "${selection[@]}" | |
break # Exit while loop | |
else | |
echo "No valid numbers entered. Please try again." >&2 | |
fi | |
fi | |
else | |
echo "Invalid input. Please enter space-separated numbers, 'all', or 'n'." >&2 | |
fi | |
done | |
if [[ ${#selection[@]} -eq 0 ]]; then | |
echo "No databases selected for deletion." | |
echo "############################################ End Database Deletion ###############################################" | |
return 0 | |
fi | |
# FINAL CONFIRMATION for selected databases | |
echo | |
echo "!!!!!!!!!!!!!!!!!!!!!!!! FINAL WARNING !!!!!!!!!!!!!!!!!!!!!!!!!" >&2 | |
echo "The following ${#selection[@]} databases WILL BE PERMANENTLY DELETED:" >&2 | |
printf " -> %s\n" "${selection[@]}" >&2 | |
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" >&2 | |
read -p "Type EXACTLY 'YES' (all uppercase) to confirm deletion: " confirmation | |
if [[ "$confirmation" != "YES" ]]; then | |
echo "Confirmation not received. No databases will be deleted." | |
echo "############################################ End Database Deletion ###############################################" | |
return 1 | |
fi | |
# Proceed with deletion | |
echo "Confirmation received. Proceeding with database deletion..." | |
local errors_occurred=0 | |
for db in "${selection[@]}"; do | |
echo "$(date): Attempting to drop database: '$db'" | |
# Use backticks for safety with special characters in DB names | |
local drop_sql="DROP DATABASE \`$db\`;" | |
if mysql $MYSQL_OPTS -e "$drop_sql"; then | |
echo "$(date): Successfully dropped database: '$db'" | |
else | |
# Log error but continue trying to drop others | |
echo "$(date): ERROR: Failed to drop database: '$db'. Check permissions or if it's in use." >&2 | |
errors_occurred=1 | |
fi | |
done | |
echo "$(date): Database deletion process finished." | |
if [[ $errors_occurred -ne 0 ]]; then | |
echo "WARNING: One or more errors occurred during deletion. Please review logs." >&2 | |
echo "############################################ End Database Deletion (with errors) #####################################" | |
return 1 | |
fi | |
echo "############################################ End Database Deletion ###############################################" | |
return 0 | |
} | |
# Function to import databases (decompress directly via pipe) | |
perform_import() { | |
echo "########################################### Start Database Import (Piped Decompression) ##############################################" | |
local STARTDATE | |
STARTDATE=$(date) | |
echo "$STARTDATE: Starting database import process." | |
echo "Searching for files matching '*${EXT}' in directory: $IMPORTPATH" | |
# Check import directory | |
if [[ ! -d "$IMPORTPATH" ]]; then | |
echo "ERROR: Import directory '$IMPORTPATH' not found." >&2 | |
echo "############################################ End Database Import (Error) ##############################################" | |
return 1 | |
fi | |
# Find backup files using find and store in an array (mapfile is safer for special chars) | |
local -a backup_files | |
# *** CORRECTED ERROR HANDLING FOR mapfile + process substitution *** | |
local mapfile_exit_code | |
local find_exit_code | |
# Use null delimiter for extreme safety with filenames containing newlines (rare but possible) | |
mapfile -d '' -t backup_files < <(find "$IMPORTPATH" -maxdepth 1 -name "*${EXT}" -print0) | |
mapfile_exit_code=$? # Capture mapfile's exit status IMMEDIATELY | |
find_exit_code=${PIPESTATUS[0]} # Capture find's exit status from process substitution IMMEDIATELY | |
# Check if either mapfile or find failed | |
# Note: find_exit_code might be non-zero if find fails (e.g., bad permissions on IMPORTPATH) | |
# Note: mapfile_exit_code might be non-zero if mapfile itself has an issue (less common) | |
if [[ $mapfile_exit_code -ne 0 || $find_exit_code -ne 0 ]]; then | |
echo "ERROR: Failed to list files in '$IMPORTPATH'. Check permissions or find command (find exit: $find_exit_code, mapfile exit: $mapfile_exit_code)." >&2 | |
echo "############################################ End Database Import (Error) ##############################################" | |
return 1 | |
fi | |
# *** END CORRECTION *** | |
if [[ ${#backup_files[@]} -eq 0 ]]; then | |
echo "No backup files found matching '*${EXT}' in '$IMPORTPATH'." | |
echo "############################################ End Database Import ###############################################" | |
return 0 | |
fi | |
# --- Interactive Selection --- | |
echo | |
echo "Select backup files to import:" | |
local i=1 | |
for file in "${backup_files[@]}"; do | |
# Display only the basename for clarity | |
echo " $i) $(basename "$file")" | |
((i++)) | |
done | |
echo " all) Import ALL listed files" | |
echo " n) No import (cancel)" | |
local -a files_to_process | |
local -A selected_indices # Use associative array for easy lookup | |
local choice | |
while true; do | |
read -p "Enter numbers (space-separated), 'all', or 'n': " choice | |
choice=$(echo "$choice" | tr '[:upper:]' '[:lower:]') # Lowercase for easier matching | |
if [[ "$choice" == "n" ]]; then | |
echo "Import cancelled by user." | |
echo "############################################ End Database Import ###############################################" | |
return 0 | |
elif [[ "$choice" == "all" ]]; then | |
files_to_process=("${backup_files[@]}") | |
echo "Selected ALL backup files for import." | |
break | |
# Use POSIX character classes for broader compatibility with =~ | |
elif [[ "$choice" =~ ^[[:digit:][:space:]]+$ ]]; then | |
# Validate and collect selected numbers | |
files_to_process=() | |
local invalid_choice=false | |
for num in $choice; do | |
# Ensure it's a valid number within the range | |
if [[ "$num" -ge 1 && "$num" -le ${#backup_files[@]} ]]; then | |
# Avoid duplicates if user enters same number twice | |
# Use :- default value construct for safety with set -u if key doesn't exist yet | |
if [[ -z "${selected_indices[$num]:-}" ]]; then | |
# Store the *full path* from the original array | |
files_to_process+=("${backup_files[$((num-1))]}") | |
selected_indices[$num]=1 # Mark as selected | |
fi | |
else | |
echo "Invalid selection: '$num'. Please enter numbers between 1 and ${#backup_files[@]}." >&2 | |
invalid_choice=true | |
break # Exit inner loop on first invalid number | |
fi | |
done | |
# If no invalid choices were found in the loop, break the outer loop | |
if [[ "$invalid_choice" == "false" ]]; then | |
if [[ ${#files_to_process[@]} -gt 0 ]]; then | |
echo "Selected files for import:" | |
for file in "${files_to_process[@]}"; do | |
echo " - $(basename "$file")" # Display only basename | |
done | |
break # Exit while loop | |
else | |
echo "No valid numbers entered. Please try again." >&2 | |
fi | |
fi | |
else | |
echo "Invalid input. Please enter space-separated numbers, 'all', or 'n'." >&2 | |
fi | |
done | |
if [[ ${#files_to_process[@]} -eq 0 ]]; then | |
echo "No files selected for import." | |
echo "############################################ End Database Import ###############################################" | |
return 0 | |
fi | |
echo | |
echo "Starting processing of selected files..." | |
local errors_occurred=0 | |
# Process only the selected files | |
for filepath in "${files_to_process[@]}"; do | |
local filename | |
filename=$(basename "$filepath") | |
echo "-------------------------------------------------" | |
echo "$(date): Processing file: $filename" | |
# Extract database name: filename minus the extension | |
# Assumes filenames like: databasename.sql.gz or databasename_timestamp.sql.gz | |
# It will extract 'databasename' or 'databasename_timestamp' respectively. Adjust if needed. | |
local dbname | |
# Use bash parameter expansion to remove the configured extension from the end | |
dbname="${filename%$EXT}" | |
if [[ -z "$dbname" ]]; then | |
echo "$(date): ERROR: Could not extract database name from '$filename' (after removing '$EXT'). Skipping." >&2 | |
errors_occurred=1 | |
continue | |
fi | |
# Optional: Sanitize dbname further if needed (e.g., remove other patterns) | |
# dbname=$(echo "$dbname" | sed 's/_timestamp_pattern//') # Example | |
echo "$(date): Target database name: '$dbname'" | |
# Check if database exists / Create database | |
# Use USE command to check existence, safer than SHOW DATABASES | grep | |
# Suppress stderr for the check, handle failure explicitly | |
if mysql $MYSQL_OPTS -e "USE \`$dbname\`;" 2>/dev/null; then | |
echo "$(date): Database '$dbname' already exists. Importing into existing database." | |
# Add prompt here if overwrite confirmation is desired | |
# read -p "Database '$dbname' exists. Overwrite? (y/N): " overwrite_confirm | |
# if [[ "${overwrite_confirm,,}" != "y" ]]; then | |
# echo "$(date): Skipping import for '$dbname'." | |
# continue | |
# fi | |
else | |
# Check if failure was due to non-existence (exit code 1 usually) vs other connection error | |
local mysql_check_exit_code=$? | |
# Crude check, might vary slightly across mysql versions | |
if [[ $mysql_check_exit_code -eq 1 ]]; then | |
echo "$(date): Database '$dbname' does not exist. Creating..." | |
local create_sql="CREATE DATABASE \`$dbname\`;" | |
if mysql $MYSQL_OPTS -e "$create_sql"; then | |
echo "$(date): Database '$dbname' created successfully." | |
else | |
echo "$(date): ERROR: Failed to create database '$dbname'. Check MySQL user permissions. Skipping import for this file." >&2 | |
errors_occurred=1 | |
continue # Skip to the next file | |
fi | |
else | |
echo "$(date): ERROR: Failed to check status of database '$dbname' (MySQL exit code: $mysql_check_exit_code). Check connection/permissions. Skipping import." >&2 | |
errors_occurred=1 | |
continue # Skip to next file | |
fi | |
fi | |
# Import using a direct pipe (MUCH FASTER) | |
echo "$(date): Decompressing and importing '$filename' into database '$dbname' via pipe..." | |
if gunzip -c "$filepath" | mysql $MYSQL_OPTS "$dbname"; then | |
# Pipe succeeded (both gunzip and mysql returned 0) | |
echo "$(date): Successfully imported database '$dbname'." | |
else | |
# Pipe failed. Check PIPESTATUS to see which command failed. | |
# PIPESTATUS is an array holding exit status of commands in the last pipe. | |
# Requires bash. Index 0 is gunzip, Index 1 is mysql. | |
local pipe_status=("${PIPESTATUS[@]}") | |
local gunzip_exit_code=${pipe_status[0]} | |
local mysql_exit_code=${pipe_status[1]} | |
echo "$(date): ERROR: Import failed for database '$dbname'." >&2 | |
if [[ $gunzip_exit_code -ne 0 ]]; then | |
echo " -> Decompression (gunzip) failed with exit code: $gunzip_exit_code." >&2 | |
fi | |
if [[ $mysql_exit_code -ne 0 ]]; then | |
echo " -> Database import (mysql) failed with exit code: $mysql_exit_code." >&2 | |
echo " -> Check MySQL logs and ensure user '$DBUSER' has necessary privileges on '$dbname'." >&2 | |
fi | |
errors_occurred=1 | |
# Optional: Decide whether to continue with next file or stop on error | |
# continue # Continue processing next file | |
# break # Stop processing files on first error | |
fi | |
done | |
# Final message for import | |
echo "-------------------------------------------------" | |
echo "$(date): Database import process finished." | |
echo "Started at: $STARTDATE" | |
if [[ $errors_occurred -ne 0 ]]; then | |
echo "WARNING: One or more errors occurred during import. Please review logs above." >&2 | |
echo "############################################ End Database Import (with errors) ##############################################" | |
return 1 | |
fi | |
echo "############################################ End Database Import ###############################################" | |
return 0 | |
} | |
# --- Main Script Execution --- | |
echo "==================================================" | |
echo " MySQL Database Management Script" | |
echo "==================================================" | |
echo | |
# Check prerequisites (basic check) | |
for cmd in mysql gunzip find grep sed date basename mapfile tr; do | |
if ! command -v "$cmd" &> /dev/null; then | |
# Check specifically for mapfile if tr exists (as mapfile is Bash builtin >= 4) | |
if [[ "$cmd" == "mapfile" && $(command -v tr) ]]; then | |
bash_major_version=$(bash --version | head -n1 | sed 's/.*version \([0-9]*\)\..*/\1/') | |
# Default to 0 if sed fails to extract version | |
if [[ ${bash_major_version:-0} -lt 4 ]]; then | |
error_exit "Required command '$cmd' (Bash builtin) requires Bash version 4+. You have Bash $bash_major_version." | |
else | |
# If mapfile exists but command -v fails, something weird is happening, but proceed cautiously. | |
# Or it might indicate a non-Bash shell identified as bash. | |
echo "WARN: 'command -v mapfile' failed, but Bash version seems >= 4. Proceeding..." >&2 | |
fi | |
else | |
error_exit "Required command '$cmd' not found. Please install it or add it to your PATH." | |
fi | |
fi | |
done | |
# Setup and Test Connection FIRST | |
if ! setup_mysql_connection; then | |
# Error message already printed by the function | |
exit 1 | |
fi | |
echo | |
# Ask user for action | |
echo "Please choose an action:" | |
echo " 1) DELETE selected Databases (USE WITH EXTREME CAUTION!)" | |
echo " 2) IMPORT selected/all Databases from '$IMPORTPATH' (*${EXT})" | |
echo " *) Any other key to Exit" | |
echo | |
read -p "Enter your choice (1 or 2): " choice | |
echo # Newline for readability | |
# Execute chosen action | |
last_exit_code=0 | |
case "$choice" in | |
1) | |
perform_delete | |
last_exit_code=$? | |
;; | |
2) | |
perform_import | |
last_exit_code=$? | |
;; | |
*) | |
echo "Invalid choice or exiting." | |
last_exit_code=0 | |
;; | |
esac | |
echo | |
echo "Script finished." | |
exit $last_exit_code |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Updated to include also the reimporting if needed