Files
backup-agent/dump_db.sh
T
kbe d6f30c506e chore: initial import of backup scripts and design docs
Existing borg-backup.sh/dump_db.sh, old/ reference scripts, and the
brainstormed design spec for restore tooling + runbook.
2026-07-25 18:51:38 +02:00

203 lines
7.1 KiB
Bash

#!/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:-backup}"
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 <t> 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)
log "[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
log "[dump] using mariadb-dump --system=users"
db_exec "$DUMPER" -u "$DB_USER" --system=users \
> "$out" 2>/dev/null
else
# Fallback: full mysql schema dump
log "[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))"