Files
ubuntu-post-install/extras/restore_kopia.sh
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

291 lines
12 KiB
Bash

#!/bin/bash
# extras/restore_kopia.sh — interactive restore from a Kopia snapshot.
# Installed to ~/docker/backup/ and ~/docker/gaming-backup/ by their installers.
#
# Run as root:
# sudo ./restore_kopia.sh interactive
# sudo ./restore_kopia.sh --list list all snapshot sources and exit
#
# Reads backup.conf from the same directory (chmod 600, root-only).
# Supports both multi-destination (backup service) and single-destination
# (gaming-backup service) conf formats.
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 jq >/dev/null 2>&1 || die "jq is required — install it: sudo apt install jq"
# shellcheck source=/dev/null
source "$CONF"
# ── Normalise conf format ─────────────────────────────────────────────────────
# gaming-backup uses KOPIA_CONFIG/KOPIA_PASSWORD directly (single dest).
# backup service uses DEST_NAMES + DEST_<n>_CONFIG/PASSWORD (multi-dest).
# Normalise both to the multi-dest interface so the rest of the script is uniform.
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"
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"
if [ "${#_DEST_ARR[@]}" -gt 1 ]; then
echo ""
echo "Select backup destination:"
echo ""
for i in "${!_DEST_ARR[@]}"; do
_cfg_var="DEST_${_DEST_ARR[$i]}_CONFIG"
printf " %d) %s (%s)\n" "$((i+1))" "${_DEST_ARR[$i]}" "${!_cfg_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
_CFG_VAR="DEST_${ACTIVE_DEST}_CONFIG"
_PW_VAR="DEST_${ACTIVE_DEST}_PASSWORD"
KOPIA_CONFIG="${!_CFG_VAR:-}"
KOPIA_PASSWORD="${!_PW_VAR:-}"
[ -n "$KOPIA_CONFIG" ] || die "No KOPIA_CONFIG found for destination '$ACTIVE_DEST'"
export KOPIA_PASSWORD
k() { "$KOPIA" --config-file="$KOPIA_CONFIG" "$@"; }
k repository status >/dev/null 2>&1 || die "Cannot connect to Kopia repository '$ACTIVE_DEST'. Check backup.conf."
# ── Snapshot helpers ──────────────────────────────────────────────────────────
all_snapshots_json() { k snapshot list --all --json 2>/dev/null; }
docker_info_for_path() {
local path="$1"
[[ "$path" == "$DOCKER_BASE"/* ]] || return 0
local relative="${path#$DOCKER_BASE/}"
local service="${relative%%/*}"
local compose="$DOCKER_BASE/$service/docker-compose.yml"
[ -f "$compose" ] && echo "$service $compose"
}
# ── --list ────────────────────────────────────────────────────────────────────
if [ "${1:-}" = "--list" ]; then
echo ""
info "Loading snapshots (dest: $ACTIVE_DEST)..."
echo ""
all_snapshots_json | jq -r '
group_by(.source.path)[] |
(.[0].source.path) as $p |
(.[0].description // "-") as $d |
(.[0].startTime | split("T") | "\(.[0]) \(.[1][:8]) UTC") as $t |
(length | tostring) as $n |
"\($p)\n \($d) | latest: \($t) | \($n) snapshot(s)\n"
'
exit 0
fi
# ── Interactive restore ───────────────────────────────────────────────────────
echo ""
echo "╔═══════════════════════════════════════════════════════╗"
echo "║ Kopia Restore ║"
echo "╚═══════════════════════════════════════════════════════╝"
echo ""
[ "${#_DEST_ARR[@]}" -gt 1 ] && info "Using destination: $ACTIVE_DEST"
info "Loading snapshot index..."
SNAP_JSON=$(all_snapshots_json)
[ -z "$SNAP_JSON" ] || [ "$SNAP_JSON" = "[]" ] || [ "$SNAP_JSON" = "null" ] \
&& die "No snapshots found. Run a backup first: sudo $HERE/backup_kopia.sh"
mapfile -t SRC_PATHS < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | .[0].source.path')
mapfile -t SRC_DESCS < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | .[0].description // "-"')
mapfile -t SRC_LATEST < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | .[0].startTime | split("T") | "\(.[0]) \(.[1][:8])"')
mapfile -t SRC_COUNTS < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | length')
[ "${#SRC_PATHS[@]}" -eq 0 ] && die "No snapshot sources found."
echo "What do you want to restore?"
echo ""
for i in "${!SRC_PATHS[@]}"; do
printf " %2d) %s\n %s | latest: %s | %s snapshots\n\n" \
"$((i+1))" "${SRC_PATHS[$i]}" "${SRC_DESCS[$i]}" \
"${SRC_LATEST[$i]}" "${SRC_COUNTS[$i]}"
done
read -rp "Select source [1-${#SRC_PATHS[@]}] or q to quit: " SEL
[[ "$SEL" =~ ^[qQ]$ ]] && echo "Cancelled." && exit 0
[[ "$SEL" =~ ^[0-9]+$ ]] && [ "$SEL" -ge 1 ] && [ "$SEL" -le "${#SRC_PATHS[@]}" ] \
|| die "Invalid selection: $SEL"
SOURCE_PATH="${SRC_PATHS[$((SEL-1))]}"
echo ""
ok "Source: $SOURCE_PATH"
# ── Pick a snapshot ───────────────────────────────────────────────────────────
echo ""
echo "Available snapshots (most recent first):"
echo ""
mapfile -t SNAP_IDS < <(echo "$SNAP_JSON" | jq -r --arg p "$SOURCE_PATH" '
[.[] | select(.source.path == $p)] | sort_by(.startTime) | reverse | .[0:15] | .[].id')
mapfile -t SNAP_TIMES < <(echo "$SNAP_JSON" | jq -r --arg p "$SOURCE_PATH" '
[.[] | select(.source.path == $p)] | sort_by(.startTime) | reverse | .[0:15] |
.[].startTime | split("T") | "\(.[0]) \(.[1][:8]) UTC"')
[ "${#SNAP_IDS[@]}" -eq 0 ] && die "No snapshots found for that source."
for i in "${!SNAP_IDS[@]}"; do
note=""; [ "$i" -eq 0 ] && note=" ← latest"
printf " %2d) %s (id: %s...)%s\n" \
"$((i+1))" "${SNAP_TIMES[$i]}" "${SNAP_IDS[$i]:0:12}" "$note"
done
echo ""
read -rp "Select snapshot [1-${#SNAP_IDS[@]}, Enter = latest]: " SNAP_SEL
SNAP_SEL="${SNAP_SEL:-1}"
[[ "$SNAP_SEL" =~ ^[0-9]+$ ]] && [ "$SNAP_SEL" -ge 1 ] && [ "$SNAP_SEL" -le "${#SNAP_IDS[@]}" ] \
|| die "Invalid selection: $SNAP_SEL"
SNAPSHOT_ID="${SNAP_IDS[$((SNAP_SEL-1))]}"
SNAPSHOT_TIME="${SNAP_TIMES[$((SNAP_SEL-1))]}"
echo ""
ok "Snapshot: $SNAPSHOT_TIME (id: ${SNAPSHOT_ID:0:16}...)"
# ── Choose restore mode ───────────────────────────────────────────────────────
echo ""
echo "Restore mode:"
echo ""
echo " 1) Inspect — restore to /tmp so you can browse files without touching anything live"
echo " 2) Restore — move current data aside, restore snapshot 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/kopia-inspect-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$TEMP_DIR"
echo ""
info "Restoring snapshot to $TEMP_DIR (nothing live is changed)..."
if k restore "$SNAPSHOT_ID" "$TEMP_DIR"; then
echo ""
ok "Done. Browse the restored files:"
echo ""
echo " ls -la $TEMP_DIR"
echo ""
echo " When finished:"
echo " rm -rf $TEMP_DIR"
else
rm -rf "$TEMP_DIR" 2>/dev/null || true
die "Restore failed — no changes were made."
fi
;;
2)
SINFO=$(docker_info_for_path "$SOURCE_PATH")
SVC_NAME="${SINFO%% *}"
COMPOSE_FILE="${SINFO##* }"
[ "$SVC_NAME" = "$COMPOSE_FILE" ] && SVC_NAME="" && COMPOSE_FILE=""
STOPPED=false
if [ -n "$SVC_NAME" ] && [ -n "$COMPOSE_FILE" ] \
&& docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$SVC_NAME"; then
echo ""
warn "Container '$SVC_NAME' is currently running."
read -rp " Stop it before restoring? Recommended to avoid data corruption. (Y/n): " STOP_YN
if [[ ! "$STOP_YN" =~ ^[Nn]$ ]]; then
info "Stopping $SVC_NAME..."
docker compose -f "$COMPOSE_FILE" down 2>/dev/null \
|| docker stop "$SVC_NAME" 2>/dev/null \
|| warn "Could not stop $SVC_NAME — continuing anyway."
STOPPED=true
ok "$SVC_NAME stopped."
else
warn "Restoring with $SVC_NAME running — consistency not guaranteed."
fi
fi
ASIDE="${SOURCE_PATH}.pre-restore-$(date +%Y%m%d-%H%M%S)"
echo ""
if [ -e "$SOURCE_PATH" ]; then
info "Moving current data aside → $(basename "$ASIDE")"
mv "$SOURCE_PATH" "$ASIDE"
ok "Current data saved at: $ASIDE"
else
warn "Source path doesn't exist yet: $SOURCE_PATH — restoring fresh."
fi
mkdir -p "$SOURCE_PATH"
info "Restoring $SNAPSHOT_TIME$SOURCE_PATH ..."
if k restore "$SNAPSHOT_ID" "$SOURCE_PATH"; then
ok "Restore complete."
else
err "Restore failed — rolling back to original data."
rm -rf "$SOURCE_PATH" 2>/dev/null || true
if [ -e "$ASIDE" ]; then
mv "$ASIDE" "$SOURCE_PATH"
ok "Original data recovered from aside copy."
fi
if [ "$STOPPED" = true ] && [ -n "$COMPOSE_FILE" ]; then
docker compose -f "$COMPOSE_FILE" up -d 2>/dev/null || true
fi
exit 1
fi
if [ "$STOPPED" = true ] && [ -n "$COMPOSE_FILE" ]; then
echo ""
read -rp " Start '$SVC_NAME' now? (Y/n): " START_YN
if [[ ! "$START_YN" =~ ^[Nn]$ ]]; then
info "Starting $SVC_NAME..."
docker compose -f "$COMPOSE_FILE" up -d 2>/dev/null \
&& ok "$SVC_NAME started." \
|| warn "Start failed — check: docker compose -f $COMPOSE_FILE logs"
fi
fi
echo ""
echo "═══════════════════════════════════════════════════════"
echo " RESTORE COMPLETE"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " Restored from : $SNAPSHOT_TIME"
echo " Restored to : $SOURCE_PATH"
[ -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:"
[ -n "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE down"
echo " rm -rf \"$SOURCE_PATH\""
echo " mv \"$ASIDE\" \"$SOURCE_PATH\""
[ -n "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE up -d"
fi
echo ""
;;
*)
echo "Cancelled."
exit 0
;;
esac