diff --git a/common/backups.nix b/common/backups.nix index 2289050..00a509a 100644 --- a/common/backups.nix +++ b/common/backups.nix @@ -2,13 +2,101 @@ let cfg = config.backup; - hostname = config.networking.hostName; mkRespository = group: "s3:s3.us-west-004.backblazeb2.com/D22TgIt0-main-backup/${group}"; + findmnt = "${pkgs.util-linux}/bin/findmnt"; + mount = "${pkgs.util-linux}/bin/mount"; + umount = "${pkgs.util-linux}/bin/umount"; + btrfs = "${pkgs.btrfs-progs}/bin/btrfs"; + zfs = "/run/current-system/sw/bin/zfs"; + + # Creates snapshots and bind mounts them over original paths within the + # service's mount namespace, so restic sees correct paths but reads frozen data + snapshotHelperFn = '' + snapshot_for_path() { + local group="$1" path="$2" action="$3" + local pathhash fstype + + pathhash=$(echo -n "$path" | sha256sum | cut -c1-8) + fstype=$(${findmnt} -n -o FSTYPE -T "$path" 2>/dev/null || echo "unknown") + + case "$fstype" in + zfs) + local dataset mount subpath snapname snappath + dataset=$(${findmnt} -n -o SOURCE -T "$path") + mount=$(${findmnt} -n -o TARGET -T "$path") + subpath=''${path#"$mount"} + [[ "$subpath" != /* ]] && subpath="/$subpath" + snapname="''${dataset}@restic-''${group}-''${pathhash}" + snappath="''${mount}/.zfs/snapshot/restic-''${group}-''${pathhash}''${subpath}" + case "$action" in + create) + ${zfs} destroy "$snapname" 2>/dev/null || true + ${zfs} snapshot "$snapname" + ${mount} --bind "$snappath" "$path" + echo "$path" + ;; + destroy) + ${umount} "$path" 2>/dev/null || true + ${zfs} destroy "$snapname" 2>/dev/null || true + ;; + esac + ;; + btrfs) + local mount subpath snapdir snappath + mount=$(${findmnt} -n -o TARGET -T "$path") + subpath=''${path#"$mount"} + [[ "$subpath" != /* ]] && subpath="/$subpath" + snapdir="/.restic-snapshots/''${group}-''${pathhash}" + snappath="''${snapdir}''${subpath}" + case "$action" in + create) + ${btrfs} subvolume delete "$snapdir" 2>/dev/null || true + mkdir -p /.restic-snapshots + ${btrfs} subvolume snapshot -r "$mount" "$snapdir" >&2 + ${mount} --bind "$snappath" "$path" + echo "$path" + ;; + destroy) + ${umount} "$path" 2>/dev/null || true + ${btrfs} subvolume delete "$snapdir" 2>/dev/null || true + ;; + esac + ;; + *) + echo "No snapshot support for $fstype ($path), using original" >&2 + [ "$action" = "create" ] && echo "$path" + ;; + esac + } + ''; + mkBackup = group: paths: { repository = mkRespository group; - inherit paths; + + dynamicFilesFrom = "cat /run/restic-backup-${group}/paths"; + + backupPrepareCommand = '' + mkdir -p /run/restic-backup-${group} + : > /run/restic-backup-${group}/paths + + ${snapshotHelperFn} + + for path in ${lib.escapeShellArgs paths}; do + snapshot_for_path ${lib.escapeShellArg group} "$path" create >> /run/restic-backup-${group}/paths + done + ''; + + backupCleanupCommand = '' + ${snapshotHelperFn} + + for path in ${lib.escapeShellArgs paths}; do + snapshot_for_path ${lib.escapeShellArg group} "$path" destroy + done + + rm -rf /run/restic-backup-${group} + ''; initialize = true; @@ -21,11 +109,11 @@ let ''--exclude-if-present ".nobackup"'' ]; + # Keeps backups from up to 6 months ago pruneOpts = [ "--keep-daily 7" # one backup for each of the last n days "--keep-weekly 5" # one backup for each of the last n weeks - "--keep-monthly 12" # one backup for each of the last n months - "--keep-yearly 75" # one backup for each of the last n years + "--keep-monthly 6" # one backup for each of the last n months ]; environmentFile = "/run/agenix/backblaze-s3-backups"; @@ -64,12 +152,25 @@ in }; config = lib.mkIf (cfg.group != null) { + assertions = lib.mapAttrsToList (group: _: { + assertion = config.systemd.services."restic-backups-${group}".enable; + message = "Expected systemd service 'restic-backups-${group}' not found. The nixpkgs restic module may have changed its naming convention."; + }) cfg.group; + services.restic.backups = lib.concatMapAttrs (group: groupCfg: { ${group} = mkBackup group groupCfg.paths; }) cfg.group; + # Mount namespace lets us bind mount snapshots over original paths, + # so restic backs up from frozen snapshots while recording correct paths + systemd.services = lib.concatMapAttrs + (group: _: { + "restic-backups-${group}".serviceConfig.PrivateMounts = true; + }) + cfg.group; + age.secrets.backblaze-s3-backups.file = ../secrets/backblaze-s3-backups.age; age.secrets.restic-password.file = ../secrets/restic-password.age;