Final review of the restore.sh branch found cmd_full restored every database via `docker exec` before starting the container, which fails immediately in the exact scenario full restore exists for (a freshly rebuilt, stopped container). Reorders to extract -> start container -> restore DBs. Also, while touching cmd_full: - Extract directly into place (cd / && borg extract) instead of staging a full copy under /tmp then cp -a'ing it into $TARGET - halves disk usage and restore time. - Replace `rm -rf "$TARGET"/*` with `find "$TARGET" -mindepth 1 -delete` so dotfiles don't survive a --force wipe. - Add acquire_lock() (shares borg-backup.sh's lockfile so a restore and the nightly backup cron can't run concurrently) and preflight() (passphrase file readable, repo reachable) before any real work in full/db/file. Test isolation: mock borg/docker/mysql/mariadb consistently via a BASH_ENV shim (previously only db-mode's test worked around PATH shadowing by a real docker binary; every mocked test needed it, and a missing `flock` mock broke everything once acquire_lock was added, since flock(1) doesn't exist on macOS). Tests also isolate LOCKFILE and BORG_PASSPHRASE_FILE to throwaway paths. RUNBOOK.md: fix the quarterly drill command (borg extract has no --destination flag, and needs `borg list --short` for a bare archive name), reword the full-restore --force comment which read backwards, and document the MYSQL_ROOT_PASSWORD/RESTORE_LOGDIR env overrides and where restore logs land. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
331 lines
10 KiB
Bash
Executable File
331 lines
10 KiB
Bash
Executable File
#!/bin/bash
|
|
# =============================================================================
|
|
# Restore tooling for the borg-backup.sh / dump_db.sh backup system.
|
|
# Modes:
|
|
# restore.sh full [--archive NAME] [--force] [--dry-run]
|
|
# restore.sh db <db_name> [--archive NAME] [--yes] [--dry-run]
|
|
# restore.sh file <path-within-target> [--archive NAME] [--dest DIR] [--dry-run]
|
|
# restore.sh --list-archives
|
|
# =============================================================================
|
|
|
|
set -euo pipefail
|
|
|
|
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
|
|
umask 077
|
|
|
|
# ========================= CONFIGURATION =========================
|
|
|
|
NAME="borg-2025"
|
|
REPO="/home/srv/files/backups/$NAME"
|
|
TARGET="/home/srv/files/content"
|
|
ARCHIVE_TARGET_PATH="${TARGET#/}"
|
|
|
|
DB_CONTAINER="mariadb"
|
|
DB_START_TIMEOUT=180
|
|
|
|
BORG_PASSPHRASE_FILE="${BORG_PASSPHRASE_FILE:-/root/.borg-passphrase}"
|
|
ROOT_PASSWORD_FILE="${ROOT_PASSWORD_FILE:-/root/.mariadb-root.pw}"
|
|
DUMP_SUBDIR="mariadb/dump"
|
|
|
|
# Same lockfile borg-backup.sh takes (via flock -n 9) before touching $TARGET
|
|
# or the repo, so a restore and the nightly backup cron job can never run
|
|
# concurrently against each other.
|
|
LOCKFILE="${LOCKFILE:-/var/lock/borg-backup.lock}"
|
|
|
|
LOGDIR="${RESTORE_LOGDIR:-/var/log/borg}"
|
|
mkdir -p "$LOGDIR" 2>/dev/null || LOGDIR="/tmp"
|
|
LOGFILE="$LOGDIR/restore-$(date +%Y-%m-%d-%H%M%S).log"
|
|
|
|
export BORG_REPO="$REPO"
|
|
export BORG_PASSCOMMAND="cat $BORG_PASSPHRASE_FILE"
|
|
|
|
# =================================================================
|
|
|
|
log() {
|
|
local line
|
|
line="[$(date '+%F %T')] $*"
|
|
echo "$line"
|
|
echo "$line" >> "$LOGFILE" 2>/dev/null || true
|
|
}
|
|
|
|
step() { echo; echo "=== $* ==="; }
|
|
|
|
die() { log "ERROR: $*"; exit 1; }
|
|
|
|
run_cmd() {
|
|
log "[RUN] $*"
|
|
"$@"
|
|
}
|
|
|
|
acquire_lock() {
|
|
exec 9>"$LOCKFILE"
|
|
if ! flock -n 9; then
|
|
die "another backup or restore is already running (lock held on $LOCKFILE)"
|
|
fi
|
|
}
|
|
|
|
preflight() {
|
|
[[ -r "$BORG_PASSPHRASE_FILE" ]] || die "passphrase file not readable: $BORG_PASSPHRASE_FILE (chmod 600 it or check the path)"
|
|
borg info --lock-wait 60 >/dev/null 2>&1 || die "cannot reach borg repo $REPO (check the passphrase file, permissions, and that the repo exists)"
|
|
}
|
|
|
|
resolve_archive() {
|
|
# Deliberately does NOT filter by hostname (unlike borg-backup.sh's
|
|
# ARCHIVE_GLOB="$(hostname -s)-*" used for prune/list): disaster recovery
|
|
# may need to run from a different host than the one that made the
|
|
# backup, so any archive in the repo is a valid restore candidate.
|
|
if [[ -n "$ARCHIVE_OVERRIDE" ]]; then
|
|
RESOLVED_ARCHIVE="$ARCHIVE_OVERRIDE"
|
|
return 0
|
|
fi
|
|
RESOLVED_ARCHIVE="$(borg list --short --lock-wait 60 2>/dev/null | tail -n1)"
|
|
[[ -n "$RESOLVED_ARCHIVE" ]] || die "no archives found in repo $REPO"
|
|
}
|
|
|
|
list_archives() {
|
|
borg list --lock-wait 60
|
|
}
|
|
|
|
extract_path() {
|
|
local rel_path="$1" dest_dir="$2" archive="$3"
|
|
mkdir -p "$dest_dir"
|
|
( cd "$dest_dir" && run_cmd borg extract --lock-wait 600 "${REPO}::${archive}" "${ARCHIVE_TARGET_PATH}/${rel_path}" )
|
|
echo "${dest_dir%/}/${ARCHIVE_TARGET_PATH}/${rel_path}"
|
|
}
|
|
|
|
cmd_file() {
|
|
local rel_path="$1" dest
|
|
resolve_archive
|
|
dest="${DEST:-/tmp/restore-file-$$}"
|
|
step "Restoring '$rel_path' from archive $RESOLVED_ARCHIVE into $dest"
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
log "[DRY-RUN] would extract ${ARCHIVE_TARGET_PATH}/${rel_path} from ${REPO}::${RESOLVED_ARCHIVE} into $dest"
|
|
return 0
|
|
fi
|
|
preflight
|
|
local final
|
|
final="$(extract_path "$rel_path" "$dest" "$RESOLVED_ARCHIVE" | tail -n1)"
|
|
log "Restored file available at: $final"
|
|
}
|
|
|
|
detect_client() {
|
|
if docker exec "$DB_CONTAINER" sh -c 'command -v mariadb' >/dev/null 2>&1; then
|
|
CLIENT_BIN="mariadb"
|
|
else
|
|
CLIENT_BIN="mysql"
|
|
fi
|
|
}
|
|
|
|
get_root_creds() {
|
|
if [[ -n "${MYSQL_ROOT_PASSWORD:-}" ]]; then
|
|
DB_PASS="$MYSQL_ROOT_PASSWORD"
|
|
elif [[ -r "$ROOT_PASSWORD_FILE" ]]; then
|
|
DB_PASS="$(< "$ROOT_PASSWORD_FILE")"
|
|
else
|
|
die "no root DB password: set MYSQL_ROOT_PASSWORD or create $ROOT_PASSWORD_FILE (chmod 600)"
|
|
fi
|
|
}
|
|
|
|
confirm_or_abort() {
|
|
local dbname="$1" typed
|
|
[[ "$YES" == true ]] && return 0
|
|
echo "This will DROP/overwrite database '$dbname'. Type the database name to confirm:"
|
|
read -r typed
|
|
[[ "$typed" == "$dbname" ]] || die "confirmation did not match '$dbname' - aborting"
|
|
}
|
|
|
|
restore_single_db() {
|
|
local sqlfile="$1" dbname="$2"
|
|
[[ -s "$sqlfile" ]] || die "dump file missing or empty: $sqlfile"
|
|
run_cmd docker exec -e MYSQL_PWD="$DB_PASS" "$DB_CONTAINER" \
|
|
"$CLIENT_BIN" -u root -e "CREATE DATABASE IF NOT EXISTS \`$dbname\`;"
|
|
log "[RUN] docker exec -i ... $CLIENT_BIN -u root $dbname < $sqlfile"
|
|
docker exec -i -e MYSQL_PWD="$DB_PASS" "$DB_CONTAINER" \
|
|
"$CLIENT_BIN" -u root "$dbname" < "$sqlfile"
|
|
}
|
|
|
|
cmd_db() {
|
|
local dbname="$1" dumpfile dest
|
|
resolve_archive
|
|
step "Restoring database '$dbname' from archive $RESOLVED_ARCHIVE"
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
log "[DRY-RUN] would extract ${ARCHIVE_TARGET_PATH}/${DUMP_SUBDIR}/${dbname}.sql from ${REPO}::${RESOLVED_ARCHIVE}"
|
|
log "[DRY-RUN] would DROP/recreate database '$dbname' and import the dump using root credentials"
|
|
return 0
|
|
fi
|
|
confirm_or_abort "$dbname"
|
|
preflight
|
|
dest="/tmp/restore-db-$$"
|
|
dumpfile="$(extract_path "${DUMP_SUBDIR}/${dbname}.sql" "$dest" "$RESOLVED_ARCHIVE" | tail -n1)"
|
|
detect_client
|
|
get_root_creds
|
|
restore_single_db "$dumpfile" "$dbname"
|
|
log "Database '$dbname' restored from $dumpfile"
|
|
rm -rf "$dest"
|
|
}
|
|
|
|
container_running() {
|
|
[[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || echo false)" == "true" ]]
|
|
}
|
|
|
|
container_health() {
|
|
docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \
|
|
"$1" 2>/dev/null || echo unknown
|
|
}
|
|
|
|
wait_for_container() {
|
|
local name="$1" deadline=$((SECONDS + DB_START_TIMEOUT)) health
|
|
while (( SECONDS < deadline )); do
|
|
if container_running "$name"; then
|
|
health="$(container_health "$name")"
|
|
case "$health" in
|
|
healthy|none) log "$name is up (health: $health)"; return 0 ;;
|
|
unhealthy) log "WARNING: $name reports unhealthy" ;;
|
|
esac
|
|
fi
|
|
sleep 3
|
|
done
|
|
return 1
|
|
}
|
|
|
|
start_db() {
|
|
docker start "$DB_CONTAINER" >/dev/null 2>&1 || true
|
|
wait_for_container "$DB_CONTAINER"
|
|
}
|
|
|
|
cmd_full() {
|
|
local dumpdir f dbname
|
|
resolve_archive
|
|
step "Full restore from archive $RESOLVED_ARCHIVE into $TARGET"
|
|
|
|
if [[ -d "$TARGET" ]] && [[ -n "$(ls -A "$TARGET" 2>/dev/null)" ]] && [[ "$FORCE" != true ]]; then
|
|
die "$TARGET is not empty - pass --force to overwrite (existing data will be replaced)"
|
|
fi
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
log "[DRY-RUN] would extract full ${ARCHIVE_TARGET_PATH} tree from ${REPO}::${RESOLVED_ARCHIVE} into $TARGET"
|
|
log "[DRY-RUN] would start $DB_CONTAINER and wait for it to become healthy"
|
|
log "[DRY-RUN] would restore every *.sql dump under ${DUMP_SUBDIR}/ using root credentials"
|
|
return 0
|
|
fi
|
|
|
|
preflight
|
|
|
|
# rm -rf "$TARGET"/* leaves dotfiles behind (stale .nobackup markers, app
|
|
# state) mixed in with the restored tree; find -delete removes everything.
|
|
if [[ -d "$TARGET" ]] && [[ -n "$(ls -A "$TARGET" 2>/dev/null)" ]]; then
|
|
find "${TARGET:?}" -mindepth 1 -delete
|
|
fi
|
|
mkdir -p "$TARGET"
|
|
|
|
# Borg records the absolute path it was given at create time, so
|
|
# extracting from / with the leading-slash-stripped path recreates the
|
|
# tree directly at $TARGET - no staging copy, no doubled disk usage.
|
|
( cd / && run_cmd borg extract --lock-wait 600 "${REPO}::${RESOLVED_ARCHIVE}" "${ARCHIVE_TARGET_PATH}" )
|
|
|
|
dumpdir="${TARGET}/${DUMP_SUBDIR}"
|
|
[[ -d "$dumpdir" ]] || die "no dump directory found after extract: $dumpdir"
|
|
|
|
# The container must be running before any docker exec against it - on a
|
|
# freshly rebuilt server it's created but stopped, so start it first.
|
|
step "Starting $DB_CONTAINER"
|
|
start_db || die "CRITICAL: $DB_CONTAINER did not come up after extract"
|
|
|
|
detect_client
|
|
get_root_creds
|
|
|
|
if [[ -f "${dumpdir}/00-users-and-grants.sql" ]]; then
|
|
step "Restoring users and grants"
|
|
run_cmd docker exec -i -e MYSQL_PWD="$DB_PASS" "$DB_CONTAINER" \
|
|
"$CLIENT_BIN" -u root < "${dumpdir}/00-users-and-grants.sql"
|
|
fi
|
|
|
|
for f in "$dumpdir"/*.sql; do
|
|
[[ -e "$f" ]] || continue
|
|
dbname="$(basename "$f" .sql)"
|
|
[[ "$dbname" == "00-users-and-grants" ]] && continue
|
|
restore_single_db "$f" "$dbname"
|
|
done
|
|
|
|
log "Full restore complete from archive $RESOLVED_ARCHIVE"
|
|
}
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage:
|
|
restore.sh full [--archive NAME] [--force] [--dry-run]
|
|
restore.sh db <db_name> [--archive NAME] [--yes] [--dry-run]
|
|
restore.sh file <path-within-target> [--archive NAME] [--dest DIR] [--dry-run]
|
|
restore.sh --list-archives
|
|
restore.sh -h | --help
|
|
EOF
|
|
}
|
|
|
|
DRY_RUN=false
|
|
FORCE=false
|
|
YES=false
|
|
ARCHIVE_OVERRIDE=""
|
|
DEST=""
|
|
|
|
parse_common_flags() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--archive) ARCHIVE_OVERRIDE="$2"; shift 2 ;;
|
|
--force) FORCE=true; shift ;;
|
|
--yes) YES=true; shift ;;
|
|
--dry-run) DRY_RUN=true; shift ;;
|
|
--dest) DEST="$2"; shift 2 ;;
|
|
*) die "unknown flag: $1" ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
main() {
|
|
local cmd="${1:-}"
|
|
case "$cmd" in
|
|
"")
|
|
usage
|
|
exit 1
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
--list-archives)
|
|
shift
|
|
parse_common_flags "$@"
|
|
list_archives
|
|
;;
|
|
full)
|
|
shift
|
|
parse_common_flags "$@"
|
|
acquire_lock
|
|
cmd_full
|
|
;;
|
|
db)
|
|
shift
|
|
local dbname="${1:-}"
|
|
[[ -n "$dbname" ]] || die "db: missing <db_name>"
|
|
shift
|
|
parse_common_flags "$@"
|
|
acquire_lock
|
|
cmd_db "$dbname"
|
|
;;
|
|
file)
|
|
shift
|
|
local relpath="${1:-}"
|
|
[[ -n "$relpath" ]] || die "file: missing <path>"
|
|
shift
|
|
parse_common_flags "$@"
|
|
acquire_lock
|
|
cmd_file "$relpath"
|
|
;;
|
|
*)
|
|
usage
|
|
die "unknown command: $cmd"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|