diff --git a/extras/backup_borg.sh b/extras/backup_borg.sh new file mode 100644 index 0000000..e9d090d --- /dev/null +++ b/extras/backup_borg.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# extras/backup_borg.sh — Borg backup worker for all Docker services. +# Installed to ~/docker/borg-backup/backup_borg.sh by the borg-backup installer. +# +# sudo ./backup_borg.sh run a full backup cycle +# sudo ./backup_borg.sh list list all archives in all repos +# sudo ./backup_borg.sh info show repo info for all destinations +# +# Minecraft instances: flush to disk (save-all) then archive — no downtime. +# All other services: stop → archive → restart for consistency. +# Reads backup.conf from the same directory. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONF="${BACKUP_CONF:-$HERE/backup.conf}" +[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run: sudo setup.sh borg-backup)"; exit 1; } +# 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_DIR="$ACTUAL_HOME/docker" + +log() { echo "[$(date '+%F %T')] $*"; } + +repo_for() { local var="DEST_${1}_REPO"; echo "${!var:-}"; } +pass_for() { local var="DEST_${1}_PASSPHRASE"; echo "${!var:-}"; } +dest_for_svc() { local var="SVC_${1//-/_}"; echo "${!var:-${DEST_DEFAULT:-default}}"; } + +b_for() { + local dest="$1"; shift + local repo; repo="$(repo_for "$dest")" + local pass; pass="$(pass_for "$dest")" + [ -n "$repo" ] || { log "Unknown destination: $dest"; return 1; } + BORG_PASSPHRASE="$pass" BORG_REPO="$repo" "$BORG" "$@" +} + +is_minecraft() { [ -f "${1}Dockerfile" ] && grep -qs itzg "${1}Dockerfile"; } + +case "${1:-run}" in + list) + for dest in ${DEST_NAMES:-default}; do + echo ""; echo "── dest: $dest ($(repo_for "$dest")) ──" + b_for "$dest" list 2>/dev/null | sort -r | head -30 || true + done + exit 0 ;; + info) + for dest in ${DEST_NAMES:-default}; do + echo ""; echo "── dest: $dest ──" + b_for "$dest" info 2>/dev/null || true + done + exit 0 ;; +esac + +log "===== Borg backup starting =====" +rc=0 +TS="$(date +%Y-%m-%dT%H-%M-%S)" + +for svc_dir in "$DOCKER_DIR"/*/; do + [ -f "${svc_dir}docker-compose.yml" ] || continue + svc="$(basename "$svc_dir")" + [[ "$svc" == "borg-backup" || "$svc" == "backup" || "$svc" == "gaming-backup" ]] && continue + + dest="$(dest_for_svc "$svc")" + repo="$(repo_for "$dest")" + [ -n "$repo" ] || { log "SKIP $svc — dest '$dest' not configured"; continue; } + + ARCHIVE="${svc}-${TS}" + + if is_minecraft "$svc_dir"; then + if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then + log "Flushing Minecraft world '$svc' (save-all, no downtime)..." + docker exec "$svc" mc-send-to-console save-all flush 2>/dev/null \ + || docker exec "$svc" rcon-cli save-all 2>/dev/null || true + sleep 5 + fi + log "Archiving $svc → $dest::$ARCHIVE ..." + if b_for "$dest" create \ + --compression=zstd,6 --exclude-caches --stats \ + "::$ARCHIVE" "$svc_dir" 2>&1 | while IFS= read -r line; do log " $line"; done; then + log "OK $svc (Minecraft, no downtime)" + else + log "WARNING: archive failed for $svc"; rc=1 + fi + else + STOPPED=false + if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then + log "Stopping $svc..." + docker compose -f "${svc_dir}docker-compose.yml" down 2>/dev/null \ + || docker stop "$svc" 2>/dev/null \ + || log "WARNING: could not stop $svc — archiving live (consistency not guaranteed)" + STOPPED=true + fi + + log "Archiving $svc → $dest::$ARCHIVE ..." + if b_for "$dest" create \ + --compression=zstd,6 --exclude-caches --stats \ + "::$ARCHIVE" "$svc_dir" 2>&1 | while IFS= read -r line; do log " $line"; done; then + log "OK $svc" + else + log "WARNING: archive failed for $svc"; rc=1 + fi + + if [ "$STOPPED" = true ]; then + log "Starting $svc..." + docker compose -f "${svc_dir}docker-compose.yml" up -d 2>/dev/null \ + || log "WARNING: could not restart $svc — run: docker compose -f ${svc_dir}docker-compose.yml up -d" + fi + fi + + log "Pruning old archives for $svc in '$dest'..." + b_for "$dest" prune \ + --keep-daily="${KEEP_DAILY:-7}" \ + --keep-weekly="${KEEP_WEEKLY:-4}" \ + --keep-monthly="${KEEP_MONTHLY:-3}" \ + --glob-archives="${svc}-*" \ + --list 2>/dev/null \ + || log "WARNING: prune failed for $svc (non-fatal)" +done + +for dest in ${DEST_NAMES:-default}; do + repo="$(repo_for "$dest")" + [ -n "$repo" ] || continue + log "Compacting repo '$dest'..." + b_for "$dest" compact 2>/dev/null || true +done + +if [ "$rc" -eq 0 ]; then + log "===== Borg backup complete =====" +else + log "===== Borg backup finished WITH WARNINGS (see above) =====" +fi +exit "$rc" diff --git a/extras/backup_gaming.sh b/extras/backup_gaming.sh new file mode 100644 index 0000000..9513caa --- /dev/null +++ b/extras/backup_gaming.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# extras/backup_gaming.sh — Kopia gaming-saves worker (no service downtime). +# Installed to ~/docker/gaming-backup/backup_gaming.sh by the gaming-backup installer. +# +# sudo ./backup_gaming.sh run a backup now +# sudo ./backup_gaming.sh snapshots list snapshots +# sudo ./backup_gaming.sh policy show retention/ignore policy +# +# Nothing is stopped: Minecraft worlds are flushed to disk (save-all) first. +# Reads backup.conf from the same directory. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONF="${BACKUP_CONF:-$HERE/backup.conf}" +[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run: sudo setup.sh gaming-backup)"; exit 1; } +# shellcheck source=/dev/null +source "$CONF" + +export KOPIA_PASSWORD +log() { echo "[$(date '+%F %T')] $*"; } +k() { "$KOPIA" --config-file="$KOPIA_CONFIG" "$@"; } + +if ! k repository status >/dev/null 2>&1; then + log "ERROR: not connected to a repository — re-run the gaming-backup service" + exit 1 +fi + +case "${1:-run}" in + snapshots) k snapshot list; exit 0 ;; + policy) k policy show --global; exit 0 ;; +esac + +log "===== Gaming backup starting =====" + +if [ -n "${MC_BASE_DIR:-}" ] && command -v docker >/dev/null 2>&1; then + _flushed=0 + for d in "$MC_BASE_DIR"/*/; do + [ -f "${d}Dockerfile" ] && grep -qs itzg "${d}Dockerfile" || continue + name="$(basename "$d")" + if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then + log "Flushing Minecraft world '$name' (save-all)..." + docker exec "$name" mc-send-to-console save-all flush 2>/dev/null \ + || docker exec "$name" rcon-cli save-all 2>/dev/null || true + _flushed=1 + fi + done + [ "$_flushed" = 1 ] && sleep 5 +fi + +rc=0 +snap() { + local label="$1" path="$2" + if [ -z "$path" ] || [ ! -e "$path" ]; then + log "skip $label — not found: ${path:-}"; return + fi + log "Snapshotting $label: $path" + if ! k snapshot create --description="gaming: $label" "$path"; then + log "WARNING: snapshot failed for $label"; rc=1 + fi +} + +if [ -n "${MC_BASE_DIR:-}" ]; then + for d in "$MC_BASE_DIR"/*/; do + [ -f "${d}Dockerfile" ] && grep -qs itzg "${d}Dockerfile" && [ -d "${d}data" ] || continue + nm="$(basename "$d")" + case "$nm" in minecraft*) lbl="$nm" ;; *) lbl="minecraft-$nm" ;; esac + snap "$lbl" "${d}data" + done +fi +[ "${BACKUP_SAVES:-no}" = yes ] && snap "emulator-saves" "$GAME_STORAGE_DIR/saves" +[ "${BACKUP_STEAM:-no}" = yes ] && snap "steam-userdata" "$GAME_STORAGE_DIR/steam" +[ "${BACKUP_MEDIA:-no}" = yes ] && snap "es-de-media" "$GAME_STORAGE_DIR/media" +[ "${BACKUP_WOLF:-no}" = yes ] && snap "wolf-state" "$WOLF_STATE_DIR" + +if [ "${REMOTE_TYPE:-none}" != "none" ] && [ -n "${REMOTE_TYPE:-}" ]; then + log "Mirroring repository to remote ($REMOTE_TYPE)..." + # shellcheck disable=SC2086 + if ! k repository sync-to "$REMOTE_TYPE" $REMOTE_ARGS; then + log "WARNING: remote mirror failed"; rc=1 + fi +fi + +if [ "$rc" -eq 0 ]; then + log "===== Gaming backup complete =====" +else + log "===== Gaming backup finished WITH WARNINGS =====" +fi +exit "$rc" diff --git a/extras/backup_kopia.sh b/extras/backup_kopia.sh new file mode 100644 index 0000000..a9e05a6 --- /dev/null +++ b/extras/backup_kopia.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# extras/backup_kopia.sh — Kopia backup worker for all Docker services. +# Installed to ~/docker/backup/backup_kopia.sh by the backup service installer. +# +# sudo ./backup_kopia.sh run a full backup cycle +# sudo ./backup_kopia.sh snapshots list all snapshots (all repos) +# sudo ./backup_kopia.sh policy show retention policies +# +# Minecraft instances: flush to disk (save-all) then snapshot — no downtime. +# All other services: stop → snapshot → restart for consistency. +# Reads backup.conf from the same directory. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONF="${BACKUP_CONF:-$HERE/backup.conf}" +[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run: sudo setup.sh backup)"; exit 1; } +# 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_DIR="$ACTUAL_HOME/docker" + +log() { echo "[$(date '+%F %T')] $*"; } + +kp_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" ] || { log "Unknown destination: $dest"; return 1; } + env KOPIA_PASSWORD="$pw" "$KOPIA" --config-file="$cfg" "$@" +} + +dest_for_svc() { + local var="SVC_${1//-/_}" + echo "${!var:-${DEST_DEFAULT:-default}}" +} + +is_minecraft() { [ -f "${1}Dockerfile" ] && grep -qs itzg "${1}Dockerfile"; } + +case "${1:-run}" in + snapshots) + for dest in ${DEST_NAMES:-default}; do + echo ""; echo "── dest: $dest ──" + kp_for "$dest" snapshot list 2>/dev/null || true + done + exit 0 ;; + policy) + for dest in ${DEST_NAMES:-default}; do + echo ""; echo "── dest: $dest ──" + kp_for "$dest" policy show --global 2>/dev/null || true + done + exit 0 ;; +esac + +log "===== Backup starting =====" +rc=0 + +for svc_dir in "$DOCKER_DIR"/*/; do + [ -f "${svc_dir}docker-compose.yml" ] || continue + svc="$(basename "$svc_dir")" + [[ "$svc" == "backup" || "$svc" == "borg-backup" || "$svc" == "gaming-backup" ]] && continue + + dest="$(dest_for_svc "$svc")" + _repo_var="DEST_${dest}_REPO" + [ -n "${!_repo_var:-}" ] || { log "SKIP $svc — dest '$dest' not configured in conf"; continue; } + + if is_minecraft "$svc_dir"; then + if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then + log "Flushing Minecraft world '$svc' (save-all, no downtime)..." + docker exec "$svc" mc-send-to-console save-all flush 2>/dev/null \ + || docker exec "$svc" rcon-cli save-all 2>/dev/null || true + sleep 5 + fi + log "Snapshotting $svc (dest: $dest)..." + if kp_for "$dest" snapshot create --description="backup: $svc" "$svc_dir"; then + log "OK $svc (Minecraft, no downtime)" + else + log "WARNING: snapshot failed for $svc"; rc=1 + fi + else + STOPPED=false + if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then + log "Stopping $svc..." + docker compose -f "${svc_dir}docker-compose.yml" down 2>/dev/null \ + || docker stop "$svc" 2>/dev/null \ + || log "WARNING: could not stop $svc — snapshotting live (consistency not guaranteed)" + STOPPED=true + fi + + log "Snapshotting $svc (dest: $dest)..." + if kp_for "$dest" snapshot create --description="backup: $svc" "$svc_dir"; then + log "OK $svc" + else + log "WARNING: snapshot failed for $svc"; rc=1 + fi + + if [ "$STOPPED" = true ]; then + log "Starting $svc..." + docker compose -f "${svc_dir}docker-compose.yml" up -d 2>/dev/null \ + || log "WARNING: could not restart $svc — run: docker compose -f ${svc_dir}docker-compose.yml up -d" + fi + fi +done + +if [ "${REMOTE_TYPE:-none}" != "none" ] && [ -n "${REMOTE_TYPE:-}" ]; then + for dest in ${DEST_NAMES:-default}; do + log "Mirroring '$dest' offsite ($REMOTE_TYPE)..." + # shellcheck disable=SC2086 + kp_for "$dest" repository sync-to "$REMOTE_TYPE" $REMOTE_ARGS \ + || { log "WARNING: mirror failed for '$dest'"; rc=1; } + done +fi + +if [ "$rc" -eq 0 ]; then + log "===== Backup complete =====" +else + log "===== Backup finished WITH WARNINGS (see above) =====" +fi +exit "$rc" diff --git a/extras/restore_borg_backup.sh b/extras/restore_borg.sh similarity index 75% rename from extras/restore_borg_backup.sh rename to extras/restore_borg.sh index 1af60de..0c0b0be 100644 --- a/extras/restore_borg_backup.sh +++ b/extras/restore_borg.sh @@ -1,10 +1,10 @@ #!/bin/bash -# extras/restore_borg_backup.sh — interactive restore from a Borg archive. -# Installed to ~/docker/borg-backup/restore// by the borg-backup installer. +# 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_backup.sh interactive -# sudo ./restore_borg_backup.sh --list list all archives and exit +# 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 @@ -21,30 +21,50 @@ die() { err "$*"; exit 1; } # ── Preflight ───────────────────────────────────────────────────────────────── [ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root: sudo $0" -[ -f "$CONF" ] || die "backup.conf not found: $CONF (run: sudo setup.sh borg-backup)" +[ -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" -export BORG_PASSPHRASE BORG_REPO -b() { borg "$@"; } - -b info 2>/dev/null | grep -q "Repository" \ - || die "Cannot connect to Borg repository at $BORG_REPO — check backup.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" -# ── List all archives ────────────────────────────────────────────────────────── -list_archives() { - b list --format '{archive}{NL}' 2>/dev/null -} +# ── 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:" + info "Archives in $BORG_REPO (dest: $ACTIVE_DEST):" echo "" b list 2>/dev/null | sort -t- -k2 -r echo "" @@ -54,17 +74,16 @@ fi # ── Interactive restore ─────────────────────────────────────────────────────── echo "" echo "╔═══════════════════════════════════════════════════════╗" -echo "║ Borg Backup Restore ║" +echo "║ Borg Restore ║" echo "╚═══════════════════════════════════════════════════════╝" echo "" +[ "${#_DEST_ARR[@]}" -gt 1 ] && info "Using destination: $ACTIVE_DEST ($BORG_REPO)" -info "Loading archive list from $BORG_REPO ..." -ARCHIVE_LIST=$(list_archives) -[ -z "$ARCHIVE_LIST" ] && die "No archives found. Run a backup first: sudo ~/docker/borg-backup/borg-backup.sh" +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 first timestamp) ── -# Archive names: -YYYY-MM-DDTHH-MM-SS -# Extract unique service prefixes. +# 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:" @@ -86,17 +105,15 @@ read -rp "Select service [1-${#SERVICES[@]}] or q to quit: " SEL SELECTED_SVC="${SERVICES[$((SEL-1))]}" ok "Service: $SELECTED_SVC" -# ── Pick an archive for that service ───────────────────────────────────────── +# ── 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" - # Parse timestamp from archive name: svc-YYYY-MM-DDTHH-MM-SS ts="${ARCHIVES[$i]#${SELECTED_SVC}-}" printf " %2d) %s%s\n" "$((i+1))" "$ts" "$note" done @@ -110,10 +127,8 @@ ARCH_SEL="${ARCH_SEL:-1}" SELECTED_ARCHIVE="${ARCHIVES[$((ARCH_SEL-1))]}" ok "Archive: $SELECTED_ARCHIVE" -# ── Determine the Docker service directory ──────────────────────────────────── -# Archive contains the full absolute path: home/user/docker/svc/ (without leading /) -# Derive expected restore target. TARGET_DIR="$DOCKER_BASE/$SELECTED_SVC" +COMPOSE_FILE="$TARGET_DIR/docker-compose.yml" # ── Choose restore mode ─────────────────────────────────────────────────────── echo "" @@ -127,21 +142,17 @@ read -rp "Select [1/2] or q to quit: " MODE [[ "$MODE" =~ ^[qQ]$ ]] && echo "Cancelled." && exit 0 case "$MODE" in - -# ── Inspect: extract to /tmp ────────────────────────────────────────────────── 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)..." - # borg extract strips leading / — run from TEMP_DIR 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 "" - echo " The service directory will be under: $TEMP_DIR${TARGET_DIR}" + echo " Service directory: $TEMP_DIR${TARGET_DIR}" echo "" echo " When finished:" echo " rm -rf $TEMP_DIR" @@ -151,10 +162,7 @@ case "$MODE" in fi ;; -# ── Restore in place ────────────────────────────────────────────────────────── 2) - COMPOSE_FILE="$TARGET_DIR/docker-compose.yml" - STOPPED=false if [ -f "$COMPOSE_FILE" ] \ && docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$SELECTED_SVC"; then @@ -173,7 +181,6 @@ case "$MODE" in fi fi - # Move current data aside ASIDE="${TARGET_DIR}.pre-restore-$(date +%Y%m%d-%H%M%S)" echo "" if [ -e "$TARGET_DIR" ]; then @@ -184,11 +191,9 @@ case "$MODE" in warn "$TARGET_DIR does not exist — restoring fresh." fi - # borg extract strips leading /: run from / to restore to original absolute path mkdir -p "$TARGET_DIR" info "Restoring $SELECTED_ARCHIVE → $TARGET_DIR ..." - # Extract only the service subdirectory to keep things scoped - EXTRACT_PATH="${TARGET_DIR#/}" # strip leading / for borg + EXTRACT_PATH="${TARGET_DIR#/}" if ( cd / && b extract --progress "$BORG_REPO::$SELECTED_ARCHIVE" "$EXTRACT_PATH" ); then ok "Restore complete." else @@ -204,7 +209,6 @@ case "$MODE" in exit 1 fi - # Restart container if [ "$STOPPED" = true ] && [ -f "$COMPOSE_FILE" ]; then echo "" read -rp " Start '$SELECTED_SVC' now? (Y/n): " START_YN @@ -225,17 +229,15 @@ case "$MODE" in echo " Restored to : $TARGET_DIR" [ -e "$ASIDE" ] && echo " Previous data : $ASIDE" echo "" - echo " Verify your data, then:" - echo "" if [ -e "$ASIDE" ]; then - echo " Keep the restore (delete aside copy when satisfied):" - echo " rm -rf \"$ASIDE\"" + 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" + 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 "" ;; diff --git a/extras/restore_kopia_backup.sh b/extras/restore_kopia.sh similarity index 78% rename from extras/restore_kopia_backup.sh rename to extras/restore_kopia.sh index a4c5541..3b8969d 100644 --- a/extras/restore_kopia_backup.sh +++ b/extras/restore_kopia.sh @@ -1,13 +1,14 @@ #!/bin/bash -# extras/restore_kopia_backup.sh — interactive restore from a Kopia snapshot. -# Installed to ~/docker/backup/ by the backup service installer. +# 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_backup.sh interactive -# sudo ./restore_kopia_backup.sh --list list all snapshot sources and exit +# sudo ./restore_kopia.sh interactive +# sudo ./restore_kopia.sh --list list all snapshot sources and exit # -# Reads backup.conf from the same directory. The repository password is stored -# there (chmod 600, root-only) — no password prompt needed. +# 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)" @@ -22,29 +23,61 @@ die() { err "$*"; exit 1; } # ── Preflight ───────────────────────────────────────────────────────────────── [ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root: sudo $0" -[ -f "$CONF" ] || die "backup.conf not found: $CONF (run: sudo setup.sh backup)" +[ -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" -export KOPIA_PASSWORD + +# ── Normalise conf format ───────────────────────────────────────────────────── +# gaming-backup uses KOPIA_CONFIG/KOPIA_PASSWORD directly (single dest). +# backup service uses DEST_NAMES + DEST__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" -k() { "$KOPIA" --config-file="$KOPIA_CONFIG" "$@"; } -k repository status >/dev/null 2>&1 || die "Cannot connect to Kopia repository. Check backup.conf." -# ── Derive Docker dir (mirrors lib/common.sh logic) ─────────────────────────── 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 -} +all_snapshots_json() { k snapshot list --all --json 2>/dev/null; } -# Given a source path like ~/docker/minecraft/data, return "service_name /path/to/compose.yml" -# or empty string if not a Docker service. docker_info_for_path() { local path="$1" [[ "$path" == "$DOCKER_BASE"/* ]] || return 0 @@ -57,12 +90,9 @@ docker_info_for_path() { # ── --list ──────────────────────────────────────────────────────────────────── if [ "${1:-}" = "--list" ]; then echo "" - info "Loading snapshots..." - JSON=$(all_snapshots_json) + info "Loading snapshots (dest: $ACTIVE_DEST)..." echo "" - echo "Backup sources:" - echo "" - echo "$JSON" | jq -r ' + all_snapshots_json | jq -r ' group_by(.source.path)[] | (.[0].source.path) as $p | (.[0].description // "-") as $d | @@ -76,22 +106,20 @@ fi # ── Interactive restore ─────────────────────────────────────────────────────── echo "" echo "╔═══════════════════════════════════════════════════════╗" -echo "║ Kopia Backup Restore ║" +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.sh" + && die "No snapshots found. Run a backup first: sudo $HERE/backup_kopia.sh" -# Build source arrays (newest-first per source) 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?" @@ -121,7 +149,6 @@ mapfile -t SNAP_IDS < <(echo "$SNAP_JSON" | jq -r --arg p "$SOURCE_PATH" ' 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 @@ -153,8 +180,6 @@ read -rp "Select [1/2] or q to quit: " MODE [[ "$MODE" =~ ^[qQ]$ ]] && echo "Cancelled." && exit 0 case "$MODE" in - -# ── Inspect: restore to /tmp, nothing touched ───────────────────────────────── 1) TEMP_DIR="/tmp/kopia-inspect-$(date +%Y%m%d-%H%M%S)" mkdir -p "$TEMP_DIR" @@ -174,15 +199,12 @@ case "$MODE" in fi ;; -# ── Restore in place: move aside, restore, offer rollback instructions ───────── 2) SINFO=$(docker_info_for_path "$SOURCE_PATH") SVC_NAME="${SINFO%% *}" COMPOSE_FILE="${SINFO##* }" - # If no match, both vars will be empty or equal [ "$SVC_NAME" = "$COMPOSE_FILE" ] && SVC_NAME="" && COMPOSE_FILE="" - # Stop associated Docker service if running STOPPED=false if [ -n "$SVC_NAME" ] && [ -n "$COMPOSE_FILE" ] \ && docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$SVC_NAME"; then @@ -201,7 +223,6 @@ case "$MODE" in fi fi - # Move current data aside ASIDE="${SOURCE_PATH}.pre-restore-$(date +%Y%m%d-%H%M%S)" echo "" if [ -e "$SOURCE_PATH" ]; then @@ -212,7 +233,6 @@ case "$MODE" in warn "Source path doesn't exist yet: $SOURCE_PATH — restoring fresh." fi - # Restore snapshot mkdir -p "$SOURCE_PATH" info "Restoring $SNAPSHOT_TIME → $SOURCE_PATH ..." if k restore "$SNAPSHOT_ID" "$SOURCE_PATH"; then @@ -230,7 +250,6 @@ case "$MODE" in exit 1 fi - # Restart container if [ "$STOPPED" = true ] && [ -n "$COMPOSE_FILE" ]; then echo "" read -rp " Start '$SVC_NAME' now? (Y/n): " START_YN @@ -251,17 +270,15 @@ case "$MODE" in echo " Restored to : $SOURCE_PATH" [ -e "$ASIDE" ] && echo " Previous data : $ASIDE" echo "" - echo " Verify your data, then:" - echo "" if [ -e "$ASIDE" ]; then - echo " Keep the restore (delete aside copy when satisfied):" - echo " rm -rf \"$ASIDE\"" + 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" + 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 "" ;; diff --git a/services/backup.sh b/services/backup.sh index 860e678..403610e 100644 --- a/services/backup.sh +++ b/services/backup.sh @@ -11,9 +11,9 @@ # New services are auto-discovered on every run — no reconfiguration needed. # # Creates: ~/docker/backup/ -# backup.conf settings + per-service destination map (chmod 600) -# backup.sh worker (run directly or via systemd timer) -# restore// restore_kopia_backup.sh + backup.conf per destination +# backup.conf settings + per-service destination map (chmod 600) +# backup_kopia.sh worker (run directly or via systemd timer) +# restore_kopia.sh interactive restore helper register_service backup backup "Encrypted backup of all Docker services (full restore)" @@ -22,9 +22,8 @@ install_backup() { local DIR="$DOCKER_DIR/backup" local CONF_FILE="$DIR/backup.conf" - local WORKER="$DIR/backup.sh" - local RESTORE_DIR="$DIR/restore" - local RESTORE_SRC="${HERE:-}/extras/restore_kopia_backup.sh" + local WORKER="$DIR/backup_kopia.sh" + local RESTORE="$DIR/restore_kopia.sh" local SVC_NAME="post-install-backup" echo "" @@ -248,7 +247,7 @@ install_backup() { KEEP_LATEST="${KEEP_LATEST:-7}" # ── 7. Create dirs + init Kopia repos ──────────────────────────────────── - mkdir -p "$DIR" "$RESTORE_DIR" + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" local repo pw cfg @@ -288,7 +287,7 @@ install_backup() { echo "# ── backup.conf ────────────────────────────────────────────────────────────" echo "# Generated $(date '+%F %T'). Safe to hand-edit." echo "# Worker : sudo $WORKER" - echo "# Restore: sudo $RESTORE_DIR//restore_kopia_backup.sh" + echo "# Restore: sudo $RESTORE" echo "" echo "KOPIA=\"$KOPIA_BIN\"" echo "" @@ -326,158 +325,23 @@ install_backup() { chmod 600 "$CONF_FILE" log_success "backup.conf written (chmod 600)" - # ── 9. Generate worker script ───────────────────────────────────────────── - log_info "Writing worker $WORKER ..." - cat > "$WORKER" << 'WORKEREOF' -#!/bin/bash -# Generated by the backup installer. -# Backs up full ~/docker// directories via Kopia. -# Minecraft instances: flush to disk (save-all) then snapshot — no downtime. -# All other services: stop → snapshot → restart for consistency. -# -# sudo ./backup.sh run a full backup cycle -# sudo ./backup.sh snapshots list all snapshots (all repos) -# sudo ./backup.sh policy show retention policies -# -# Reads backup.conf from the same directory. -set -uo pipefail - -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONF="${BACKUP_CONF:-$HERE/backup.conf}" -[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run the backup service)"; exit 1; } -# 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_DIR="$ACTUAL_HOME/docker" - -log() { echo "[$(date '+%F %T')] $*"; } - -kp_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" ] || { log "Unknown destination: $dest"; return 1; } - env KOPIA_PASSWORD="$pw" "$KOPIA" --config-file="$cfg" "$@" -} - -dest_for_svc() { - local var="SVC_${1//-/_}" - echo "${!var:-${DEST_DEFAULT:-default}}" -} - -# Detect itzg Minecraft instances by their Dockerfile signature. -is_minecraft() { [ -f "${1}Dockerfile" ] && grep -qs itzg "${1}Dockerfile"; } - -case "${1:-run}" in - snapshots) - for dest in ${DEST_NAMES:-default}; do - echo ""; echo "── dest: $dest ──" - kp_for "$dest" snapshot list 2>/dev/null || true - done - exit 0 ;; - policy) - for dest in ${DEST_NAMES:-default}; do - echo ""; echo "── dest: $dest ──" - kp_for "$dest" policy show --global 2>/dev/null || true - done - exit 0 ;; -esac - -log "===== Backup starting =====" -rc=0 - -for svc_dir in "$DOCKER_DIR"/*/; do - [ -f "${svc_dir}docker-compose.yml" ] || continue - svc="$(basename "$svc_dir")" - [[ "$svc" == "backup" || "$svc" == "gaming-backup" ]] && continue - - dest="$(dest_for_svc "$svc")" - _repo_var="DEST_${dest}_REPO" - [ -n "${!_repo_var:-}" ] || { log "SKIP $svc — dest '$dest' not configured in conf"; continue; } - - if is_minecraft "$svc_dir"; then - # ── Minecraft: flush world to disk, snapshot without stopping ──────── - if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then - log "Flushing Minecraft world '$svc' (save-all, no downtime)..." - docker exec "$svc" mc-send-to-console save-all flush 2>/dev/null \ - || docker exec "$svc" rcon-cli save-all 2>/dev/null || true - sleep 5 - fi - log "Snapshotting $svc (dest: $dest)..." - if kp_for "$dest" snapshot create --description="backup: $svc" "$svc_dir"; then - log "OK $svc (Minecraft, no downtime)" - else - log "WARNING: snapshot failed for $svc"; rc=1 - fi - else - # ── All other services: stop → snapshot → restart ───────────────── - STOPPED=false - if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then - log "Stopping $svc..." - docker compose -f "${svc_dir}docker-compose.yml" down 2>/dev/null \ - || docker stop "$svc" 2>/dev/null \ - || log "WARNING: could not stop $svc — snapshotting live (consistency not guaranteed)" - STOPPED=true - fi - - log "Snapshotting $svc (dest: $dest)..." - if kp_for "$dest" snapshot create --description="backup: $svc" "$svc_dir"; then - log "OK $svc" - else - log "WARNING: snapshot failed for $svc"; rc=1 - fi - - if [ "$STOPPED" = true ]; then - log "Starting $svc..." - docker compose -f "${svc_dir}docker-compose.yml" up -d 2>/dev/null \ - || log "WARNING: could not restart $svc — run: docker compose -f ${svc_dir}docker-compose.yml up -d" - fi - fi -done - -if [ "${REMOTE_TYPE:-none}" != "none" ] && [ -n "${REMOTE_TYPE:-}" ]; then - for dest in ${DEST_NAMES:-default}; do - log "Mirroring '$dest' offsite ($REMOTE_TYPE)..." - # shellcheck disable=SC2086 - kp_for "$dest" repository sync-to "$REMOTE_TYPE" $REMOTE_ARGS \ - || { log "WARNING: mirror failed for '$dest'"; rc=1; } - done -fi - -if [ "$rc" -eq 0 ]; then - log "===== Backup complete =====" -else - log "===== Backup finished WITH WARNINGS (see above) =====" -fi -exit "$rc" -WORKEREOF + # ── 9. Install worker script ────────────────────────────────────────────── + log_info "Installing worker $WORKER ..." + cp "${HERE:-}/extras/backup_kopia.sh" "$WORKER" chmod +x "$WORKER" chown root:root "$WORKER" 2>/dev/null || true - log_success "backup.sh written" + log_success "backup_kopia.sh installed" - # ── 10. Restore scripts (one per destination) ──────────────────────────── + # ── 10. Install restore script ──────────────────────────────────────────── + local RESTORE_SRC="${HERE:-}/extras/restore_kopia.sh" if [ -f "$RESTORE_SRC" ]; then - for dn in "${DEST_NAMES_ARR[@]}"; do - local dest_rdir="$RESTORE_DIR/$dn" - mkdir -p "$dest_rdir" - cp "$RESTORE_SRC" "$dest_rdir/restore_kopia_backup.sh" - chmod +x "$dest_rdir/restore_kopia_backup.sh" - { - echo "# backup.conf for backup destination '$dn'" - echo "# Read by restore_kopia_backup.sh in this directory." - echo "KOPIA=\"$KOPIA_BIN\"" - echo "KOPIA_CONFIG=\"${DEST_CONFIGS[$dn]}\"" - printf "KOPIA_PASSWORD='%s'\n" "${DEST_PASSWORDS[$dn]}" - } > "$dest_rdir/backup.conf" - chown root:root "$dest_rdir/backup.conf" 2>/dev/null || true - chmod 600 "$dest_rdir/backup.conf" - log_success "restore/$dn/ ready" - done + cp "$RESTORE_SRC" "$RESTORE" + chmod +x "$RESTORE" + chown root:root "$RESTORE" 2>/dev/null || true + log_success "restore_kopia.sh installed" else - log_warning "extras/restore_kopia_backup.sh not found — restore scripts not installed" - log_warning "Copy it manually: cp extras/restore_kopia_backup.sh $RESTORE_DIR//" + log_warning "extras/restore_kopia.sh not found — restore script not installed" + log_warning "Copy it manually: cp extras/restore_kopia.sh $RESTORE" fi # ── 11. Systemd timer ──────────────────────────────────────────────────── @@ -559,11 +423,9 @@ SVCEOF echo " sudo $WORKER back up now" echo " sudo $WORKER snapshots list all snapshots" echo "" - echo " Restore (per destination):" - for dn in "${DEST_NAMES_ARR[@]}"; do - echo " sudo $RESTORE_DIR/$dn/restore_kopia_backup.sh" - echo " sudo $RESTORE_DIR/$dn/restore_kopia_backup.sh --list" - done + echo " Restore:" + echo " sudo $RESTORE" + echo " sudo $RESTORE --list" echo "" [ -n "$AUTORUN" ] && echo " $AUTORUN" && echo "" log_warning "Save your passwords (in backup.conf) somewhere safe —" diff --git a/services/borg-backup.sh b/services/borg-backup.sh index 9bdeaa0..369110c 100644 --- a/services/borg-backup.sh +++ b/services/borg-backup.sh @@ -11,9 +11,9 @@ # Vorta GUI, SSH remote repos out of the box, widely packaged. # # Creates: ~/docker/borg-backup/ -# backup.conf settings + per-dest repo/passphrase (chmod 600) -# borg-backup.sh worker (run directly or via systemd timer) -# restore// restore_borg_backup.sh + backup.conf per destination +# backup.conf settings + per-dest repo/passphrase (chmod 600) +# backup_borg.sh worker (run directly or via systemd timer) +# restore_borg.sh interactive restore helper register_service borg-backup backup "Encrypted backup of all Docker services via Borg" @@ -22,9 +22,8 @@ install_borg_backup() { local DIR="$DOCKER_DIR/borg-backup" local CONF_FILE="$DIR/backup.conf" - local WORKER="$DIR/borg-backup.sh" - local RESTORE_DIR="$DIR/restore" - local RESTORE_SRC="${HERE:-}/extras/restore_borg_backup.sh" + local WORKER="$DIR/backup_borg.sh" + local RESTORE="$DIR/restore_borg.sh" local SVC_NAME="post-install-borg-backup" echo "" @@ -193,7 +192,7 @@ install_borg_backup() { KEEP_MONTHLY="${KEEP_MONTHLY:-3}" # ── 7. Create dirs + init Borg repos ───────────────────────────────────── - mkdir -p "$DIR" "$RESTORE_DIR" + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" local repo pw @@ -232,7 +231,7 @@ install_borg_backup() { echo "# ── backup.conf ────────────────────────────────────────────────────────────" echo "# Generated $(date '+%F %T'). Safe to hand-edit." echo "# Worker : sudo $WORKER" - echo "# Restore: sudo $RESTORE_DIR//restore_borg_backup.sh" + echo "# Restore: sudo $RESTORE" echo "" echo "BORG=\"$BORG_BIN\"" echo "" @@ -268,182 +267,23 @@ install_borg_backup() { chmod 600 "$CONF_FILE" log_success "backup.conf written (chmod 600)" - # ── 9. Generate worker script ───────────────────────────────────────────── - log_info "Writing worker $WORKER ..." - cat > "$WORKER" << 'WORKEREOF' -#!/bin/bash -# Generated by the borg-backup installer. -# Backs up full ~/docker// directories via Borg. -# Minecraft instances: flush to disk (save-all) then archive — no downtime. -# All other services: stop → archive → restart for consistency. -# -# sudo ./borg-backup.sh run a full backup cycle -# sudo ./borg-backup.sh list list all archives in all repos -# sudo ./borg-backup.sh info show repo info for all destinations -# -# Reads backup.conf from the same directory. -set -uo pipefail - -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONF="${BACKUP_CONF:-$HERE/backup.conf}" -[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run the borg-backup service)"; exit 1; } -# 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_DIR="$ACTUAL_HOME/docker" - -log() { echo "[$(date '+%F %T')] $*"; } - -repo_for() { - local var="DEST_${1}_REPO"; echo "${!var:-}" -} -pass_for() { - local var="DEST_${1}_PASSPHRASE"; echo "${!var:-}" -} -dest_for_svc() { - local var="SVC_${1//-/_}"; echo "${!var:-${DEST_DEFAULT:-default}}" -} - -b_for() { - local dest="$1"; shift - local repo; repo="$(repo_for "$dest")" - local pass; pass="$(pass_for "$dest")" - [ -n "$repo" ] || { log "Unknown destination: $dest"; return 1; } - BORG_PASSPHRASE="$pass" BORG_REPO="$repo" "$BORG" "$@" -} - -is_minecraft() { [ -f "${1}Dockerfile" ] && grep -qs itzg "${1}Dockerfile"; } - -case "${1:-run}" in - list) - for dest in ${DEST_NAMES:-default}; do - echo ""; echo "── dest: $dest ($(repo_for "$dest")) ──" - b_for "$dest" list 2>/dev/null | sort -r | head -30 || true - done - exit 0 ;; - info) - for dest in ${DEST_NAMES:-default}; do - echo ""; echo "── dest: $dest ──" - b_for "$dest" info 2>/dev/null || true - done - exit 0 ;; -esac - -log "===== Borg backup starting =====" -rc=0 -TS="$(date +%Y-%m-%dT%H-%M-%S)" - -for svc_dir in "$DOCKER_DIR"/*/; do - [ -f "${svc_dir}docker-compose.yml" ] || continue - svc="$(basename "$svc_dir")" - [[ "$svc" == "borg-backup" || "$svc" == "backup" || "$svc" == "gaming-backup" ]] && continue - - dest="$(dest_for_svc "$svc")" - repo="$(repo_for "$dest")" - [ -n "$repo" ] || { log "SKIP $svc — dest '$dest' not configured"; continue; } - - ARCHIVE="${svc}-${TS}" - - if is_minecraft "$svc_dir"; then - # ── Minecraft: flush world to disk, archive without stopping ───────── - if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then - log "Flushing Minecraft world '$svc' (save-all, no downtime)..." - docker exec "$svc" mc-send-to-console save-all flush 2>/dev/null \ - || docker exec "$svc" rcon-cli save-all 2>/dev/null || true - sleep 5 - fi - log "Archiving $svc → $dest::$ARCHIVE ..." - if b_for "$dest" create \ - --compression=zstd,6 \ - --exclude-caches \ - --stats \ - "::$ARCHIVE" "$svc_dir" 2>&1 | while IFS= read -r line; do log " $line"; done; then - log "OK $svc (Minecraft, no downtime)" - else - log "WARNING: archive failed for $svc"; rc=1 - fi - else - # ── All other services: stop → archive → restart ────────────────── - STOPPED=false - if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then - log "Stopping $svc..." - docker compose -f "${svc_dir}docker-compose.yml" down 2>/dev/null \ - || docker stop "$svc" 2>/dev/null \ - || log "WARNING: could not stop $svc — archiving live (consistency not guaranteed)" - STOPPED=true - fi - - log "Archiving $svc → $dest::$ARCHIVE ..." - if b_for "$dest" create \ - --compression=zstd,6 \ - --exclude-caches \ - --stats \ - "::$ARCHIVE" "$svc_dir" 2>&1 | while IFS= read -r line; do log " $line"; done; then - log "OK $svc" - else - log "WARNING: archive failed for $svc"; rc=1 - fi - - if [ "$STOPPED" = true ]; then - log "Starting $svc..." - docker compose -f "${svc_dir}docker-compose.yml" up -d 2>/dev/null \ - || log "WARNING: could not restart $svc — run: docker compose -f ${svc_dir}docker-compose.yml up -d" - fi - fi - - # Prune old archives for this service in its destination repo - log "Pruning old archives for $svc in '$dest'..." - b_for "$dest" prune \ - --keep-daily="${KEEP_DAILY:-7}" \ - --keep-weekly="${KEEP_WEEKLY:-4}" \ - --keep-monthly="${KEEP_MONTHLY:-3}" \ - --glob-archives="${svc}-*" \ - --list 2>/dev/null \ - || log "WARNING: prune failed for $svc (non-fatal)" -done - -# Compact each repo to reclaim space freed by pruning -for dest in ${DEST_NAMES:-default}; do - repo="$(repo_for "$dest")" - [ -n "$repo" ] || continue - log "Compacting repo '$dest'..." - b_for "$dest" compact 2>/dev/null || true -done - -if [ "$rc" -eq 0 ]; then - log "===== Borg backup complete =====" -else - log "===== Borg backup finished WITH WARNINGS (see above) =====" -fi -exit "$rc" -WORKEREOF + # ── 9. Install worker script ────────────────────────────────────────────── + log_info "Installing worker $WORKER ..." + cp "${HERE:-}/extras/backup_borg.sh" "$WORKER" chmod +x "$WORKER" chown root:root "$WORKER" 2>/dev/null || true - log_success "borg-backup.sh written" + log_success "backup_borg.sh installed" - # ── 10. Restore scripts (one per destination) ───────────────────────────── + # ── 10. Install restore script ──────────────────────────────────────────── + local RESTORE_SRC="${HERE:-}/extras/restore_borg.sh" if [ -f "$RESTORE_SRC" ]; then - for dn in "${DEST_NAMES_ARR[@]}"; do - local dest_rdir="$RESTORE_DIR/$dn" - mkdir -p "$dest_rdir" - cp "$RESTORE_SRC" "$dest_rdir/restore_borg_backup.sh" - chmod +x "$dest_rdir/restore_borg_backup.sh" - { - echo "# backup.conf for borg-backup destination '$dn'" - echo "# Read by restore_borg_backup.sh in this directory." - echo "BORG=\"$BORG_BIN\"" - echo "BORG_REPO=\"${DEST_REPOS[$dn]}\"" - printf "BORG_PASSPHRASE='%s'\n" "${DEST_PASSWORDS[$dn]}" - } > "$dest_rdir/backup.conf" - chown root:root "$dest_rdir/backup.conf" 2>/dev/null || true - chmod 600 "$dest_rdir/backup.conf" - log_success "restore/$dn/ ready" - done + cp "$RESTORE_SRC" "$RESTORE" + chmod +x "$RESTORE" + chown root:root "$RESTORE" 2>/dev/null || true + log_success "restore_borg.sh installed" else - log_warning "extras/restore_borg_backup.sh not found — restore scripts not installed" - log_warning "Copy it manually: cp extras/restore_borg_backup.sh $RESTORE_DIR//" + log_warning "extras/restore_borg.sh not found — restore script not installed" + log_warning "Copy it manually: cp extras/restore_borg.sh $RESTORE" fi # ── 11. Systemd timer ───────────────────────────────────────────────────── @@ -525,10 +365,8 @@ SVCEOF echo " sudo $WORKER info repo stats" echo "" echo " Restore:" - for dn in "${DEST_NAMES_ARR[@]}"; do - echo " sudo $RESTORE_DIR/$dn/restore_borg_backup.sh" - echo " sudo $RESTORE_DIR/$dn/restore_borg_backup.sh --list" - done + echo " sudo $RESTORE" + echo " sudo $RESTORE --list" echo "" log_warning "IMPORTANT — back up your Borg key and passphrase now." echo " The key is stored in the repo itself (repokey-blake2 encryption)." diff --git a/services/gaming-backup.sh b/services/gaming-backup.sh index 476cfb6..ea7fb44 100644 --- a/services/gaming-backup.sh +++ b/services/gaming-backup.sh @@ -26,7 +26,7 @@ install_gaming_backup() { # ── Repo-conventional paths ────────────────────────────────────────────── local BACKUP_DIR="$DOCKER_DIR/gaming-backup" local CONF_FILE="$BACKUP_DIR/backup.conf" # editable settings - local WORKER="$BACKUP_DIR/gaming-backup.sh" # generated worker + local WORKER="$BACKUP_DIR/backup_gaming.sh" # worker script local KOPIA_CONFIG="/etc/gaming-backup/repository.config" local CACHE_DIR="/var/cache/gaming-backup" local SVC_NAME="post-install-gaming-backup" @@ -273,108 +273,23 @@ CONFEOF || log_warning "Could not pre-set Steam ignore policy (applies on first snapshot)." fi - # ── 7. Generate the worker script ──────────────────────────────────────── - log_info "Writing worker $WORKER ..." - cat > "$WORKER" << 'WORKEREOF' -#!/bin/bash -# Generated by the gaming-backup service — frequent game-save snapshots via Kopia. -# Nothing is stopped: Minecraft worlds are flushed to disk (save-all) first. -# -# sudo ./gaming-backup.sh run a backup now -# sudo ./gaming-backup.sh snapshots list snapshots -# sudo ./gaming-backup.sh policy show retention/ignore policy -# -# Reads settings from backup.conf next to this script. -set -uo pipefail - -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONF="${BACKUP_CONF:-$HERE/backup.conf}" -[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run the gaming-backup service)"; exit 1; } -# shellcheck source=/dev/null -source "$CONF" - -export KOPIA_PASSWORD -log() { echo "[$(date '+%F %T')] $*"; } -k() { "$KOPIA" --config-file="$KOPIA_CONFIG" "$@"; } - -if ! k repository status >/dev/null 2>&1; then - log "ERROR: not connected to a repository — re-run the gaming-backup service" - exit 1 -fi - -case "${1:-run}" in - snapshots) k snapshot list; exit 0 ;; - policy) k policy show --global; exit 0 ;; -esac - -log "===== Gaming backup starting =====" - -# Flush each running Minecraft world to disk first so snapshots are consistent. -if [ -n "${MC_BASE_DIR:-}" ] && command -v docker >/dev/null 2>&1; then - _flushed=0 - for d in "$MC_BASE_DIR"/*/; do - [ -f "${d}Dockerfile" ] && grep -qs itzg "${d}Dockerfile" || continue - name="$(basename "$d")" - if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then - log "Flushing Minecraft world '$name' (save-all)..." - docker exec "$name" mc-send-to-console save-all flush 2>/dev/null \ - || docker exec "$name" rcon-cli save-all 2>/dev/null || true - _flushed=1 - fi - done - [ "$_flushed" = 1 ] && sleep 5 -fi - -rc=0 -snap() { - local label="$1" path="$2" - if [ -z "$path" ] || [ ! -e "$path" ]; then - log "skip $label — not found: ${path:-}"; return - fi - log "Snapshotting $label: $path" - if ! k snapshot create --description="gaming: $label" "$path"; then - log "WARNING: snapshot failed for $label"; rc=1 - fi -} - -if [ -n "${MC_BASE_DIR:-}" ]; then - for d in "$MC_BASE_DIR"/*/; do - [ -f "${d}Dockerfile" ] && grep -qs itzg "${d}Dockerfile" && [ -d "${d}data" ] || continue - nm="$(basename "$d")" - case "$nm" in minecraft*) lbl="$nm" ;; *) lbl="minecraft-$nm" ;; esac - snap "$lbl" "${d}data" - done -fi -[ "${BACKUP_SAVES:-no}" = yes ] && snap "emulator-saves" "$GAME_STORAGE_DIR/saves" -[ "${BACKUP_STEAM:-no}" = yes ] && snap "steam-userdata" "$GAME_STORAGE_DIR/steam" -[ "${BACKUP_MEDIA:-no}" = yes ] && snap "es-de-media" "$GAME_STORAGE_DIR/media" -[ "${BACKUP_WOLF:-no}" = yes ] && snap "wolf-state" "$WOLF_STATE_DIR" - -if [ "${REMOTE_TYPE:-none}" != "none" ] && [ -n "${REMOTE_TYPE:-}" ]; then - log "Mirroring repository to remote ($REMOTE_TYPE)..." - # shellcheck disable=SC2086 - if ! k repository sync-to "$REMOTE_TYPE" $REMOTE_ARGS; then - log "WARNING: remote mirror failed"; rc=1 - fi -fi - -if [ "$rc" -eq 0 ]; then log "===== Gaming backup complete ====="; else log "===== Gaming backup finished WITH WARNINGS ====="; fi -exit "$rc" -WORKEREOF + # ── 7. Install the worker script ───────────────────────────────────────── + log_info "Installing worker $WORKER ..." + cp "${HERE:-}/extras/backup_gaming.sh" "$WORKER" chmod +x "$WORKER" chown root:root "$WORKER" 2>/dev/null || true - log_success "gaming-backup.sh written" + log_success "backup_gaming.sh installed" # ── Copy the interactive restore script ─────────────────────────────────── - local RESTORE_SRC="${HERE:-}/extras/restore_kopia_backup.sh" - local RESTORE_DEST="$BACKUP_DIR/restore_kopia_backup.sh" + local RESTORE_SRC="${HERE:-}/extras/restore_kopia.sh" + local RESTORE_DEST="$BACKUP_DIR/restore_kopia.sh" if [ -f "$RESTORE_SRC" ]; then cp "$RESTORE_SRC" "$RESTORE_DEST" chmod +x "$RESTORE_DEST" chown root:root "$RESTORE_DEST" 2>/dev/null || true - log_success "restore_kopia_backup.sh installed" + log_success "restore_kopia.sh installed" else - log_warning "extras/restore_kopia_backup.sh not found — restore script not installed" + log_warning "extras/restore_kopia.sh not found — restore script not installed" fi # ── 8. Install systemd timer (fallback: cron) ──────────────────────────── @@ -452,8 +367,8 @@ UNITEOF echo " Commands:" echo " sudo $WORKER back up now" echo " sudo $WORKER snapshots list snapshots" - echo " sudo $RESTORE_DEST interactive restore" - echo " sudo $RESTORE_DEST --list list all snapshot sources" + echo " sudo $RESTORE_DEST interactive restore" + echo " sudo $RESTORE_DEST --list list all snapshot sources" echo " $AUTORUN" echo "" echo " Tip: also install the 'backup' service for nightly full-service recovery."