#!/usr/bin/env bash # Hourly off-box snapshot of ~/development -> nh3-nas via rsync --link-dest # hardlink snapshots. Penance for the 2026-07-12 soong-lab clobber: uncommitted # dev work now has an hourly, versioned, off-box safety net. Secrets + heavy # reconstructable dirs are excluded. Snapshots are timestamped dirs on the NAS; # unchanged files hardlink to the previous snapshot (space-efficient). Retention: # 48 hourly + 30 daily + 12 weekly (retention.py; dailies and weeklies added 2026-10-02). set -uo pipefail SRC="$HOME/development/" DEST_HOST="nh3-nas" DEST_BASE="/volume1/Backup/nh3-dev-development" STAMP="$(date +%Y-%m-%d_%H%M)" LOG="$HOME/.config/dev-backup/dev-backup.log" exec >>"$LOG" 2>&1 echo "=== $(date -Is) snapshot $STAMP start ===" # previous snapshot for hardlink dedup PREV="$(ssh -o ConnectTimeout=15 -o BatchMode=yes "$DEST_HOST" "ls -1d $DEST_BASE/20* 2>/dev/null | sort | tail -1" || true)" LINKDEST=() [ -n "$PREV" ] && LINKDEST=(--link-dest="$PREV") echo "link-dest: ${PREV:-}" ssh -o BatchMode=yes "$DEST_HOST" "mkdir -p '$DEST_BASE/$STAMP'" rsync -a --delete --numeric-ids \ --exclude='node_modules/' --exclude='.venv/' --exclude='venv/' --exclude='__pycache__/' \ --exclude='.pytest_cache/' --exclude='.mypy_cache/' --exclude='.ruff_cache/' --exclude='.cache/' \ --exclude='dist/' --exclude='build/' --exclude='.next/' --exclude='target/' --exclude='*.pyc' \ --exclude='.env' --exclude='.env.*' --exclude='*.pem' --exclude='*.key' --exclude='id_*' \ --exclude='*.sqlite' --exclude='*.sqlite3' --exclude='*.db-wal' --exclude='*.db-shm' \ "${LINKDEST[@]}" \ "$SRC" "$DEST_HOST:$DEST_BASE/$STAMP/" RC=$? echo "rsync rc=$RC" # rc 0 = ok; rc 24 = some files vanished mid-transfer (benign for a live tree) if [ "$RC" -eq 0 ] || [ "$RC" -eq 24 ]; then ssh -o BatchMode=yes "$DEST_HOST" "ln -sfn '$DEST_BASE/$STAMP' '$DEST_BASE/latest'" # retention (Prime, 2026-10-02): newest 48 hourly + newest of each of the last 30 days + newest of # each of the last 12 ISO weeks. retention.py (next to this script) picks what to delete; anything # whose name is not exactly YYYY-MM-DD_HHMM is never selected, and the remote side refuses any path # outside $DEST_BASE/20??-??-??_???? as a second guard. # chmod BEFORE rm: rsync -a copies a read-only source dir (mode 0555) as read-only, and rm cannot # unlink inside it. Without the chmod this prune failed every run from 2026-07-18 to 2026-10-01 and # left 1,740 husk dirs behind (removed 2026-10-01), while the run still logged OK. Errors are COUNTED, # not dumped (one failing run logged ~400 MB), and a failed prune fails the unit. ALL="$(ssh -o BatchMode=yes "$DEST_HOST" "ls -1d $DEST_BASE/20* 2>/dev/null")" DELETE="$(printf '%s\n' "$ALL" | python3 "$(dirname "$0")/retention.py")" EXPECT="$(printf '%s\n' "$ALL" | python3 "$(dirname "$0")/retention.py" --keep-count)" NDEL="$(printf '%s' "$DELETE" | grep -c . || true)" PRUNE_ERR="$(printf '%s\n' "$DELETE" | grep . | ssh -o BatchMode=yes "$DEST_HOST" "while read -r d; do case \"\$d\" in $DEST_BASE/20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]_[0-9][0-9][0-9][0-9]) chmod -R u+w \"\$d\" 2>&1 && rm -rf \"\$d\" 2>&1 || echo \"FAILED \$d\" ;; *) echo \"REFUSED \$d\" ;; esac done" | wc -l)" KEPT="$(ssh -o BatchMode=yes "$DEST_HOST" "ls -1d $DEST_BASE/20* 2>/dev/null | wc -l")" echo "retention prune: deleted ${NDEL:-?}, ${PRUNE_ERR:-?} error lines, ${KEPT:-?} snapshots on the NAS (expected 0 errors and ${EXPECT:-?} kept)" if [ "${PRUNE_ERR:-x}" = 0 ] && [ -n "${EXPECT:-}" ] && [ "${KEPT:-x}" = "$EXPECT" ]; then echo "=== $(date -Is) snapshot $STAMP OK (rc=$RC) ===" else echo "=== $(date -Is) snapshot $STAMP taken (rc=$RC) but RETENTION FAILED ===" exit 3 fi else echo "=== $(date -Is) snapshot $STAMP FAILED rc=$RC — keeping partial for inspection ===" exit 1 fi