Files
Claude e55449442f refactor: move backup/restore worker scripts to extras/ as source files
Replace embedded heredocs in the three backup service installers with
cp from versioned source files in extras/:

  extras/backup_kopia.sh   — Kopia worker (was inline in services/backup.sh)
  extras/backup_borg.sh    — Borg worker  (was inline in services/borg-backup.sh)
  extras/backup_gaming.sh  — gaming saves worker (was inline in services/gaming-backup.sh)
  extras/restore_kopia.sh  — unified Kopia restore (multi-dest + single-dest)
  extras/restore_borg.sh   — unified Borg restore with destination picker

Each installer now does `cp extras/<script>.sh $DIR/<script>.sh` instead of
writing the script inline. Workers and restore scripts are now readable in the
repo rather than buried in heredocs.

Restore scripts are installed flat into the service directory root:
  ~/docker/backup/backup_kopia.sh   ~/docker/backup/restore_kopia.sh
  ~/docker/borg-backup/backup_borg.sh   ~/docker/borg-backup/restore_borg.sh
  ~/docker/gaming-backup/backup_gaming.sh   ~/docker/gaming-backup/restore_kopia.sh

The new restore scripts handle destination selection internally, so a single
script replaces the old per-destination restore/<dest>/ layout.

Also fixes `local` used outside a function in restore_kopia.sh and
restore_borg.sh (destination picker loop), and removes the now-superseded
extras/restore_kopia_backup.sh and extras/restore_borg_backup.sh.

https://claude.ai/code/session_019XgsQ13XKm4Zj3cNsDNwHj
2026-06-04 18:50:09 +00:00

250 lines
10 KiB
Bash

