#!/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 [--archive NAME] [--yes] [--dry-run] # restore.sh file [--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" 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] $*" "$@" } resolve_archive() { 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 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" 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" } usage() { cat <<'EOF' Usage: restore.sh full [--archive NAME] [--force] [--dry-run] restore.sh db [--archive NAME] [--yes] [--dry-run] restore.sh file [--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 "$@" cmd_full ;; db) shift local dbname="${1:-}" [[ -n "$dbname" ]] || die "db: missing " shift parse_common_flags "$@" cmd_db "$dbname" ;; file) shift local relpath="${1:-}" [[ -n "$relpath" ]] || die "file: missing " shift parse_common_flags "$@" cmd_file "$relpath" ;; *) usage die "unknown command: $cmd" ;; esac } main "$@"