Files
backup-agent/restore.sh
T
kbe 4be1372e4d feat: add full disaster-recovery mode to restore.sh
Adds container_running/container_health/wait_for_container/start_db
helpers and cmd_full(), plus tests. Fixed a delimiter bug in the two
new tests' sed scripts (the literal `${TARGET#/}` replacement text
collided with `#` as the sed delimiter) and relaxed the dry-run
no-external-calls assertion to allow the legitimate borg-list call
from resolve_archive(), mirroring the existing db dry-run test.
2026-07-25 19:23:27 +02:00

299 lines
8.9 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"
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"
}
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 staging 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 restore every *.sql dump under ${DUMP_SUBDIR}/ using root credentials"
log "[DRY-RUN] would start $DB_CONTAINER and wait for it to become healthy"
return 0
fi
staging="/tmp/restore-full-$$"
mkdir -p "$staging"
( cd "$staging" && run_cmd borg extract --lock-wait 600 "${REPO}::${RESOLVED_ARCHIVE}" "${ARCHIVE_TARGET_PATH}" )
mkdir -p "$(dirname "$TARGET")"
rm -rf "${TARGET:?}"/* 2>/dev/null || true
mkdir -p "$TARGET"
run_cmd cp -a "${staging}/${ARCHIVE_TARGET_PATH}/." "$TARGET/"
rm -rf "$staging"
detect_client
get_root_creds
dumpdir="${TARGET}/${DUMP_SUBDIR}"
[[ -d "$dumpdir" ]] || die "no dump directory found after extract: $dumpdir"
if [[ -f "${dumpdir}/00-users-and-grants.sql" ]]; then
step "Restoring users and grants"
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
step "Starting $DB_CONTAINER"
start_db || die "CRITICAL: $DB_CONTAINER did not come up after restore"
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 "$@"
cmd_full
;;
db)
shift
local dbname="${1:-}"
[[ -n "$dbname" ]] || die "db: missing <db_name>"
shift
parse_common_flags "$@"
cmd_db "$dbname"
;;
file)
shift
local relpath="${1:-}"
[[ -n "$relpath" ]] || die "file: missing <path>"
shift
parse_common_flags "$@"
cmd_file "$relpath"
;;
*)
usage
die "unknown command: $cmd"
;;
esac
}
main "$@"