#!/bin/bash # ============================================================================= # Consistent per-database MariaDB/MySQL dumps from a Docker container. # Writes to a staging dir, verifies, then swaps atomically. # ============================================================================= # CHANGES vs. original: # • Uses mysqldump --users (MySQL 8.0.31+) instead of MariaDB-only # --system=users for dumping user accounts as CREATE USER / GRANT. # • Falls back to full mysql schema dump on older MySQL / MariaDB. # • Works with both mysql:8.2.0 and MariaDB containers. # ============================================================================= set -euo pipefail umask 077 # ========================= CONFIGURATION ========================= CONTAINER="${DOCKER_CONTAINER_NAME:-mariadb}" PASSWORD_FILE="${PASSWORD_FILE:-/root/.mariadb-backup.pw}" DB_USER="${DB_USER:-root}" SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" DUMP_DIR="${DUMP_DIR:-dump}" [[ "$DUMP_DIR" == /* ]] || DUMP_DIR="$SCRIPT_DIR/$DUMP_DIR" STAGING="${DUMP_DIR}.staging" DEDUP_FRIENDLY="${DEDUP_FRIENDLY:-1}" EXCLUDED_SCHEMAS="'information_schema','mysql','performance_schema','sys'" # ================================================================= die() { echo "ERROR: $*" >&2 exit 1 } warn() { echo "WARNING: $*" >&2; } cleanup() { rm -rf "$STAGING"; } trap cleanup EXIT # --- Preconditions ------------------------------------------------------ command -v docker >/dev/null 2>&1 || die "docker not found in PATH" [[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" == "true" ]] || die "container '$CONTAINER' is not running" if [[ -n "${MYSQL_ROOT_PASSWORD:-}" ]]; then DB_PASS="$MYSQL_ROOT_PASSWORD" elif [[ -r "$PASSWORD_FILE" ]]; then DB_PASS="$(<"$PASSWORD_FILE")" else die "no password: set MYSQL_ROOT_PASSWORD or create $PASSWORD_FILE (chmod 600)" fi # Detect client binaries (MariaDB 11+ renamed them; MySQL keeps mysql/*). if docker exec "$CONTAINER" sh -c 'command -v mariadb-dump' >/dev/null 2>&1; then CLIENT=mariadb DUMPER=mariadb-dump CLIENT_BIN="mariadb" else CLIENT=mysql DUMPER=mysqldump CLIENT_BIN="mysql" fi # MYSQL_PWD keeps the password out of the process list. db_exec() { docker exec -e MYSQL_PWD="$DB_PASS" "$CONTAINER" "$@"; } sql() { db_exec "$CLIENT_BIN" -u "$DB_USER" -N -B -e "$1"; } DUMP_OPTS=( -u "$DB_USER" --single-transaction --quick --routines --triggers --events --hex-blob --max-allowed-packet=1G --default-character-set=utf8mb4 --skip-dump-date ) [[ "$DEDUP_FRIENDLY" == "1" ]] && DUMP_OPTS+=(--skip-extended-insert) # --- Warn about non-transactional tables -------------------------------- NON_TXN="$(sql " SELECT CONCAT(table_schema,'.',table_name,' [',engine,']') FROM information_schema.tables WHERE engine IS NOT NULL AND engine NOT IN ('InnoDB','MEMORY','SEQUENCE') AND table_schema NOT IN ($EXCLUDED_SCHEMAS);" || true)" if [[ -n "$NON_TXN" ]]; then warn "non-transactional tables found - NOT covered by --single-transaction:" echo "$NON_TXN" | sed 's/^/ /' >&2 warn "convert them: ALTER TABLE ENGINE=InnoDB;" fi # --- Database list ------------------------------------------------------ mapfile -t DATABASES < <(sql " SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ($EXCLUDED_SCHEMAS) ORDER BY schema_name;" | tr -d '\r') ((${#DATABASES[@]} > 0)) || die "no databases returned - check credentials" echo "Found ${#DATABASES[@]} database(s) to dump" # --- Free space sanity check -------------------------------------------- if [[ -d "$DUMP_DIR" ]]; then need_kb=$(($(du -sk "$DUMP_DIR" | cut -f1) * 3 / 2)) free_kb=$(df -Pk "$(dirname "$DUMP_DIR")" | awk 'NR==2{print $4}') ((free_kb > need_kb)) || die "not enough free space (~${need_kb}K needed, ${free_kb}K free)" fi # --- Detect user-dump method -------------------------------------------- # mysqldump --users : MySQL 8.0.31+ (dumps CREATE USER + GRANT, no DB tables) # mariadb-dump --system=users : MariaDB 10.5+ (same idea, different flag) # fallback : dump full mysql schema (larger, includes grant tables) dump_users_and_grants() { local out="$1" if "$DUMPER" --help 2>&1 | grep -q -- ' --users '; then # MySQL 8.0.31+: --users dumps CREATE USER + GRANT statements only # (when no --databases / --all-databases is given) echo "[dump] using mysqldump --users (MySQL 8.0.31+ style)" db_exec "$DUMPER" -u "$DB_USER" --users \ >"$out" 2>/dev/null elif docker exec "$CONTAINER" sh -c "command -v mariadb-dump" >/dev/null 2>&1 && docker exec "$CONTAINER" mariadb-dump --help 2>&1 | grep -q -- ' --system '; then # MariaDB: --system=users echo "[dump] using mariadb-dump --system=users" db_exec "$DUMPER" -u "$DB_USER" --system=users \ >"$out" 2>/dev/null else # Fallback: full mysql schema dump echo "[dump] falling back to full mysql schema dump (older server)" db_exec "$DUMPER" "${DUMP_OPTS[@]}" --databases mysql \ >"$out" 2>/dev/null fi } # --- Dump into staging, verify each file -------------------------------- rm -rf "$STAGING" mkdir -p "$STAGING" failed=() verify_dump() { local f="$1" [[ -s "$f" ]] || { warn "$(basename "$f"): empty" return 1 } # mysqldump ends with "-- Dump completed on ..."; mysqlpump ends similarly. # For the mysql-schema fallback, just check non-empty (it has no marker). if grep -q '^-- Dump completed' "$f" 2>/dev/null; then return 0 fi # Fallback files (full mysql dump) won't have the marker — accept non-empty. if [[ "$(basename "$f")" == 00-mysql-schema.sql ]]; then return 0 fi warn "$(basename "$f"): no completion marker" return 1 } # Users and grants first USERS_FILE="$STAGING/00-users-and-grants.sql" echo "Dumping users and grants" if dump_users_and_grants "$USERS_FILE" && verify_dump "$USERS_FILE"; then echo " -> ok ($(du -h "$USERS_FILE" | cut -f1))" else warn "users-and-grants dump failed or empty" failed+=("users-and-grants") rm -f "$USERS_FILE" fi for db in "${DATABASES[@]}"; do out="$STAGING/${db}.sql" printf 'Dumping %-32s ' "$db" if db_exec "$DUMPER" "${DUMP_OPTS[@]}" -- "$db" >"$out" && verify_dump "$out"; then printf 'ok (%s)\n' "$(du -h "$out" | cut -f1)" else printf 'FAILED\n' failed+=("$db") rm -f "$out" fi done # --- Abort before touching the good copy -------------------------------- if ((${#failed[@]} > 0)); then die "${#failed[@]} dump(s) failed: ${failed[*]} - previous dumps left intact" fi # --- Atomic swap -------------------------------------------------------- rm -rf "${DUMP_DIR}.old" [[ -d "$DUMP_DIR" ]] && mv "$DUMP_DIR" "${DUMP_DIR}.old" mv "$STAGING" "$DUMP_DIR" rm -rf "${DUMP_DIR}.old" trap - EXIT echo "-----------------------------------------------------------" echo "All ${#DATABASES[@]} database(s) dumped and verified" echo "Location: $DUMP_DIR ($(du -sh "$DUMP_DIR" | cut -f1))"