#!/usr/bin/env bash # # ONE-TIME seed of the NAS restic repository. # # Why this exists rather than just running restic-backup: a single `restic # backup` over 520G takes ~12h on this uplink and commits nothing until it # finishes. Two attempts were already lost whole -- one to an edit of the # running script, one to an unplanned reboot -- because an interrupted run # leaves only unreferenced packs that the next run cannot reuse. # # So: back up in chunks, each of which commits its own snapshot. An # interruption now costs one chunk, not the run. Re-running picks up where it # left off via the state file, which survives reboots. # # The chunk list does NOT have to be exhaustive. Once it completes, the normal # restic-backup.service run snapshots the full path set and dedupes against # everything seeded here, so any file not covered by a chunk is picked up then # -- in minutes rather than hours. Afterwards: # # restic forget --tag seed --group-by '' && restic prune # # drops the seed snapshots; their data stays, referenced by the real snapshot. set -euo pipefail umask 077 export RESTIC_CACHE_DIR="$HOME/.cache/restic" set -a; . "$HOME/.config/restic/hetzner.env"; set +a EXCLUDES="$HOME/.config/restic/excludes.txt" STATE="$HOME/.local/state/restic-seed.done" BATCH_BYTES=$(( 15 * 1024 * 1024 * 1024 )) # ~15G per chunk => ~20 min each mkdir -p "$(dirname "$STATE")"; touch "$STATE" log() { printf '%s restic-seed: %s\n' "$(date -Is)" "$*"; } # Backed up whole -- each is small enough to be one chunk. WHOLE=( /etc /home/connor /nas/books /nas/docs /nas/photo/takeout /nas/photo/immich/backups /nas/audio/podcast ) # Too big for one chunk; split into batches of their immediate children. # admin/ is immich's only library user and holds 185G across 22 year folders; # music/ and shanty/ are ~190 artist directories each. SPLIT=( /nas/photo/immich/library/admin /nas/audio/music /nas/audio/shanty ) # run_chunk ... # Keyed by the md5 of its path list so the state file survives re-ordering. run_chunk() { local key; key=$(printf '%s\n' "$@" | md5sum | cut -c1-12) if grep -qx "$key" "$STATE"; then log "skip [$key] $1 ${2:+(+$(($#-1)) more)}" return 0 fi log "chunk [$key] $1 ${2:+(+$(($#-1)) more)}" # Exit 3 means some files were unreadable but the snapshot was written -- # still progress, still worth recording. local rc=0 nice -n 10 ionice -c2 -n7 restic backup \ --one-file-system \ --exclude-file="$EXCLUDES" \ --exclude-caches \ --tag nas --tag seed \ "$@" || rc=$? if [ "$rc" -eq 0 ] || [ "$rc" -eq 3 ]; then printf '%s\n' "$key" >> "$STATE" # NOT `[ ... ] && log`: under `set -e` a false test makes that the # function's exit status and kills the whole seed on the first # SUCCESSFUL chunk. if [ "$rc" -eq 3 ]; then log " (exit 3: some files unreadable)"; fi else log " FAILED rc=$rc -- leaving unrecorded so a re-run retries it" return "$rc" fi } for p in "${WHOLE[@]}"; do [ -e "$p" ] || { log "missing, skipping: $p"; continue; } run_chunk "$p" done for root in "${SPLIT[@]}"; do [ -d "$root" ] || { log "missing, skipping: $root"; continue; } batch=(); size=0 while IFS= read -r -d '' child; do csize=$(du -sb --apparent-size "$child" 2>/dev/null | cut -f1) || csize=0 # Flush first if adding this child would overflow, so a single # oversized child still gets a chunk of its own rather than being # merged into a huge one. if [ "${#batch[@]}" -gt 0 ] && [ $(( size + csize )) -gt "$BATCH_BYTES" ]; then run_chunk "${batch[@]}" batch=(); size=0 fi batch+=("$child"); size=$(( size + csize )) done < <(find "$root" -mindepth 1 -maxdepth 1 -print0 | sort -z) if [ "${#batch[@]}" -gt 0 ]; then run_chunk "${batch[@]}"; fi done log "seed complete -- $(wc -l < "$STATE") chunks recorded" log "next: systemctl start restic-backup.service (full run, dedupes against these)"