scripts/backup.sh

#!/usr/bin/env bash
# Daily backup for the homelab stack.
#   1. rsync --link-dest snapshot of /srv/data into /srv/snapshots/YYYY-MM-DD
#   2. rclone copy the fresh snapshot to B2
#   3. prune old snapshots (30 daily + 12 monthly) locally and on B2
# See mercemay.top/src/homelab-compose/ for context.
set -euo pipefail

SRC="${SRC:-/srv/data}"
SNAP_ROOT="${SNAP_ROOT:-/srv/snapshots}"
REMOTE="${REMOTE:-b2:homelab-backup}"
LOCK="/var/run/homelab-backup.lock"
LOG_TAG="homelab-backup"

log() {
	printf '[%s] %s\n' "$(date -Iseconds)" "$*"
	logger -t "$LOG_TAG" "$*" || true
}

die() {
	log "ERROR: $*"
	exit 1
}

[[ $EUID -eq 0 ]] || die "run as root (needs rsync --numeric-ids)"

exec 9>"$LOCK"
flock -n 9 || die "another backup already running"

today="$(date +%Y-%m-%d)"
dest="$SNAP_ROOT/$today"

if [[ -d "$dest" ]]; then
	log "snapshot $today already exists, skipping rsync"
else
	latest="$(ls -1d "$SNAP_ROOT"/????-??-?? 2>/dev/null | sort | tail -n1 || true)"
	mkdir -p "$SNAP_ROOT"
	link_opt=()
	if [[ -n "$latest" && -d "$latest" ]]; then
		link_opt=(--link-dest "$latest")
		log "linking against $latest"
	fi

	log "rsync $SRC -> $dest"
	rsync -aHAX --numeric-ids --delete \
		--exclude '/cache/**' \
		--exclude '/tmp/**' \
		"${link_opt[@]}" \
		"$SRC/" "$dest/"
fi

log "rclone copy $dest -> $REMOTE/$today"
rclone copy \
	--transfers 4 \
	--checkers 8 \
	--fast-list \
	--b2-hard-delete \
	"$dest" "$REMOTE/$today"

prune_local() {
	local keep_daily=30
	local keep_monthly=12
	mapfile -t all < <(ls -1d "$SNAP_ROOT"/????-??-?? 2>/dev/null | sort -r)
	local kept=0
	local months_seen=()
	for snap in "${all[@]}"; do
		local d; d="$(basename "$snap")"
		local month="${d:0:7}"
		if (( kept < keep_daily )); then
			kept=$((kept+1))
			continue
		fi
		if [[ "${d:8:2}" == "01" ]] && (( ${#months_seen[@]} < keep_monthly )); then
			months_seen+=("$month")
			continue
		fi
		log "pruning local $snap"
		rm -rf --one-file-system "$snap"
	done
}

prune_remote() {
	local keep_daily=30
	local keep_monthly=12
	mapfile -t all < <(rclone lsjson --dirs-only "$REMOTE" | jq -r '.[].Name' | sort -r)
	local kept=0
	local months_seen=()
	for d in "${all[@]}"; do
		[[ "$d" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || continue
		local month="${d:0:7}"
		if (( kept < keep_daily )); then
			kept=$((kept+1))
			continue
		fi
		if [[ "${d:8:2}" == "01" ]] && (( ${#months_seen[@]} < keep_monthly )); then
			months_seen+=("$month")
			continue
		fi
		log "pruning remote $d"
		rclone purge "$REMOTE/$d" || log "purge failed: $d"
	done
}

prune_local
prune_remote

log "done; current snapshots:"
ls -1d "$SNAP_ROOT"/????-??-?? 2>/dev/null | sort -r | head -5 | while read -r s; do
	log "  $s"
done