Files
ubuntu-post-install/tools/rsync-backup.sh
T
Claude 5749c7adb3 Add tools/rsync-backup.sh — interactive rsync mirror backup with versioned deletes
dest/current/ stays a plain mirror; deleted/overwritten files are moved to
dest/versions/YYYY-MM-DD/ so accidental deletions are recoverable while
intentional --delete still propagates. Unchanged files in version folders
are hardlinked via --link-dest to avoid extra disk cost. Supports local and
remote (SSH) source/destination paths, saved jobs, and a cron hint.

https://claude.ai/code/session_015SmW4EAD6mMLZGVCygy3GR
2026-06-08 17:01:35 +00:00

379 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# tools/rsync-backup.sh — Interactive rsync mirror backup with versioned deletes.
#
# Usage:
# bash rsync-backup.sh
#
# How it works:
# - dest/current/ is always a plain mirror of the source (any file browser works)
# - dest/versions/YYYY-MM-DD/ holds files that were deleted or overwritten that day
# - Unchanged files in versions/ are hardlinked (no extra disk cost)
# - --delete is active, so intentional source deletions propagate; accidental
# deletes are safe because the file lands in today's versions/ folder first
#
# Requirements:
# - rsync installed on both source and destination hosts
# - Passwordless SSH access already configured (ssh-copy-id or equivalent)
# Run: ssh-copy-id user@remotehost before using this script for remote jobs.
set -euo pipefail
# ── Colours ───────────────────────────────────────────────────────────────────
info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; }
ok() { printf '\033[0;32m[OK]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
err() { printf '\033[0;31m[ERROR]\033[0m %s\n' "$*" >&2; }
# ── Config file ───────────────────────────────────────────────────────────────
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/rsync-backup"
CONFIG_FILE="$CONFIG_DIR/jobs.conf"
save_job() {
# save_job NAME SOURCE DEST EXCLUDES
mkdir -p "$CONFIG_DIR"
# Remove any existing entry for this name
if [[ -f "$CONFIG_FILE" ]]; then
grep -v "^JOB_${1}=" "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" && mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE" || true
fi
printf 'JOB_%s=%s\n' "$1" "$(printf '%q %q %q' "$2" "$3" "$4")" >> "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
}
load_jobs() {
[[ -f "$CONFIG_FILE" ]] || return 0
# shellcheck source=/dev/null
source "$CONFIG_FILE"
}
list_jobs() {
[[ -f "$CONFIG_FILE" ]] || return 0
grep '^JOB_' "$CONFIG_FILE" | sed 's/^JOB_/ /' | sed 's/=.*//'
}
# ── Helpers ───────────────────────────────────────────────────────────────────
ask() {
# ask PROMPT DEFAULT VARNAME
local _prompt="$1" _default="$2" _var="$3" _input=""
read -r -p " $_prompt${_default:+ [$_default]}: " _input
printf -v "$_var" '%s' "${_input:-$_default}"
}
ask_yn() {
# ask_yn PROMPT DEFAULT(y/n) VARNAME
local _prompt="$1" _default="$2" _var="$3" _input=""
read -r -p " $_prompt [${_default^^}/${_default,,}]: " _input
_input="${_input:-$_default}"
printf -v "$_var" '%s' "${_input,,}"
}
require_rsync() {
command -v rsync &>/dev/null && return 0
err "rsync is not installed. Install with: sudo apt install rsync"
exit 1
}
test_ssh() {
local host="$1"
info "Testing SSH connection to $host ..."
if ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" true 2>/dev/null; then
ok "SSH connection OK"
return 0
else
err "Cannot connect to $host via SSH without a password."
echo ""
echo " Passwordless SSH is required. Set it up with:"
echo " ssh-copy-id $host"
echo ""
return 1
fi
}
is_remote() {
# returns true if path contains user@host: or host:
[[ "$1" == *:* ]]
}
remote_host() {
echo "${1%%:*}"
}
# ── Core backup logic ─────────────────────────────────────────────────────────
run_backup() {
local name="$1" source="$2" dest="$3" excludes="$4"
local today; today="$(date +%Y-%m-%d)"
local current="${dest%/}/current"
local versions_today="${dest%/}/versions/${today}"
local versions_prev="${dest%/}/versions/$(date -d 'yesterday' +%Y-%m-%d 2>/dev/null || date -v-1d +%Y-%m-%d 2>/dev/null || echo 'prev')"
echo ""
info "Job: $name"
info "Source : $source"
info "Dest : $current"
info "Versions → $versions_today"
echo ""
# Build exclude args
local -a excl_args=()
if [[ -n "$excludes" ]]; then
IFS=',' read -ra _excl_list <<< "$excludes"
for _e in "${_excl_list[@]}"; do
excl_args+=(--exclude="${_e// /}")
done
fi
# Build rsync command
local -a cmd=(
rsync
-avh
--delete
--backup
--backup-dir="$versions_today"
--progress
--stats
"${excl_args[@]}"
)
# Add --link-dest if yesterday's versions folder exists (saves space via hardlinks)
if is_remote "$dest"; then
local rhost; rhost="$(remote_host "$dest")"
local remote_prev="${dest#*:}"
remote_prev="${remote_prev%/}/versions/$(date -d 'yesterday' +%Y-%m-%d 2>/dev/null || date -v-1d +%Y-%m-%d 2>/dev/null || echo 'prev')"
if ssh "$rhost" "[ -d '$remote_prev' ]" 2>/dev/null; then
cmd+=(--link-dest="$remote_prev")
fi
else
if [[ -d "$versions_prev" ]]; then
cmd+=(--link-dest="$versions_prev")
fi
fi
cmd+=("${source%/}/" "$current/")
info "Running: ${cmd[*]}"
echo ""
if "${cmd[@]}"; then
echo ""
ok "Backup complete: $name"
ok "Mirror : $current"
ok "Changed/deleted files saved to: $versions_today"
else
local rc=$?
# rsync exit 24 = vanished files (harmless)
if [[ $rc -eq 24 ]]; then
warn "Some files vanished during sync (exit 24) — this is usually harmless."
ok "Backup finished: $name"
else
err "rsync exited with code $rc — check output above."
return $rc
fi
fi
}
# ── Prune old versions ────────────────────────────────────────────────────────
prune_versions() {
local dest="$1" keep_days="$2"
if is_remote "$dest"; then
local rhost; rhost="$(remote_host "$dest")"
local rpath="${dest#*:}"
local versions_dir="${rpath%/}/versions"
info "Pruning remote versions older than $keep_days days from $rhost:$versions_dir ..."
ssh "$rhost" "find '$versions_dir' -maxdepth 1 -mindepth 1 -type d -mtime +${keep_days} -exec rm -rf {} + 2>/dev/null; echo done" \
&& ok "Prune complete" || warn "Prune failed (non-fatal)"
else
local versions_dir="${dest%/}/versions"
if [[ -d "$versions_dir" ]]; then
info "Pruning local versions older than $keep_days days from $versions_dir ..."
find "$versions_dir" -maxdepth 1 -mindepth 1 -type d -mtime "+${keep_days}" \
-exec rm -rf {} + 2>/dev/null || true
ok "Prune complete"
fi
fi
}
# ── Wizard: create / edit a job ───────────────────────────────────────────────
wizard_job() {
echo ""
echo " ── Job Configuration ─────────────────────────────────────"
echo ""
echo " Paths can be local (/home/user/photos) or remote (user@host:/path)."
echo " Remote paths require passwordless SSH (ssh-copy-id user@host)."
echo ""
local name="" source="" dest="" excludes="" save=""
ask "Job name (letters/numbers/hyphens)" "my-backup" name
name="${name//[^a-zA-Z0-9_-]/-}"
ask "Source path" "" source
[[ -z "$source" ]] && { warn "Source cannot be empty."; return 1; }
ask "Destination base path (current/ and versions/ created here)" "" dest
[[ -z "$dest" ]] && { warn "Destination cannot be empty."; return 1; }
ask "Exclude patterns, comma-separated (e.g. *.tmp,Thumbs.db) or leave blank" "" excludes
echo ""
# Test SSH if remote is involved
if is_remote "$source"; then
test_ssh "$(remote_host "$source")" || return 1
fi
if is_remote "$dest"; then
test_ssh "$(remote_host "$dest")" || return 1
fi
echo ""
ask_yn "Save this job for future runs?" "y" save
if [[ "$save" == "y" ]]; then
save_job "$name" "$source" "$dest" "$excludes"
ok "Job '$name' saved to $CONFIG_FILE"
fi
echo ""
local run_now=""
ask_yn "Run the backup now?" "y" run_now
if [[ "$run_now" == "y" ]]; then
run_backup "$name" "$source" "$dest" "$excludes"
echo ""
local do_prune=""
ask_yn "Prune old version folders now?" "y" do_prune
if [[ "$do_prune" == "y" ]]; then
local keep=""
ask "Keep versions for how many days?" "30" keep
prune_versions "$dest" "$keep"
fi
fi
}
# ── Run a saved job ───────────────────────────────────────────────────────────
run_saved_job() {
load_jobs
local names=()
while IFS= read -r line; do
[[ -n "$line" ]] && names+=("$line")
done < <(list_jobs)
if [[ ${#names[@]} -eq 0 ]]; then
warn "No saved jobs found."
return 0
fi
echo ""
echo " Saved jobs:"
echo ""
local i=1
for n in "${names[@]}"; do
printf " %2d) %s\n" "$i" "$n"
i=$(( i + 1 ))
done
echo ""
local choice=""
read -r -p " Run job number [1]: " choice
choice="${choice:-1}"
[[ ! "$choice" =~ ^[0-9]+$ ]] && { warn "Invalid."; return 1; }
local idx=$(( choice - 1 ))
[[ "$idx" -lt 0 || "$idx" -ge ${#names[@]} ]] && { warn "Invalid selection."; return 1; }
local job_name="${names[$idx]// /}"
local var="JOB_${job_name}"
local job_val="${!var:-}"
[[ -z "$job_val" ]] && { err "Could not load job '$job_name'."; return 1; }
# Parse the three quoted fields back out
eval "local _parts=($job_val)"
local src="${_parts[0]}" dst="${_parts[1]}" excl="${_parts[2]:-}"
run_backup "$job_name" "$src" "$dst" "$excl"
echo ""
local do_prune=""
ask_yn "Prune old version folders now?" "n" do_prune
if [[ "$do_prune" == "y" ]]; then
local keep=""
ask "Keep versions for how many days?" "30" keep
prune_versions "$dst" "$keep"
fi
}
# ── Cron helper ───────────────────────────────────────────────────────────────
show_cron_hint() {
echo ""
echo " ── Automate with cron ────────────────────────────────────"
echo ""
echo " To run a saved job automatically, add a line like this to crontab"
echo " (edit with: crontab -e):"
echo ""
echo " # Daily at 2 AM — run job 'my-backup'"
echo " 0 2 * * * bash $PWD/$(basename "$0") --job my-backup >> /var/log/rsync-backup.log 2>&1"
echo ""
echo " Or use a systemd timer — ask the setup wizard for details."
echo ""
}
# ── Non-interactive job run (--job NAME) ──────────────────────────────────────
run_job_by_name() {
local name="$1"
load_jobs
local var="JOB_${name}"
local job_val="${!var:-}"
[[ -z "$job_val" ]] && { err "No saved job named '$name'. Run without --job to create one."; exit 1; }
eval "local _parts=($job_val)"
local src="${_parts[0]}" dst="${_parts[1]}" excl="${_parts[2]:-}"
run_backup "$name" "$src" "$dst" "$excl"
}
# ── Main ──────────────────────────────────────────────────────────────────────
main() {
# Non-interactive mode
if [[ "${1:-}" == "--job" ]]; then
require_rsync
run_job_by_name "${2:?--job requires a job name}"
exit $?
fi
require_rsync
echo ""
echo "┌──────────────────────────────────────────────────────────┐"
echo "│ rsync Mirror Backup with Versioned Deletes │"
echo "│ │"
echo "│ dest/current/ — plain mirror (always current) │"
echo "│ dest/versions/DATE/ — deleted/changed files by day │"
echo "└──────────────────────────────────────────────────────────┘"
echo ""
echo " NOTE: Passwordless SSH is required for remote paths."
echo " Set up with: ssh-copy-id user@remotehost"
echo ""
while true; do
echo " What would you like to do?"
echo " 1) Create a new backup job (and optionally run it)"
echo " 2) Run a saved job"
echo " 3) Show cron/automation hint"
echo " 0) Quit"
echo ""
read -r -p " Choice [1]: " action
action="${action:-1}"
echo ""
case "$action" in
1) wizard_job ;;
2) run_saved_job ;;
3) show_cron_hint ;;
0|q|Q) break ;;
*) warn "Invalid choice." ;;
esac
echo ""
done
ok "Done."
}
main "$@"