Add borg-backup service with restore script
New service: borg-backup (backup group) - Installs borgbackup from apt - Same coverage as Kopia backup: full ~/docker/<service>/ snapshots - Minecraft: flush world (save-all), archive, no downtime - All others: stop → archive → restart - Multi-destination support with per-service routing - SSH remote repos supported natively (user@host:/path) - Per-service pruning: keep-daily/weekly/monthly - repo compact after each run - Systemd timer (with cron fallback) - repokey-blake2 encryption; key export reminder in summary - worker.sh: run / list / info subcommands New restore helper: extras/restore_borg_backup.sh - Installed to ~/docker/borg-backup/restore/<dest>/ - Interactive: lists services → pick archive → inspect (/tmp) or restore in-place - In-place restore: stops container, moves current data aside, extracts, restarts - Old data kept as .pre-restore-DATE with rollback instructions printed https://claude.ai/code/session_019XgsQ13XKm4Zj3cNsDNwHj
This commit is contained in:
@@ -72,7 +72,7 @@ Update them any time with `sudo ./setup.sh configure`.
|
||||
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
||||
| `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` |
|
||||
| `extras` | `silent-send`, `sync-cc` |
|
||||
| `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) |
|
||||
| `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency); `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) |
|
||||
|
||||
Run `./setup.sh --list` to see descriptions.
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/bin/bash
|
||||
# extras/restore_borg_backup.sh — interactive restore from a Borg archive.
|
||||
# Installed to ~/docker/borg-backup/restore/<dest>/ 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
|
||||
#
|
||||
# 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 (run: sudo setup.sh borg-backup)"
|
||||
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
|
||||
}
|
||||
|
||||
# ── --list ────────────────────────────────────────────────────────────────────
|
||||
if [ "${1:-}" = "--list" ]; then
|
||||
echo ""
|
||||
info "Archives in $BORG_REPO:"
|
||||
echo ""
|
||||
b list 2>/dev/null | sort -t- -k2 -r
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Interactive restore ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════╗"
|
||||
echo "║ Borg Backup Restore ║"
|
||||
echo "╚═══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
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"
|
||||
|
||||
# ── Group archives by service prefix (everything before the first timestamp) ──
|
||||
# Archive names: <service>-YYYY-MM-DDTHH-MM-SS
|
||||
# Extract unique service prefixes.
|
||||
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 for that service ─────────────────────────────────────────
|
||||
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
|
||||
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"
|
||||
|
||||
# ── 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"
|
||||
|
||||
# ── 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
|
||||
|
||||
# ── 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 ""
|
||||
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
|
||||
;;
|
||||
|
||||
# ── 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
|
||||
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
|
||||
|
||||
# Move current data aside
|
||||
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
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# Restart container
|
||||
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 ""
|
||||
echo " Verify your data, then:"
|
||||
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
|
||||
@@ -0,0 +1,544 @@
|
||||
#!/bin/bash
|
||||
# services/borg-backup.sh — Full Docker-service backup via Borg.
|
||||
# Part of the modular post-install system (sourced by setup.sh).
|
||||
#
|
||||
# Backs up each entire ~/docker/<service>/ directory (compose file, config,
|
||||
# data, databases — everything needed to restore from nothing).
|
||||
# Minecraft instances: flush world (save-all), snapshot, no downtime
|
||||
# All other services: stop → snapshot → restart for consistency
|
||||
#
|
||||
# Borg advantages over Kopia: mature tooling, Borgmatic YAML config option,
|
||||
# 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/<dest>/ restore_borg_backup.sh + backup.conf per destination
|
||||
|
||||
register_service borg-backup backup "Encrypted backup of all Docker services via Borg"
|
||||
|
||||
install_borg_backup() {
|
||||
require_docker || return 1
|
||||
|
||||
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 SVC_NAME="post-install-borg-backup"
|
||||
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════╗"
|
||||
echo "║ Borg Backup Setup ║"
|
||||
echo "║ Full ~/docker/<service>/ snapshots ║"
|
||||
echo "╚═══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo " Backs up each entire service directory — compose file, config, data,"
|
||||
echo " databases, everything needed to restore a service from scratch."
|
||||
echo ""
|
||||
echo " Minecraft: world flushed to disk (save-all), snapshot, NO downtime."
|
||||
echo " Everything else: stopped briefly, snapshotted, restarted."
|
||||
echo ""
|
||||
echo " Borg supports local paths AND remote repos over SSH:"
|
||||
echo " local: /mnt/backup-drive/borg-repo"
|
||||
echo " remote: user@hostname:/path/to/repo"
|
||||
echo " ssh://user@hostname:2222/path/to/repo"
|
||||
echo " Remote repos require passwordless SSH key access to the remote host."
|
||||
echo ""
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would discover services under $DOCKER_DIR"
|
||||
echo "[DRY-RUN] Would create $DIR with conf, worker, and restore scripts"
|
||||
echo "[DRY-RUN] Would init Borg repo(s) at user-specified paths"
|
||||
echo "[DRY-RUN] Would install systemd timer"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── 1. Borg ──────────────────────────────────────────────────────────────
|
||||
if ! command -v borg >/dev/null 2>&1; then
|
||||
log_info "Installing borgbackup..."
|
||||
apt-get install -y borgbackup \
|
||||
|| { log_error "Failed to install borgbackup. Try: sudo apt install borgbackup"; return 1; }
|
||||
fi
|
||||
local BORG_BIN; BORG_BIN="$(command -v borg)"
|
||||
log_success "Borg: $("$BORG_BIN" --version 2>/dev/null)"
|
||||
|
||||
# ── 2. Discover installed services ───────────────────────────────────────
|
||||
local -a ALL_SVCS=()
|
||||
local d svc
|
||||
for d in "$DOCKER_DIR"/*/; do
|
||||
[ -f "${d}docker-compose.yml" ] || continue
|
||||
svc="$(basename "$d")"
|
||||
[[ "$svc" == "borg-backup" || "$svc" == "backup" || "$svc" == "gaming-backup" ]] && continue
|
||||
ALL_SVCS+=("$svc")
|
||||
done
|
||||
|
||||
if [ "${#ALL_SVCS[@]}" -eq 0 ]; then
|
||||
log_warning "No services found under $DOCKER_DIR — auto-detected on each backup run."
|
||||
else
|
||||
log_info "Services found: ${ALL_SVCS[*]}"
|
||||
fi
|
||||
|
||||
# ── 3. Destinations ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " BACKUP DESTINATIONS"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Each destination is a Borg repository (local path or user@host:/path)."
|
||||
echo " For best resilience: use a different drive or remote host from your data."
|
||||
echo " Destination names must be letters, numbers, and underscores only."
|
||||
echo ""
|
||||
|
||||
local DEFAULT_DEST="$ACTUAL_HOME/backups/borg-repo"
|
||||
local _repo=""
|
||||
prompt_text " Default repository path [${DEFAULT_DEST}]:" "$DEFAULT_DEST" _repo
|
||||
_repo="${_repo/#\~/$ACTUAL_HOME}"; _repo="${_repo%/}"
|
||||
|
||||
local -a DEST_NAMES_ARR=("default")
|
||||
local -A DEST_REPOS=() DEST_PASSWORDS=()
|
||||
DEST_REPOS["default"]="$_repo"
|
||||
|
||||
local _extra=""
|
||||
prompt_yn " Add more destinations (for services on different drives)? (y/N):" "n" _extra
|
||||
if [[ "$_extra" =~ ^[Yy]$ ]]; then
|
||||
echo ""
|
||||
local _dn _dr
|
||||
while true; do
|
||||
prompt_text " Destination name (blank to finish):" "" _dn
|
||||
[ -z "$_dn" ] && break
|
||||
_dn="${_dn//[^a-zA-Z0-9_]/_}"
|
||||
[ "$_dn" = "default" ] && { log_warning " 'default' is reserved — use another name."; continue; }
|
||||
prompt_text " Path for '$_dn' repository:" "" _dr
|
||||
[ -z "$_dr" ] && continue
|
||||
_dr="${_dr/#\~/$ACTUAL_HOME}"; _dr="${_dr%/}"
|
||||
DEST_REPOS["$_dn"]="$_dr"
|
||||
DEST_NAMES_ARR+=("$_dn")
|
||||
log_success " Destination '$_dn' → $_dr"
|
||||
done
|
||||
fi
|
||||
|
||||
# ── 4. Service → destination assignment ──────────────────────────────────
|
||||
local -A SVC_DEST_MAP=()
|
||||
if [ "${#ALL_SVCS[@]}" -gt 0 ] && [ "${#DEST_NAMES_ARR[@]}" -gt 1 ]; then
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " ASSIGN SERVICES TO DESTINATIONS"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Destinations:"
|
||||
local dn
|
||||
for dn in "${DEST_NAMES_ARR[@]}"; do
|
||||
printf " %-16s %s\n" "$dn" "${DEST_REPOS[$dn]}"
|
||||
done
|
||||
echo ""
|
||||
echo " Press Enter to accept the default for each service."
|
||||
echo ""
|
||||
local _d
|
||||
for svc in "${ALL_SVCS[@]}"; do
|
||||
prompt_text " $svc [default]:" "default" _d
|
||||
if [ -n "$_d" ] && [ "$_d" != "default" ] && [ -n "${DEST_REPOS[$_d]:-}" ]; then
|
||||
SVC_DEST_MAP["$svc"]="$_d"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── 5. Passwords ─────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
log_info "Setting repository passphrases (stored in backup.conf, chmod 600)..."
|
||||
for dn in "${DEST_NAMES_ARR[@]}"; do
|
||||
local pw=""
|
||||
if [ "$UNATTENDED" = true ]; then
|
||||
pw="$(generate_password 32)"
|
||||
else
|
||||
read -rsp " Passphrase for '$dn' [Enter = auto-generate]: " pw; echo
|
||||
fi
|
||||
[ -z "$pw" ] && pw="$(generate_password 32)" && log_info " Auto-generated passphrase for '$dn'."
|
||||
DEST_PASSWORDS["$dn"]="$pw"
|
||||
done
|
||||
|
||||
# ── 6. Schedule & retention ──────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " SCHEDULE & RETENTION"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Minecraft runs uninterrupted; other services stop briefly (seconds each)."
|
||||
echo " Schedule for off-peak hours."
|
||||
echo ""
|
||||
echo " 1) Daily at 02:00 (recommended)"
|
||||
echo " 2) Every 12 hours"
|
||||
echo " 3) Weekly (Sunday 02:00)"
|
||||
echo " 4) Custom (systemd OnCalendar)"
|
||||
echo ""
|
||||
local _sch=""
|
||||
prompt_text " How often? [1]:" "1" _sch
|
||||
local ONCALENDAR SCHED_LABEL
|
||||
case "${_sch:-1}" in
|
||||
2) ONCALENDAR="*-*-* 02,14:00:00"; SCHED_LABEL="every 12 hours" ;;
|
||||
3) ONCALENDAR="Sun *-*-* 02:00:00"; SCHED_LABEL="weekly Sunday 02:00" ;;
|
||||
4) prompt_text " OnCalendar expression:" "*-*-* 02:00:00" ONCALENDAR; SCHED_LABEL="$ONCALENDAR" ;;
|
||||
*) ONCALENDAR="*-*-* 02:00:00"; SCHED_LABEL="daily at 02:00" ;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo " Retention policy — Borg prunes per-service archives independently."
|
||||
echo ""
|
||||
local KEEP_DAILY="" KEEP_WEEKLY="" KEEP_MONTHLY=""
|
||||
prompt_text " Keep last N daily archives per service [7]:" "7" KEEP_DAILY
|
||||
prompt_text " Keep last N weekly archives per service [4]:" "4" KEEP_WEEKLY
|
||||
prompt_text " Keep last N monthly archives per service [3]:" "3" KEEP_MONTHLY
|
||||
KEEP_DAILY="${KEEP_DAILY:-7}"
|
||||
KEEP_WEEKLY="${KEEP_WEEKLY:-4}"
|
||||
KEEP_MONTHLY="${KEEP_MONTHLY:-3}"
|
||||
|
||||
# ── 7. Create dirs + init Borg repos ─────────────────────────────────────
|
||||
mkdir -p "$DIR" "$RESTORE_DIR"
|
||||
ensure_docker_dir_ownership "$DIR"
|
||||
|
||||
local repo pw
|
||||
for dn in "${DEST_NAMES_ARR[@]}"; do
|
||||
repo="${DEST_REPOS[$dn]}"
|
||||
pw="${DEST_PASSWORDS[$dn]}"
|
||||
|
||||
# Skip init for remote repos — user must set them up manually with SSH access.
|
||||
if [[ "$repo" == *@*:* ]] || [[ "$repo" == ssh://* ]]; then
|
||||
log_info "Remote repo '$dn' ($repo) — checking connectivity..."
|
||||
if BORG_PASSPHRASE="$pw" "$BORG_BIN" info "$repo" >/dev/null 2>&1; then
|
||||
log_success "Remote repo '$dn' connected."
|
||||
elif BORG_PASSPHRASE="$pw" "$BORG_BIN" init --encryption=repokey-blake2 "$repo" 2>/dev/null; then
|
||||
log_success "Remote repo '$dn' initialised at $repo"
|
||||
else
|
||||
log_warning "Could not init remote repo '$dn' at $repo."
|
||||
log_warning "Ensure SSH key access to the remote host is configured, then:"
|
||||
log_warning " BORG_PASSPHRASE='${pw}' borg init --encryption=repokey-blake2 ${repo}"
|
||||
fi
|
||||
else
|
||||
mkdir -p "$repo"
|
||||
if BORG_PASSPHRASE="$pw" "$BORG_BIN" info "$repo" >/dev/null 2>&1; then
|
||||
log_success "Repo '$dn' already exists at $repo."
|
||||
else
|
||||
log_info "Initialising repo '$dn' at $repo ..."
|
||||
BORG_PASSPHRASE="$pw" "$BORG_BIN" init --encryption=repokey-blake2 "$repo" \
|
||||
|| { log_error "Failed to init repo '$dn'."; return 1; }
|
||||
log_success "Repo '$dn' initialised at $repo"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 8. Write backup.conf ─────────────────────────────────────────────────
|
||||
log_info "Writing $CONF_FILE ..."
|
||||
{
|
||||
echo "# ── backup.conf ────────────────────────────────────────────────────────────"
|
||||
echo "# Generated $(date '+%F %T'). Safe to hand-edit."
|
||||
echo "# Worker : sudo $WORKER"
|
||||
echo "# Restore: sudo $RESTORE_DIR/<dest>/restore_borg_backup.sh"
|
||||
echo ""
|
||||
echo "BORG=\"$BORG_BIN\""
|
||||
echo ""
|
||||
echo "# Space-separated list of destination names."
|
||||
echo "DEST_NAMES=\"${DEST_NAMES_ARR[*]}\""
|
||||
echo "DEST_DEFAULT=\"default\""
|
||||
echo ""
|
||||
echo "# Retention (applied per-service archive prefix)."
|
||||
echo "KEEP_DAILY=$KEEP_DAILY"
|
||||
echo "KEEP_WEEKLY=$KEEP_WEEKLY"
|
||||
echo "KEEP_MONTHLY=$KEEP_MONTHLY"
|
||||
echo ""
|
||||
for dn in "${DEST_NAMES_ARR[@]}"; do
|
||||
echo "# ── destination: $dn"
|
||||
echo "DEST_${dn}_REPO=\"${DEST_REPOS[$dn]}\""
|
||||
printf "DEST_%s_PASSPHRASE='%s'\n" "$dn" "${DEST_PASSWORDS[$dn]}"
|
||||
echo ""
|
||||
done
|
||||
echo "# ── Service → destination map ───────────────────────────────────────────────"
|
||||
echo "# Format: SVC_<name>=<dest_name> (hyphens become underscores)"
|
||||
echo "# Omit or comment out to use DEST_DEFAULT."
|
||||
for svc in "${ALL_SVCS[@]}"; do
|
||||
local svc_var="${svc//-/_}"
|
||||
local dest_val="${SVC_DEST_MAP[$svc]:-}"
|
||||
if [ -n "$dest_val" ]; then
|
||||
echo "SVC_${svc_var}=\"${dest_val}\""
|
||||
else
|
||||
echo "# SVC_${svc_var}=\"default\""
|
||||
fi
|
||||
done
|
||||
} > "$CONF_FILE"
|
||||
chown root:root "$CONF_FILE" 2>/dev/null || true
|
||||
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/<service>/ 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
|
||||
chmod +x "$WORKER"
|
||||
chown root:root "$WORKER" 2>/dev/null || true
|
||||
log_success "borg-backup.sh written"
|
||||
|
||||
# ── 10. Restore scripts (one per destination) ─────────────────────────────
|
||||
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
|
||||
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/<dest>/"
|
||||
fi
|
||||
|
||||
# ── 11. Systemd timer ─────────────────────────────────────────────────────
|
||||
log_info "Installing systemd timer ($SCHED_LABEL)..."
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
||||
tee "/etc/systemd/system/${SVC_NAME}.service" >/dev/null << SVCEOF
|
||||
[Unit]
|
||||
Description=Post-install Borg backup (full Docker service directories)
|
||||
After=docker.service network-online.target
|
||||
Wants=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/bash $WORKER run
|
||||
SVCEOF
|
||||
|
||||
tee "/etc/systemd/system/${SVC_NAME}.timer" >/dev/null << SVCEOF
|
||||
[Unit]
|
||||
Description=Schedule post-install Borg backup ($SCHED_LABEL)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=$ONCALENDAR
|
||||
Persistent=true
|
||||
RandomizedDelaySec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
SVCEOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now "${SVC_NAME}.timer"
|
||||
log_success "Timer enabled: $SCHED_LABEL"
|
||||
else
|
||||
log_warning "systemd not detected — installing cron fallback."
|
||||
local CRON
|
||||
case "${_sch:-1}" in
|
||||
2) CRON="0 2,14 * * *" ;;
|
||||
3) CRON="0 2 * * 0" ;;
|
||||
*) CRON="0 2 * * *" ;;
|
||||
esac
|
||||
echo "$CRON root /bin/bash $WORKER run >> /var/log/${SVC_NAME}.log 2>&1" \
|
||||
> "/etc/cron.d/${SVC_NAME}"
|
||||
log_success "Cron job installed: $CRON"
|
||||
fi
|
||||
|
||||
# ── 12. Optional first run ────────────────────────────────────────────────
|
||||
echo ""
|
||||
local _now=""
|
||||
prompt_yn " Run the first backup now? (y/N):" "n" _now
|
||||
if [[ "$_now" =~ ^[Yy]$ ]]; then
|
||||
/bin/bash "$WORKER" run || log_warning "First backup reported warnings — check output above."
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " BORG BACKUP CONFIGURED"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Config : $CONF_FILE"
|
||||
echo " Worker : $WORKER"
|
||||
echo " Schedule : $SCHED_LABEL"
|
||||
echo " Retention: ${KEEP_DAILY}d daily / ${KEEP_WEEKLY}w weekly / ${KEEP_MONTHLY}m monthly (per service)"
|
||||
echo ""
|
||||
echo " Destinations:"
|
||||
for dn in "${DEST_NAMES_ARR[@]}"; do
|
||||
printf " %-16s %s\n" "$dn" "${DEST_REPOS[$dn]}"
|
||||
done
|
||||
echo ""
|
||||
if [ "${#ALL_SVCS[@]}" -gt 0 ]; then
|
||||
echo " Services backed up: ${ALL_SVCS[*]}"
|
||||
else
|
||||
echo " Services: none yet — auto-discovered on each run"
|
||||
fi
|
||||
echo ""
|
||||
echo " Commands:"
|
||||
echo " sudo $WORKER back up now"
|
||||
echo " sudo $WORKER list list all archives"
|
||||
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 ""
|
||||
log_warning "IMPORTANT — back up your Borg key and passphrase now."
|
||||
echo " The key is stored in the repo itself (repokey-blake2 encryption)."
|
||||
echo " Export it to a safe location:"
|
||||
for dn in "${DEST_NAMES_ARR[@]}"; do
|
||||
local _rp="${DEST_REPOS[$dn]}"
|
||||
local _pw="${DEST_PASSWORDS[$dn]}"
|
||||
echo " BORG_PASSPHRASE='${_pw}' borg key export ${_rp} ~/borg-key-${dn}.txt"
|
||||
done
|
||||
echo " Store the exported key file and passphrase somewhere that is NOT"
|
||||
echo " on this machine (e.g. USB drive, password manager, offsite)."
|
||||
echo ""
|
||||
}
|
||||
Reference in New Issue
Block a user