This is what's actually needed for a DigitalOcean -> IONOS Asterisk migration (item #1 of the user's original 4-item list): move the whole stack to different hardware entirely, not restore onto the box the offsite mirror already targets. dr_bringup_kopia.sh only ever scanned DEST_NAMES for restorable snapshots — those are always local filesystem Kopia repos (services/backup.sh creates them with `repository create filesystem --path=...`), meaning they only exist on whichever box originally ran the backup. On a genuinely new box, every one of them fails to connect and there's nothing left to restore from — the script's own header comment only covered the case where "the spare box IS the box the primary's mirror targets" (i.e. already holds a copy of the repo data), not a fresh, unrelated box. Fix: also try REMOTE_TYPE/REMOTE_ARGS (the offsite Backblaze/S3 mirror, if configured) as a same-shaped destination named "offsite", reusing DEST_default_PASSWORD since sync-to always mirrors that exact same encrypted repo. Connects once into a fresh local config file scoped to this DR run, then folds into the existing per-destination scan/restore loop unchanged — "offsite" just becomes another entry in _DEST_ARR. Documented the actual migration workflow in the header comment, including the BACKUP_CONF override so copying the old box's backup.conf over doesn't clobber the new box's own freshly-configured one. Verified against the real script (not a reimplementation) with mocked kopia/docker binaries and a crafted backup.conf, covering: local dest unreachable + offsite connects successfully (the actual migration shape) with correct service/path discovery; a real (non---list) restore run confirmed it selects the latest of multiple snapshots by startTime and issues the correct `kopia restore <snapshot> <path>` call; offsite connect failing (bad REMOTE_ARGS) warns and degrades to "no restorable sources found" instead of crashing; REMOTE_TYPE=none skips the new code path entirely with no behavior change (regression check against the pre-existing local-only case). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
287 lines
13 KiB
Bash
287 lines
13 KiB
Bash
#!/bin/bash
|
|
# extras/dr_bringup_kopia.sh — non-interactive disaster-recovery bring-up.
|
|
# Installed to ~/docker/backup/dr_bringup.sh by the backup service installer.
|
|
#
|
|
# Restores the LATEST snapshot of every backed-up service (or one chosen
|
|
# service) straight into place and brings it up with `docker compose up -d`.
|
|
# Meant to run unattended on a cold spare box during a real outage — unlike
|
|
# restore_kopia.sh (one service at a time, interactive prompts per step),
|
|
# this walks every discovered service with no prompts so it can complete a
|
|
# full-stack recovery in one command.
|
|
#
|
|
# sudo ./dr_bringup.sh restore + start every service
|
|
# sudo ./dr_bringup.sh --service NAME restore + start one service
|
|
# sudo ./dr_bringup.sh --list list restorable sources and exit
|
|
# sudo ./dr_bringup.sh --dry-run show what would happen, touch nothing
|
|
# sudo ./dr_bringup.sh --no-start restore only, skip docker compose up -d
|
|
#
|
|
# Reads backup.conf from the same directory (or wherever $BACKUP_CONF
|
|
# points — see below). On the spare box this file won't exist yet on its
|
|
# own — copy it over from the primary box first (it holds the repository
|
|
# paths/passwords needed to connect):
|
|
# scp primary:~/docker/backup/backup.conf ~/docker/backup/backup.conf
|
|
# If the destination repo is a local path shared with the primary (e.g. the
|
|
# spare box IS the box the primary's REMOTE_TYPE=sftp mirror targets),
|
|
# nothing else is needed — the repo data is already there. Otherwise this
|
|
# also tries REMOTE_TYPE/REMOTE_ARGS (the offsite Backblaze/S3 mirror, if
|
|
# configured) as a restore source — the only one reachable from a genuinely
|
|
# new box (e.g. migrating to different hardware/a different provider).
|
|
#
|
|
# Migrating to a brand-new box that will keep running this repo's own
|
|
# backup.sh going forward: don't just scp the old box's backup.conf over
|
|
# the new box's own (that would replace its freshly-configured local repo
|
|
# with the old box's, which doesn't exist on this box). Instead:
|
|
# sudo ./setup.sh backup # sets up this box's own backups,
|
|
# # and deploys this script
|
|
# scp old-box:~/docker/backup/backup.conf ~/docker/backup/backup-source.conf
|
|
# sudo BACKUP_CONF=~/docker/backup/backup-source.conf \
|
|
# ~/docker/backup/dr_bringup.sh --list # preview first
|
|
# sudo BACKUP_CONF=~/docker/backup/backup-source.conf \
|
|
# ~/docker/backup/dr_bringup.sh # then actually restore
|
|
set -uo pipefail
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
CONF="${BACKUP_CONF:-$HERE/backup.conf}"
|
|
|
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
|
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
|
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
|
die() { err "$*"; exit 1; }
|
|
|
|
# ── Preflight ─────────────────────────────────────────────────────────────────
|
|
[ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root: sudo $0"
|
|
[ -f "$CONF" ] || die "backup.conf not found: $CONF — copy it from the primary box first."
|
|
command -v jq >/dev/null 2>&1 || die "jq is required — install it: sudo apt install jq"
|
|
|
|
# shellcheck source=/dev/null
|
|
source "$CONF"
|
|
|
|
# gaming-backup's single-dest conf format normalises the same way restore_kopia.sh does.
|
|
if [ -z "${DEST_NAMES:-}" ]; then
|
|
DEST_NAMES="default"
|
|
DEST_default_CONFIG="${KOPIA_CONFIG:-}"
|
|
DEST_default_PASSWORD="${KOPIA_PASSWORD:-}"
|
|
fi
|
|
|
|
command -v "$KOPIA" >/dev/null 2>&1 || die "Kopia not found: $KOPIA"
|
|
command -v docker >/dev/null 2>&1 || die "Docker not found — run this repo's post-install (base + require_docker) on this box first."
|
|
|
|
ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
|
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/home/$ACTUAL_USER")"
|
|
DOCKER_BASE="$ACTUAL_HOME/docker"
|
|
|
|
# ── Args ──────────────────────────────────────────────────────────────────────
|
|
DO_LIST=false DRY=false START=true ONLY_SVC=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--list) DO_LIST=true; shift ;;
|
|
--dry-run) DRY=true; shift ;;
|
|
--no-start) START=false; shift ;;
|
|
--service) ONLY_SVC="${2:-}"; shift 2 ;;
|
|
*) die "Unknown argument: $1 (see --help by reading the script header)" ;;
|
|
esac
|
|
done
|
|
|
|
# Bounded so a stalled repo connection (offsite mirror host unreachable
|
|
# mid-restore, etc.) can't hang forever and block every service behind it in
|
|
# the batch — 300s is generous for this stack's data sizes; raise it if a
|
|
# service's dataset genuinely needs longer.
|
|
k_for() {
|
|
local dest="$1"; shift
|
|
local cfg_var="DEST_${dest}_CONFIG" pw_var="DEST_${dest}_PASSWORD"
|
|
local cfg="${!cfg_var:-}" pw="${!pw_var:-}"
|
|
[ -n "$cfg" ] || return 1
|
|
timeout 300 env KOPIA_PASSWORD="$pw" "$KOPIA" --config-file="$cfg" "$@"
|
|
}
|
|
|
|
# ── Discover the latest restorable snapshot per service, across every
|
|
# destination — no dependency on backup.conf's SVC_<name> map, which only
|
|
# says where a *new* backup should go, not where past snapshots actually
|
|
# landed (relevant if a service was ever reassigned between destinations).
|
|
read -ra _DEST_ARR <<< "$DEST_NAMES"
|
|
declare -a SRC_PATH_LIST=() SRC_DEST_LIST=() SRC_SNAP_LIST=()
|
|
|
|
# DEST_NAMES entries are always LOCAL filesystem repos (services/backup.sh
|
|
# creates them with `repository create filesystem --path=...`) — meaning
|
|
# they only exist on whichever box originally ran the backup. On a genuinely
|
|
# new box (migrating to different hardware/provider, not restoring onto the
|
|
# same DR-spare the offsite mirror already targets), none of them will
|
|
# connect, and without this block there would be nothing left to restore
|
|
# from at all. REMOTE_TYPE/REMOTE_ARGS (the offsite Backblaze/S3 mirror) is
|
|
# reachable from anywhere, so try it too, as a same-shaped destination named
|
|
# "offsite" — reusing DEST_default_PASSWORD since sync-to always mirrors
|
|
# that exact same encrypted repo, so there's no separate password to ask
|
|
# for. Connect once into a fresh local config file scoped to this DR run
|
|
# (no pre-existing config for it the way the local DEST_NAMES entries have).
|
|
if [ -n "${REMOTE_TYPE:-}" ] && [ "$REMOTE_TYPE" != "none" ] && [ -n "${DEST_default_PASSWORD:-}" ]; then
|
|
OFFSITE_CFG="/etc/kopia-backup/dr-offsite.config"
|
|
mkdir -p "$(dirname "$OFFSITE_CFG")" /var/cache/kopia-backup
|
|
# shellcheck disable=SC2086
|
|
if env KOPIA_PASSWORD="$DEST_default_PASSWORD" "$KOPIA" --config-file="$OFFSITE_CFG" repository status >/dev/null 2>&1 \
|
|
|| env KOPIA_PASSWORD="$DEST_default_PASSWORD" "$KOPIA" --config-file="$OFFSITE_CFG" \
|
|
--cache-directory=/var/cache/kopia-backup repository connect $REMOTE_TYPE $REMOTE_ARGS >/dev/null 2>&1; then
|
|
DEST_offsite_CONFIG="$OFFSITE_CFG"
|
|
DEST_offsite_PASSWORD="$DEST_default_PASSWORD"
|
|
_DEST_ARR+=("offsite")
|
|
info "Connected to the offsite mirror ($REMOTE_TYPE) as an additional restore source ('offsite')."
|
|
else
|
|
warn "Could not connect to the offsite mirror ($REMOTE_TYPE) as a restore source — check REMOTE_TYPE/REMOTE_ARGS in $CONF."
|
|
fi
|
|
fi
|
|
|
|
for dest in "${_DEST_ARR[@]}"; do
|
|
if ! k_for "$dest" repository status >/dev/null 2>&1; then
|
|
warn "Cannot connect to destination '$dest' — skipping."
|
|
continue
|
|
fi
|
|
SNAP_JSON="$(k_for "$dest" snapshot list --all --json 2>/dev/null)"
|
|
[ -z "$SNAP_JSON" ] && continue
|
|
[ "$SNAP_JSON" = "null" ] && continue
|
|
|
|
mapfile -t _paths < <(echo "$SNAP_JSON" | jq -r --arg base "$DOCKER_BASE/" \
|
|
'[.[] | select(.source.path | startswith($base))] | group_by(.source.path)[] | .[0].source.path')
|
|
|
|
for p in "${_paths[@]}"; do
|
|
svc="$(basename "$p")"
|
|
[ -n "$ONLY_SVC" ] && [ "$svc" != "$ONLY_SVC" ] && continue
|
|
|
|
# First destination to claim a service name wins (DEST_NAMES always
|
|
# lists "default" first — see backup.sh) so a stale duplicate in a
|
|
# second repo can't shadow the current one.
|
|
_dupe=false
|
|
for _seen in "${SRC_PATH_LIST[@]:-}"; do
|
|
[ "$(basename "$_seen")" = "$svc" ] && _dupe=true && break
|
|
done
|
|
[ "$_dupe" = true ] && continue
|
|
|
|
latest_id="$(echo "$SNAP_JSON" | jq -r --arg p "$p" \
|
|
'[.[] | select(.source.path == $p)] | sort_by(.startTime) | reverse | .[0].id')"
|
|
[ -z "$latest_id" ] && continue
|
|
[ "$latest_id" = "null" ] && continue
|
|
|
|
SRC_PATH_LIST+=("$p")
|
|
SRC_DEST_LIST+=("$dest")
|
|
SRC_SNAP_LIST+=("$latest_id")
|
|
done
|
|
done
|
|
|
|
echo ""
|
|
echo "╔═══════════════════════════════════════════════════════╗"
|
|
echo "║ Kopia Disaster-Recovery Bring-Up ║"
|
|
echo "╚═══════════════════════════════════════════════════════╝"
|
|
|
|
if [ "$DO_LIST" = true ]; then
|
|
echo ""
|
|
[ "${#SRC_PATH_LIST[@]}" -eq 0 ] && { warn "No restorable sources found."; exit 0; }
|
|
printf " %-16s %-10s %s\n" "SERVICE" "DEST" "PATH"
|
|
for i in "${!SRC_PATH_LIST[@]}"; do
|
|
printf " %-16s %-10s %s\n" "$(basename "${SRC_PATH_LIST[$i]}")" "${SRC_DEST_LIST[$i]}" "${SRC_PATH_LIST[$i]}"
|
|
done
|
|
exit 0
|
|
fi
|
|
|
|
if [ "${#SRC_PATH_LIST[@]}" -eq 0 ]; then
|
|
if [ -n "$ONLY_SVC" ]; then
|
|
die "No snapshots found for service '$ONLY_SVC'."
|
|
else
|
|
die "No snapshots found. Run a backup on the primary box first, then copy backup.conf here."
|
|
fi
|
|
fi
|
|
|
|
# ── Restore + start ───────────────────────────────────────────────────────────
|
|
TOTAL_START=$(date +%s)
|
|
declare -a UP_SVCS=() FAILED_SVCS=()
|
|
|
|
for i in "${!SRC_PATH_LIST[@]}"; do
|
|
path="${SRC_PATH_LIST[$i]}"
|
|
dest="${SRC_DEST_LIST[$i]}"
|
|
snap="${SRC_SNAP_LIST[$i]}"
|
|
svc="$(basename "$path")"
|
|
compose="${path%/}/docker-compose.yml"
|
|
|
|
echo ""
|
|
info "── $svc (dest: $dest, snapshot: ${snap:0:12}...) ──"
|
|
|
|
if [ "$DRY" = true ]; then
|
|
echo " [DRY-RUN] Would restore → $path"
|
|
[ "$START" = true ] && echo " [DRY-RUN] Would run: docker compose -f $compose up -d"
|
|
continue
|
|
fi
|
|
|
|
SVC_START=$(date +%s)
|
|
|
|
if [ -e "$path" ]; then
|
|
aside="${path%/}.pre-dr-$(date +%Y%m%d-%H%M%S)"
|
|
mv "$path" "$aside"
|
|
info " Existing data moved aside → $(basename "$aside")"
|
|
fi
|
|
mkdir -p "$path"
|
|
|
|
if ! k_for "$dest" restore "$snap" "$path"; then
|
|
err " Restore failed (or timed out) for $svc"
|
|
FAILED_SVCS+=("$svc: restore failed or timed out")
|
|
continue
|
|
fi
|
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$path" 2>/dev/null || true
|
|
ok " Restored"
|
|
|
|
if [ "$START" = true ]; then
|
|
if [ ! -f "$compose" ]; then
|
|
warn " No docker-compose.yml at $path — restored but not started"
|
|
FAILED_SVCS+=("$svc: no compose file")
|
|
continue
|
|
fi
|
|
# Bounded so a stuck image pull or a compose file waiting on something
|
|
# (e.g. an interactive prompt) can't stall the rest of the batch — one
|
|
# bad service should never cost the others their spot in the 10-minute
|
|
# budget this script exists for.
|
|
if timeout 120 docker compose -f "$compose" up -d 2>/dev/null; then
|
|
ok " Started"
|
|
else
|
|
err " docker compose up -d failed (or timed out) for $svc"
|
|
FAILED_SVCS+=("$svc: compose up failed or timed out")
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
SVC_ELAPSED=$(( $(date +%s) - SVC_START ))
|
|
UP_SVCS+=("$svc (${SVC_ELAPSED}s)")
|
|
done
|
|
|
|
TOTAL_ELAPSED=$(( $(date +%s) - TOTAL_START ))
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
if [ "$DRY" = true ]; then
|
|
echo " DRY-RUN COMPLETE — nothing was touched"
|
|
else
|
|
echo " DISASTER-RECOVERY BRING-UP COMPLETE"
|
|
fi
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo ""
|
|
[ "$DRY" = false ] && echo " Total time: $((TOTAL_ELAPSED/60))m $((TOTAL_ELAPSED%60))s" && echo ""
|
|
|
|
if [ "${#UP_SVCS[@]}" -gt 0 ]; then
|
|
echo " Up:"
|
|
for s in "${UP_SVCS[@]}"; do echo " ✓ $s"; done
|
|
echo ""
|
|
fi
|
|
|
|
if [ "${#FAILED_SVCS[@]}" -gt 0 ]; then
|
|
echo " Failed (skipped — did not stop the rest of the batch):"
|
|
for s in "${FAILED_SVCS[@]}"; do echo " ✗ $s"; done
|
|
echo ""
|
|
fi
|
|
|
|
# One bad service is a partial success, not a failed run — the whole point of
|
|
# this script is getting as much of the stack back up as possible. Only a
|
|
# fully empty result (nothing came up at all) counts as a failed exit code.
|
|
if [ "$DRY" = false ] && [ "${#UP_SVCS[@]}" -eq 0 ]; then
|
|
err "Nothing came up."
|
|
exit 1
|
|
fi
|
|
|
|
exit 0
|