| 1 | { config, pkgs, ... }: |
| 2 | let |
| 3 | # Hourly backup: rsync the local working drives up to the NAS, then (only on a |
| 4 | # fully clean push) trigger a ZFS snapshot + tiered prune on the NAS. The backup |
| 5 | # on the NAS is plain browsable files; history lives in ZFS snapshots. |
| 6 | sandwich-backup = pkgs.writeShellApplication { |
| 7 | name = "sandwich-backup"; |
| 8 | runtimeInputs = with pkgs; [ |
| 9 | rsync |
| 10 | openssh |
| 11 | coreutils |
| 12 | ]; |
| 13 | text = '' |
| 14 | NAS=clo@git.paperclover.net |
| 15 | DEST=$NAS:/mnt/storage1/backup/sandwich |
| 16 | LOCK=/tmp/sandwich-backup.lock |
| 17 | |
| 18 | # Dedicated passphraseless key so the launchd job runs unattended; the |
| 19 | # interactive key keeps its passphrase. IdentitiesOnly stops ssh from |
| 20 | # offering the passphrased key first. |
| 21 | SSH=(ssh -i "$HOME/.ssh/id_sandwich_backup" -o IdentitiesOnly=yes -o BatchMode=yes |
| 22 | -o ConnectTimeout=30 -o ServerAliveInterval=30 -o ServerAliveCountMax=4) |
| 23 | |
| 24 | # Single-instance guard: the initial sync takes hours, far longer than the |
| 25 | # hourly interval. mkdir is atomic; recover if a previous run died uncleanly. |
| 26 | if ! mkdir "$LOCK" 2>/dev/null; then |
| 27 | if [ -f "$LOCK/pid" ] && kill -0 "$(cat "$LOCK/pid")" 2>/dev/null; then |
| 28 | echo "already running (pid $(cat "$LOCK/pid"))" |
| 29 | exit 0 |
| 30 | fi |
| 31 | echo "clearing stale lock" |
| 32 | rm -rf "$LOCK" |
| 33 | mkdir "$LOCK" |
| 34 | fi |
| 35 | echo $$ >"$LOCK/pid" |
| 36 | trap 'rm -rf "$LOCK"' EXIT |
| 37 | |
| 38 | # rsync exit 24 (source files vanished mid-transfer) is benign on a live |
| 39 | # machine; anything else is a real failure and aborts before the snapshot. |
| 40 | rsync_one() { |
| 41 | local rc=0 |
| 42 | rsync -a --delete --partial --timeout=600 --human-readable --stats \ |
| 43 | -e "''${SSH[*]}" \ |
| 44 | --exclude '.DS_Store' \ |
| 45 | --exclude '.Spotlight-V100' \ |
| 46 | --exclude '.Trashes' \ |
| 47 | --exclude '.fseventsd' \ |
| 48 | --exclude '.TemporaryItems' \ |
| 49 | --exclude '.DocumentRevisions-V100' \ |
| 50 | "$@" || rc=$? |
| 51 | if [ "$rc" -ne 0 ] && [ "$rc" -ne 24 ]; then |
| 52 | return "$rc" |
| 53 | fi |
| 54 | } |
| 55 | |
| 56 | rsync_one /Volumes/Asset/ "$DEST/Asset/" |
| 57 | rsync_one /Volumes/Project/ "$DEST/Project/" |
| 58 | rsync_one --exclude 'LMStudio/' /Volumes/Documents/ "$DEST/Documents/" |
| 59 | rsync_one "$HOME/Desktop/" "$DEST/Desktop/" |
| 60 | |
| 61 | # Snapshot only reached if every push above succeeded. |
| 62 | "''${SSH[@]}" "$NAS" /mnt/storage1/apps/home-infra/backup/snapshot-prune.sh storage1/backup/sandwich |
| 63 | ''; |
| 64 | }; |
| 65 | in |
| 66 | { |
| 67 | home.packages = with pkgs; [ |
| 68 | rsync |
| 69 | sandwich-backup |
| 70 | ]; |
| 71 | |
| 72 | launchd.agents.sandwich-backup = { |
| 73 | enable = true; |
| 74 | config = { |
| 75 | ProgramArguments = [ "${sandwich-backup}/bin/sandwich-backup" ]; |
| 76 | StartInterval = 3600; |
| 77 | RunAtLoad = true; |
| 78 | StandardOutPath = "${config.home.homeDirectory}/Library/Logs/sandwich-backup.log"; |
| 79 | StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/sandwich-backup.log"; |
| 80 | }; |
| 81 | }; |
| 82 | } |