#!/bin/bash
# extras/restore_borg.sh — interactive restore from a Borg archive.
# Installed to ~/docker/borg-backup/restore_borg.sh by the borg-backup installer.
#
# Run as root:
# sudo ./restore_borg.sh interactive
# sudo ./restore_borg.sh --list list all archives and exit
#
# Reads backup.conf from the same directory (chmod 600, root-only).
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"
command -v borg >/dev/null 2>&1 || die "borg not found — install: sudo apt install borgbackup"
# shellcheck source=/dev/null
source "$CONF"
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"
# ── Destination picker (skipped when only one dest) ───────────────────────────
read -ra _DEST_ARR <<< "${DEST_NAMES:-default}"
if [ "${#_DEST_ARR[@]}" -gt 1 ]; then
echo ""
echo "Select backup destination:"
echo ""
for i in "${!_DEST_ARR[@]}"; do
_repo_var="DEST_${_DEST_ARR[$i]}_REPO"
printf " %d) %s (%s)\n" "$((i+1))" "${_DEST_ARR[$i]}" "${!_repo_var:-unknown}"
done
echo ""
read -rp "Destination [1-${#_DEST_ARR[@]}]: " _DEST_SEL
[[ "$_DEST_SEL" =~ ^[0-9]+$ ]] && [ "$_DEST_SEL" -ge 1 ] && [ "$_DEST_SEL" -le "${#_DEST_ARR[@]}" ] \
|| die "Invalid selection"
ACTIVE_DEST="${_DEST_ARR[$((_DEST_SEL-1))]}"
else
ACTIVE_DEST="${_DEST_ARR[0]}"
fi
_REPO_VAR="DEST_${ACTIVE_DEST}_REPO"
_PASS_VAR="DEST_${ACTIVE_DEST}_PASSPHRASE"
export BORG_REPO="${!_REPO_VAR:-}"
export BORG_PASSPHRASE="${!_PASS_VAR:-}"
[ -n "$BORG_REPO" ] || die "No BORG_REPO found for destination '$ACTIVE_DEST'"
b() { borg "$@"; }
b info 2>/dev/null | grep -q "Repository" \
|| die "Cannot connect to Borg repository at $BORG_REPO — check backup.conf."
# ── --list ────────────────────────────────────────────────────────────────────
if [ "${1:-}" = "--list" ]; then
echo ""
info "Archives in $BORG_REPO (dest: $ACTIVE_DEST):"
echo ""
b list 2>/dev/null | sort -t- -k2 -r
echo ""
exit 0
fi
# ── Interactive restore ───────────────────────────────────────────────────────
echo ""
echo "╔═══════════════════════════════════════════════════════╗"
echo "║ Borg Restore ║"
echo "╚═══════════════════════════════════════════════════════╝"
echo ""
[ "${#_DEST_ARR[@]}" -gt 1 ] && info "Using destination: $ACTIVE_DEST ($BORG_REPO)"
info "Loading archive list..."
ARCHIVE_LIST=$(b list --format '{archive}{NL}' 2>/dev/null)
[ -z "$ARCHIVE_LIST" ] && die "No archives found. Run a backup first: sudo $HERE/backup_borg.sh"
# Group archives by service prefix (everything before the timestamp)
mapfile -t SERVICES < <(echo "$ARCHIVE_LIST" | sed 's/-[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T.*//' | sort -u)
echo "Backed-up services:"
echo ""
for i in "${!SERVICES[@]}"; do
svc="${SERVICES[$i]}"
latest=$(echo "$ARCHIVE_LIST" | grep "^${svc}-" | sort | tail -1)
count=$(echo "$ARCHIVE_LIST" | grep -c "^${svc}-")
printf " %2d) %-28s latest: %s | %s archive(s)\n" \
"$((i+1))" "$svc" "${latest#${svc}-}" "$count"
done
echo ""
read -rp "Select service [1-${#SERVICES[@]}] or q to quit: " SEL
[[ "$SEL" =~ ^[qQ]$ ]] && echo "Cancelled." && exit 0
[[ "$SEL" =~ ^[0-9]+$ ]] && [ "$SEL" -ge 1 ] && [ "$SEL" -le "${#SERVICES[@]}" ] \
|| die "Invalid selection: $SEL"
SELECTED_SVC="${SERVICES[$((SEL-1))]}"
ok "Service: $SELECTED_SVC"
# ── Pick an archive ───────────────────────────────────────────────────────────
echo ""
echo "Available archives (most recent first):"
echo ""
mapfile -t ARCHIVES < <(echo "$ARCHIVE_LIST" | grep "^${SELECTED_SVC}-" | sort -r | head -20)
[ "${#ARCHIVES[@]}" -eq 0 ] && die "No archives found for $SELECTED_SVC."
for i in "${!ARCHIVES[@]}"; do
note=""; [ "$i" -eq 0 ] && note=" ← latest"
ts="${ARCHIVES[$i]#${SELECTED_SVC}-}"
printf " %2d) %s%s\n" "$((i+1))" "$ts" "$note"
done
echo ""
read -rp "Select archive [1-${#ARCHIVES[@]}, Enter = latest]: " ARCH_SEL
ARCH_SEL="${ARCH_SEL:-1}"
[[ "$ARCH_SEL" =~ ^[0-9]+$ ]] && [ "$ARCH_SEL" -ge 1 ] && [ "$ARCH_SEL" -le "${#ARCHIVES[@]}" ] \
|| die "Invalid selection: $ARCH_SEL"
SELECTED_ARCHIVE="${ARCHIVES[$((ARCH_SEL-1))]}"
ok "Archive: $SELECTED_ARCHIVE"
TARGET_DIR="$DOCKER_BASE/$SELECTED_SVC"
COMPOSE_FILE="$TARGET_DIR/docker-compose.yml"
# ── Choose restore mode ───────────────────────────────────────────────────────
echo ""
echo "Restore mode:"
echo ""
echo " 1) Inspect — extract to /tmp so you can browse without touching live data"
echo " 2) Restore — move current data aside, restore archive in its place"
echo " old data kept as .pre-restore-DATE (easy rollback)"
echo ""
read -rp "Select [1/2] or q to quit: " MODE
[[ "$MODE" =~ ^[qQ]$ ]] && echo "Cancelled." && exit 0
case "$MODE" in
1)
TEMP_DIR="/tmp/borg-inspect-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$TEMP_DIR"
echo ""
info "Extracting $SELECTED_ARCHIVE to $TEMP_DIR (nothing live is changed)..."
if ( cd "$TEMP_DIR" && b extract --progress "$BORG_REPO::$SELECTED_ARCHIVE" ); then
echo ""
ok "Done. Browse the extracted files:"
echo ""
echo " ls -la $TEMP_DIR"
echo " Service directory: $TEMP_DIR${TARGET_DIR}"
echo ""
echo " When finished:"
echo " rm -rf $TEMP_DIR"
else
rm -rf "$TEMP_DIR" 2>/dev/null || true
die "Extraction failed — no changes were made."
fi
;;
2)
STOPPED=false
if [ -f "$COMPOSE_FILE" ] \
&& docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$SELECTED_SVC"; then
echo ""
warn "Container '$SELECTED_SVC' is currently running."
read -rp " Stop it before restoring? Recommended to avoid corruption. (Y/n): " STOP_YN
if [[ ! "$STOP_YN" =~ ^[Nn]$ ]]; then
info "Stopping $SELECTED_SVC..."
docker compose -f "$COMPOSE_FILE" down 2>/dev/null \
|| docker stop "$SELECTED_SVC" 2>/dev/null \
|| warn "Could not stop $SELECTED_SVC — continuing anyway."
STOPPED=true
ok "$SELECTED_SVC stopped."
else
warn "Restoring with $SELECTED_SVC running — consistency not guaranteed."
fi
fi
ASIDE="${TARGET_DIR}.pre-restore-$(date +%Y%m%d-%H%M%S)"
echo ""
if [ -e "$TARGET_DIR" ]; then
info "Moving current data aside → $(basename "$ASIDE")"
mv "$TARGET_DIR" "$ASIDE"
ok "Current data saved at: $ASIDE"
else
warn "$TARGET_DIR does not exist — restoring fresh."
fi
mkdir -p "$TARGET_DIR"
info "Restoring $SELECTED_ARCHIVE$TARGET_DIR ..."
EXTRACT_PATH="${TARGET_DIR#/}"
if ( cd / && b extract --progress "$BORG_REPO::$SELECTED_ARCHIVE" "$EXTRACT_PATH" ); then
ok "Restore complete."
else
err "Restore failed — rolling back to original data."
rm -rf "$TARGET_DIR" 2>/dev/null || true
if [ -e "$ASIDE" ]; then
mv "$ASIDE" "$TARGET_DIR"
ok "Original data recovered from aside copy."
fi
if [ "$STOPPED" = true ] && [ -f "$COMPOSE_FILE" ]; then
docker compose -f "$COMPOSE_FILE" up -d 2>/dev/null || true
fi
exit 1
fi
if [ "$STOPPED" = true ] && [ -f "$COMPOSE_FILE" ]; then
echo ""
read -rp " Start '$SELECTED_SVC' now? (Y/n): " START_YN
if [[ ! "$START_YN" =~ ^[Nn]$ ]]; then
info "Starting $SELECTED_SVC..."
docker compose -f "$COMPOSE_FILE" up -d 2>/dev/null \
&& ok "$SELECTED_SVC started." \
|| warn "Start failed — check: docker compose -f $COMPOSE_FILE logs"
fi
fi
echo ""
echo "═══════════════════════════════════════════════════════"
echo " RESTORE COMPLETE"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " Restored from : $SELECTED_ARCHIVE"
echo " Restored to : $TARGET_DIR"
[ -e "$ASIDE" ] && echo " Previous data : $ASIDE"
echo ""
if [ -e "$ASIDE" ]; then
echo " Keep the restore (delete aside copy when satisfied):"
echo " rm -rf \"$ASIDE\""
echo ""
echo " Roll back to previous data:"
[ -f "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE down"
echo " rm -rf \"$TARGET_DIR\""
echo " mv \"$ASIDE\" \"$TARGET_DIR\""
[ -f "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE up -d"
fi
echo ""
;;
*)
echo "Cancelled."
exit 0
;;
esac