Files
backup-agent/tests/test_restore.sh
T
kbeandClaude Sonnet 5 592ef45bab fix: start mariadb before restoring DBs in cmd_full, add lock/preflight
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>
2026-07-25 19:42:56 +02:00

219 lines
7.9 KiB
Bash
Executable File

#!/bin/bash
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
RESTORE="$HERE/../restore.sh"
FAILURES=0
assert_eq() {
local expected="$1" actual="$2" msg="$3"
if [[ "$expected" != "$actual" ]]; then
echo "FAIL: $msg (expected '$expected', got '$actual')"
FAILURES=$((FAILURES + 1))
else
echo "PASS: $msg"
fi
}
assert_contains() {
local haystack="$1" needle="$2" msg="$3"
if [[ "$haystack" != *"$needle"* ]]; then
echo "FAIL: $msg (expected to contain '$needle', got '$haystack')"
FAILURES=$((FAILURES + 1))
else
echo "PASS: $msg"
fi
}
test_help_exits_zero() {
local out rc
out="$(bash "$RESTORE" -h)"
rc=$?
assert_eq "0" "$rc" "help exits 0"
assert_contains "$out" "Usage:" "help prints usage"
}
test_no_args_exits_one() {
local rc
bash "$RESTORE" >/dev/null 2>&1
rc=$?
assert_eq "1" "$rc" "no args exits 1"
}
test_unknown_command_exits_one() {
local rc
bash "$RESTORE" bogus >/dev/null 2>&1
rc=$?
assert_eq "1" "$rc" "unknown command exits 1"
}
source "$HERE/lib/setup_mocks.sh"
# Every test below isolates two things beyond PATH: BASH_ENV forces borg/
# docker/mysql/mariadb to the mock regardless of what's really installed on
# this machine's PATH (restore.sh's own hardened PATH would otherwise shadow
# the mock with any real binary in a system dir), and LOCKFILE/
# BORG_PASSPHRASE_FILE point at throwaway files so acquire_lock()/preflight()
# never touch real system paths like /var/lock or /root.
test_list_archives() {
local mockdir out
mockdir="$(mktemp -d)"
setup_mock_bin "$mockdir"
out="$(PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" bash "$RESTORE" --list-archives)"
assert_contains "$out" "host-2026-01-01T00-00-00" "list-archives shows first archive"
assert_contains "$out" "host-2026-06-01T00-00-00" "list-archives shows latest archive"
rm -rf "$mockdir"
}
test_file_restore_extracts_to_dest() {
local mockdir dest out final passfile lockfile
mockdir="$(mktemp -d)"
dest="$(mktemp -d)"
setup_mock_bin "$mockdir"
passfile="$mockdir/passphrase"
lockfile="$mockdir/lock"
echo "s3cr3t" > "$passfile"
out="$(PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" \
BORG_PASSPHRASE_FILE="$passfile" LOCKFILE="$lockfile" \
bash "$RESTORE" file photos/img.jpg --dest "$dest")"
final="$dest/home/srv/files/content/photos/img.jpg"
assert_contains "$out" "Restored file available at: $final" "file mode reports final path"
if [[ -f "$final" ]]; then
echo "PASS: extracted file exists on disk"
else
echo "FAIL: extracted file missing at $final"
FAILURES=$((FAILURES + 1))
fi
rm -rf "$mockdir" "$dest"
}
test_file_restore_dry_run_makes_no_borg_call() {
local mockdir dest
mockdir="$(mktemp -d)"
dest="$(mktemp -d)"
setup_mock_bin "$mockdir"
PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" LOCKFILE="$mockdir/lock" \
bash "$RESTORE" file photos/img.jpg --dest "$dest" --dry-run >/dev/null
if [[ -s "$mockdir/mock.log" ]] && grep -q "^borg extract" "$mockdir/mock.log"; then
echo "FAIL: dry-run invoked borg extract"
FAILURES=$((FAILURES + 1))
else
echo "PASS: dry-run made no borg extract call"
fi
rm -rf "$mockdir" "$dest"
}
test_db_restore_with_yes_runs_full_sequence() {
local mockdir out passfile
mockdir="$(mktemp -d)"
setup_mock_bin "$mockdir"
echo "rootpass" > "$mockdir/rootpw"
passfile="$mockdir/passphrase"
echo "s3cr3t" > "$passfile"
out="$(PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" \
ROOT_PASSWORD_FILE="$mockdir/rootpw" BORG_PASSPHRASE_FILE="$passfile" \
LOCKFILE="$mockdir/lock" \
bash "$RESTORE" db shopdb --yes </dev/null)"
assert_contains "$out" "Database 'shopdb' restored from" "db mode reports success"
if grep -q "CREATE DATABASE IF NOT EXISTS" "$mockdir/mock.log"; then
echo "PASS: db mode issued CREATE DATABASE"
else
echo "FAIL: db mode did not issue CREATE DATABASE"
FAILURES=$((FAILURES + 1))
fi
rm -rf "$mockdir"
}
test_db_restore_dry_run_skips_confirmation_and_calls() {
local mockdir out
mockdir="$(mktemp -d)"
setup_mock_bin "$mockdir"
out="$(PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" LOCKFILE="$mockdir/lock" \
bash "$RESTORE" db shopdb --dry-run </dev/null)"
assert_contains "$out" "DRY-RUN" "db dry-run prints DRY-RUN plan"
# cmd_db calls resolve_archive() (a borg list call) before checking DRY_RUN,
# same as cmd_file does, so mock.log legitimately gets a "borg list" entry.
# What must NOT happen in dry-run is any docker/mysql/mariadb interaction.
if grep -qE "^(docker|mysql|mariadb)" "$mockdir/mock.log"; then
echo "FAIL: dry-run invoked a docker/db-client mock binary"
FAILURES=$((FAILURES + 1))
else
echo "PASS: dry-run made no docker/db-client calls"
fi
rm -rf "$mockdir"
}
test_db_restore_aborts_on_wrong_confirmation() {
local mockdir rc
mockdir="$(mktemp -d)"
setup_mock_bin "$mockdir"
set +e
echo "wrongname" | PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" LOCKFILE="$mockdir/lock" \
bash "$RESTORE" db shopdb >/dev/null 2>&1
rc=$?
set -e
assert_eq "1" "$rc" "db mode aborts on mismatched confirmation"
rm -rf "$mockdir"
}
test_full_restore_refuses_nonempty_target_without_force() {
local mockdir target out rc
mockdir="$(mktemp -d)"
target="$(mktemp -d)"
touch "$target/existing-file"
setup_mock_bin "$mockdir"
set +e
out="$(PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" LOCKFILE="$mockdir/lock" bash -c '
sed "s#^TARGET=\"/home/srv/files/content\"#TARGET=\"'"$target"'\"#; s@^ARCHIVE_TARGET_PATH=.*@ARCHIVE_TARGET_PATH=\"\${TARGET#/}\"@" "'"$RESTORE"'" > "'"$mockdir"'/restore_patched.sh"
bash "'"$mockdir"'/restore_patched.sh" full
' 2>&1)"
rc=$?
set -e
assert_eq "1" "$rc" "full mode refuses non-empty target without --force"
assert_contains "$out" "is not empty - pass --force" "refusal message names the reason"
rm -rf "$mockdir" "$target"
}
test_full_restore_dry_run_makes_no_calls() {
local mockdir target out
mockdir="$(mktemp -d)"
target="$(mktemp -d)"
setup_mock_bin "$mockdir"
out="$(PATH="$mockdir:$PATH" BASH_ENV="$(mock_bash_env "$mockdir")" LOCKFILE="$mockdir/lock" bash -c '
sed "s#^TARGET=\"/home/srv/files/content\"#TARGET=\"'"$target"'\"#; s@^ARCHIVE_TARGET_PATH=.*@ARCHIVE_TARGET_PATH=\"\${TARGET#/}\"@" "'"$RESTORE"'" > "'"$mockdir"'/restore_patched.sh"
bash "'"$mockdir"'/restore_patched.sh" full --dry-run
')"
assert_contains "$out" "DRY-RUN" "full dry-run prints DRY-RUN plan"
# cmd_full calls resolve_archive() (a borg list call) before checking
# DRY_RUN, same as cmd_db/cmd_file, so mock.log legitimately gets a
# "borg list" entry. What must NOT happen in dry-run is a borg extract,
# or any docker/db-client interaction.
if grep -qE "^(borg extract|docker|mysql|mariadb)" "$mockdir/mock.log" 2>/dev/null; then
echo "FAIL: full dry-run invoked borg extract or a docker/db-client mock binary"
FAILURES=$((FAILURES + 1))
else
echo "PASS: full dry-run made no borg extract or docker/db-client calls"
fi
rm -rf "$mockdir" "$target"
}
test_help_exits_zero
test_no_args_exits_one
test_unknown_command_exits_one
test_list_archives
test_file_restore_extracts_to_dest
test_file_restore_dry_run_makes_no_borg_call
test_db_restore_with_yes_runs_full_sequence
test_db_restore_dry_run_skips_confirmation_and_calls
test_db_restore_aborts_on_wrong_confirmation
test_full_restore_refuses_nonempty_target_without_force
test_full_restore_dry_run_makes_no_calls
echo "-----"
if [[ "$FAILURES" -gt 0 ]]; then
echo "$FAILURES failure(s)"
exit 1
fi
echo "All tests passed"