diff --git a/modules/modules.nix b/modules/modules.nix index b48ef31af..d1abd9210 100644 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -397,6 +397,7 @@ let ./services/redshift-gammastep/gammastep.nix ./services/redshift-gammastep/redshift.nix ./services/remmina.nix + ./services/restic.nix ./services/rsibreak.nix ./services/safeeyes.nix ./services/screen-locker.nix diff --git a/modules/services/restic.nix b/modules/services/restic.nix new file mode 100644 index 000000000..a7dacea1e --- /dev/null +++ b/modules/services/restic.nix @@ -0,0 +1,468 @@ +{ config, lib, pkgs, ... }: +let + unitType = with lib.types; + let primitive = oneOf [ bool int str path ]; + in attrsOf (either primitive (listOf primitive)); + + cfg = config.services.restic; + + fmtRcloneOpt = opt: + lib.pipe opt [ + (lib.replaceStrings [ "-" ] [ "_" ]) + lib.toUpper + (lib.add "RCLONE_") + ]; + + toEnvVal = v: if lib.isBool v then lib.boolToString v else v; + attrsToEnvs = attrs: + lib.pipe attrs [ + (lib.mapAttrsToList + (k: v: if v != null then "${k}=${toEnvVal v}" else [ ])) + lib.flatten + ]; + + runtimeInputs = with pkgs; [ coreutils findutils diffutils jq gnugrep which ]; +in { + options.services.restic = { + enable = lib.mkEnableOption "restic"; + + backups = lib.mkOption { + description = '' + Periodic backups to create with Restic. + ''; + type = lib.types.attrsOf (lib.types.submodule ({ config, name, ... }: { + options = { + package = lib.mkPackageOption pkgs "restic" { }; + + ssh-package = lib.mkPackageOption pkgs "openssh" { }; + + passwordFile = lib.mkOption { + type = lib.types.str; + description = '' + A file containing the repository password. + ''; + example = "/etc/nixos/restic-password"; + }; + + environmentFile = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = '' + A file containing the credentials to access the repository, in the + format of an EnvironmentFile as described by {manpage}`systemd.exec(5)`. + See + for the specific credentials you will need for your backend. + ''; + }; + + rcloneOptions = lib.mkOption { + type = with lib.types; attrsOf (oneOf [ str bool ]); + default = { }; + apply = + lib.mapAttrs' (opt: v: lib.nameValuePair (fmtRcloneOpt opt) v); + description = '' + Options to pass to rclone to control its behavior. See + for available options. When specifying + option names, strip the leading `--`. To set a flag such as + `--drive-use-trash`, which does not take a value, set the value to the + Boolean `true`. + ''; + example = { + bwlimit = "10M"; + drive-use-trash = true; + }; + }; + + inhibitsSleep = lib.mkOption { + default = false; + type = lib.types.bool; + example = true; + description = '' + Prevents the system from sleeping while backing up. This uses systemd-inhibit + to block system idling so you may need to enable polkitd with + {option}`security.polkit.enable`. + ''; + }; + + repository = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = '' + Repository to backup to. This should be in the form of a backend specification as + detailed here + . + + If your using the rclone backend, you can configure your remotes with + {option}`programs.rclone.remotes` then use them in your backend specification. + ''; + example = "sftp:backup@192.168.1.100:/backups/${name}"; + }; + + repositoryFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + description = '' + Path to a file containing the repository location to backup to. This should be + in the same form as the {option}`repository` option. + ''; + }; + + paths = lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + description = '' + Paths to back up, alongside those defined by the {option}`dynamicFilesFrom` + option. If left empty and {option}`dynamicFilesFrom` is also not specified, no + backup command will be run. This can be used to create a prune-only job. + ''; + example = [ "/var/lib/postgresql" "/home/user/backup" ]; + }; + + exclude = lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + description = '' + Patterns to exclude when backing up. See + for + details on syntax. + ''; + example = [ "/var/cache" "/home/*/.cache" ".git" ]; + }; + + timerConfig = lib.mkOption { + type = lib.types.nullOr unitType; + default = { + OnCalendar = "daily"; + Persistent = true; + }; + description = '' + When to run the backup. See {manpage}`systemd.timer(5)` for details. If null + no timer is created and the backup will only run when explicitly started. + ''; + example = { + OnCalendar = "00:05"; + RandomizedDelaySec = "5h"; + Persistent = true; + }; + }; + + extraBackupArgs = lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + description = '' + Extra arguments passed to restic backup. + ''; + example = + [ "--cleanup-cache" "--exclude-file=/etc/nixos/restic-ignore" ]; + }; + + extraOptions = lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + description = '' + Extra extended options to be passed to the restic `-o` flag. See the restic + documentation for more details. + ''; + example = [ + "sftp.command='ssh backup@192.168.1.100 -i /home/user/.ssh/id_rsa -s sftp'" + ]; + }; + + initialize = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Create the repository if it does not already exist. + ''; + }; + + pruneOpts = lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + description = '' + A list of policy options for 'restic forget --prune', to automatically + prune old snapshots. See + + for a full list of options. + + Note: The 'forget' command is run *after* the 'backup' command, so keep + that in mind when constructing the --keep-\* options. + ''; + example = [ + "--keep-daily 7" + "--keep-weekly 5" + "--keep-monthly 12" + "--keep-yearly 75" + ]; + }; + + runCheck = lib.mkOption { + type = lib.types.bool; + default = lib.length config.checkOpts > 0 + || lib.length config.pruneOpts > 0; + defaultText = lib.literalExpression + "lib.length config.checkOpts > 0 || lib.length config.pruneOpts > 0"; + description = + "Whether to run 'restic check' with the provided `checkOpts` options."; + example = true; + }; + + checkOpts = lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + description = '' + A list of options for 'restic check'. + ''; + example = [ "--with-cache" ]; + }; + + dynamicFilesFrom = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = '' + A script that produces a list of files to back up. The results of + this command, along with the paths specified via {option}`paths`, + are given to the '--files-from' option. + ''; + example = "find /home/alice/git -type d -name .git"; + }; + + backupPrepareCommand = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = '' + A script that must run before starting the backup process. + ''; + }; + + backupCleanupCommand = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = '' + A script that must run after finishing the backup process. + ''; + }; + + createWrapper = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to generate and add a script to the system path, that has the + same environment variables set as the systemd service. This can be used + to e.g. mount snapshots or perform other opterations, without having to + manually specify most options. + ''; + }; + + progressFps = lib.mkOption { + type = with lib.types; nullOr numbers.nonnegative; + default = null; + description = '' + Controls the frequency of progress reporting. + ''; + example = 0.1; + }; + }; + })); + default = { }; + example = { + localbackup = { + paths = [ "/home" ]; + exclude = [ "/home/*/.cache" ]; + repository = "/mnt/backup-hdd"; + passwordFile = "/etc/nixos/secrets/restic-password"; + initialize = true; + }; + remotebackup = { + paths = [ "/home" ]; + repository = "sftp:backup@host:/backups/home"; + passwordFile = "/etc/nixos/secrets/restic-password"; + extraOptions = [ + "sftp.command='ssh backup@host -i /etc/nixos/secrets/backup-private-key -s sftp'" + ]; + timerConfig = { + OnCalendar = "00:05"; + RandomizedDelaySec = "5h"; + }; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = lib.mapAttrsToList (n: v: { + assertion = lib.xor (v.repository == null) (v.repositoryFile == null); + message = + "services.restic.backups.${n}: exactly one of repository or repositoryFile should be set"; + }) cfg.backups; + + systemd.user.services = lib.mapAttrs' (name: backup: + let + doBackup = backup.dynamicFilesFrom != null || backup.paths != [ ]; + doPrune = backup.pruneOpts != [ ]; + doCheck = backup.runCheck; + serviceName = "restic-backups-${name}"; + + extraOptions = lib.concatMap (arg: [ "-o" arg ]) backup.extraOptions; + + excludeFile = + pkgs.writeText "exclude-patterns" (lib.concatLines backup.exclude); + excludeFileFlag = "--exclude-file=${excludeFile}"; + + filesFromTmpFile = "/run/user/$UID/${serviceName}/includes"; + filesFromFlag = "--files-from=${filesFromTmpFile}"; + + inhibitCmd = lib.optionals backup.inhibitsSleep [ + "${pkgs.systemd}/bin/systemd-inhibit" + "--mode='block'" + "--who='restic'" + "--what='idle'" + "--why=${lib.escapeShellArg "Scheduled backup ${name}"}" + ]; + + mkResticCmd' = pre: args: + lib.concatStringsSep " " (pre + ++ lib.singleton (lib.getExe backup.package) ++ extraOptions + ++ lib.flatten args); + mkResticCmd = mkResticCmd' [ ]; + + backupCmd = "${lib.getExe pkgs.bash} -c " + lib.escapeShellArg + (mkResticCmd' inhibitCmd [ + "backup" + backup.extraBackupArgs + excludeFileFlag + filesFromFlag + ]); + + forgetCmd = mkResticCmd [ "forget" "--prune" backup.pruneOpts ]; + checkCmd = mkResticCmd [ "check" backup.checkOpts ]; + unlockCmd = mkResticCmd "unlock"; + in lib.nameValuePair serviceName { + Unit = { + Description = "Restic backup service"; + Wants = [ "network-online.target" ]; + After = [ "network-online.target" ]; + }; + + Service = { + Type = "oneshot"; + + X-RestartIfChanged = true; + RuntimeDirectory = serviceName; + CacheDirectory = serviceName; + CacheDirectoryMode = "0700"; + PrivateTmp = true; + + Environment = + [ "RESTIC_CACHE_DIR=%C" "PATH=${backup.ssh-package}/bin" ] + ++ attrsToEnvs ({ + RESTIC_PROGRESS_FPS = backup.progressFps; + RESTIC_PASSWORD_FILE = backup.passwordFile; + RESTIC_REPOSITORY = backup.repository; + RESTIC_REPOSITORY_FILE = backup.repositoryFile; + } // backup.rcloneOptions); + + ExecStart = lib.optional doBackup backupCmd + ++ lib.optionals doPrune [ unlockCmd forgetCmd ] + ++ lib.optional doCheck checkCmd; + + ExecStartPre = lib.getExe (pkgs.writeShellApplication { + name = "${serviceName}-exec-start-pre"; + inherit runtimeInputs; + text = '' + set -x + + ${lib.optionalString (backup.backupPrepareCommand != null) '' + ${pkgs.writeScript "backupPrepareCommand" + backup.backupPrepareCommand} + ''} + + ${lib.optionalString (backup.initialize) '' + ${mkResticCmd [ "cat" "config" ]} 2>/dev/null || ${ + mkResticCmd "init" + } + ''} + + ${lib.optionalString + (backup.paths != null && backup.paths != [ ]) '' + cat ${ + pkgs.writeText "staticPaths" (lib.concatLines backup.paths) + } >> ${filesFromTmpFile} + ''} + + ${lib.optionalString (backup.dynamicFilesFrom != null) '' + ${ + pkgs.writeScript "dynamicFilesFromScript" + backup.dynamicFilesFrom + } >> ${filesFromTmpFile} + ''} + ''; + }); + + ExecStopPost = lib.getExe (pkgs.writeShellApplication { + name = "${serviceName}-exec-stop-post"; + inherit runtimeInputs; + text = '' + set -x + + ${lib.optionalString (backup.backupCleanupCommand != null) '' + ${pkgs.writeScript "backupCleanupCommand" + backup.backupCleanupCommand} + ''} + ''; + }); + } // lib.optionalAttrs (backup.environmentFile != null) { + EnvironmentFile = backup.environmentFile; + }; + }) cfg.backups; + + systemd.user.timers = lib.mapAttrs' (name: backup: + lib.nameValuePair "restic-backups-${name}" { + Unit.Description = "Restic backup service"; + Install.WantedBy = [ "timers.target" ]; + + Timer = backup.timerConfig; + }) (lib.filterAttrs (_: v: v.timerConfig != null) cfg.backups); + + home.packages = lib.mapAttrsToList (name: backup: + let + serviceName = "restic-backups-${name}"; + backupService = config.systemd.user.services.${serviceName}; + notPathVar = x: !(lib.hasPrefix "PATH" x); + extraOptions = lib.concatMap (arg: [ "-o" arg ]) backup.extraOptions; + restic = lib.concatStringsSep " " + (lib.flatten [ (lib.getExe backup.package) extraOptions ]); + in pkgs.writeShellApplication { + name = "restic-${name}"; + # https://github.com/koalaman/shellcheck/issues/1986 + excludeShellChecks = [ "SC2034" ]; + bashOptions = [ "errexit" "nounset" "allexport" ]; + text = '' + ${lib.optionalString (backup.environmentFile != null) '' + source ${backup.environmentFile} + ''} + + # Set same environment variables as the systemd service + ${lib.pipe backupService.Service.Environment [ + (lib.filter notPathVar) + lib.concatLines + ]} + + # Override this as %C will not work + RESTIC_CACHE_DIR=$HOME/.cache/${serviceName} + + PATH=${ + lib.pipe backupService.Service.Environment [ + (lib.filter (lib.hasPrefix "PATH=")) + lib.head + (lib.removePrefix "PATH=") + ] + }:$PATH + + exec ${restic} "$@" + ''; + }) (lib.filterAttrs (_: v: v.createWrapper) cfg.backups); + }; + + meta.maintainers = [ lib.hm.maintainers.jess ]; +} diff --git a/tests/default.nix b/tests/default.nix index 04d7b1c52..fc6836313 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -542,6 +542,7 @@ in import nmtSrc { ./modules/services/recoll ./modules/services/redshift-gammastep ./modules/services/remmina + ./modules/services/restic ./modules/services/screen-locker ./modules/services/signaturepdf ./modules/services/snixembed diff --git a/tests/integration/default.nix b/tests/integration/default.nix index e3ac0bed8..357b8ec63 100644 --- a/tests/integration/default.nix +++ b/tests/integration/default.nix @@ -16,6 +16,7 @@ let nh = runTest ./standalone/nh.nix; nixos-basics = runTest ./nixos/basics.nix; rclone = runTest ./standalone/rclone; + restic = runTest ./standalone/restic.nix; standalone-flake-basics = runTest ./standalone/flake-basics.nix; standalone-standard-basics = runTest ./standalone/standard-basics.nix; }; diff --git a/tests/integration/standalone/restic-home.nix b/tests/integration/standalone/restic-home.nix new file mode 100644 index 000000000..856e4a3e5 --- /dev/null +++ b/tests/integration/standalone/restic-home.nix @@ -0,0 +1,111 @@ +{ pkgs, ... }: +let + passwordFile = "/home/alice/password"; + paths = [ "/home/alice/files" ]; + exclude = [ "*exclude*" ]; +in { + home.username = "alice"; + home.homeDirectory = "/home/alice"; + + home.stateVersion = "24.05"; # Please read the comment before changing. + + # Let Home Manager install and manage itself. + programs.home-manager.enable = true; + systemd.user.startServices = false; + + programs.rclone = { + enable = true; + remotes = { + alices-computer.config = { + type = "local"; + one_file_system = true; + }; + }; + }; + + services.restic = { + enable = true; + backups = { + init = { + inherit passwordFile paths exclude; + initialize = true; + repository = "/home/alice/repos/backup"; + }; + + noinit = { + inherit passwordFile paths exclude; + initialize = false; + repository = "/home/alice/repos/noinit"; + }; + + basic = { + inherit passwordFile paths exclude; + initialize = true; + repository = "/home/alice/repos/basic"; + }; + + repo-file = { + inherit passwordFile paths exclude; + initialize = true; + repositoryFile = + pkgs.writeText "repositoryFile" "/home/alice/repos/repo-file"; + }; + + rclone = { + inherit passwordFile paths exclude; + initialize = true; + repository = "rclone:alices-computer:/home/alice/repos/rclone"; + }; + + dynamic-paths = { + inherit passwordFile paths exclude; + initialize = true; + repository = "/home/alice/repos/dynamic-paths"; + dynamicFilesFrom = '' + find /home/alice/dyn-files -type f ! -name "*secret*" + ''; + }; + + inhibits-sleep = { + inherit passwordFile paths exclude; + inhibitsSleep = true; + initialize = true; + repository = "/home/alice/repos/inhibits-sleep"; + }; + + pre-post-jobs = { + inherit passwordFile paths exclude; + initialize = true; + repository = "/home/alice/repos/pre-post-jobs"; + backupPrepareCommand = '' + echo "Preparing Backup..." + echo "Notifying Alice..." + echo "Ready!" + ''; + backupCleanupCommand = '' + echo "Finishing Backup..." + echo "Mailing alice the results..." + echo "Done." + ''; + }; + + prune-me = { + inherit passwordFile paths exclude; + initialize = true; + repository = "/home/alice/repos/prune-me"; + }; + + prune-only = { + inherit passwordFile; + repository = "/home/alice/repos/prune-me"; + pruneOpts = [ + "--keep-yearly 4" + "--keep-monthly 3" + "--keep-weekly 2" + "--keep-daily 2" + "--keep-hourly 3" + ]; + }; + }; + }; +} diff --git a/tests/integration/standalone/restic.nix b/tests/integration/standalone/restic.nix new file mode 100644 index 000000000..ebfc4c191 --- /dev/null +++ b/tests/integration/standalone/restic.nix @@ -0,0 +1,265 @@ +{ pkgs, lib, ... }: +let + testDir = pkgs.runCommand "test-files-to-backup" { } '' + mkdir $out + echo some_file > $out/some_file + echo some_other_file > $out/some_other_file + mkdir $out/a_dir + echo a_file > $out/a_dir/a_file + echo a_file_2 > $out/a_dir/a_file_2 + echo alices-secret-diary > $out/a_dir/excluded_file_1 + echo alices-bank-details > $out/excluded_file_2 + ''; + + dynDir = testDir.overrideAttrs (final: prev: { + buildCommand = prev.buildCommand + '' + echo more secret data > $out/top-secret + echo shhhh > $out/top-secret-v2 + echo this isnt secret > $out/metadata + ''; + }); +in { + name = "restic"; + + nodes.machine = { ... }: { + imports = [ "${pkgs.path}/nixos/modules/installer/cd-dvd/channel.nix" ]; + virtualisation.memorySize = 2048; + users.users.alice = { + isNormalUser = true; + description = "Alice Foobar"; + password = "foobar"; + uid = 1000; + }; + + security.polkit.enable = true; + }; + + testScript = '' + start_all() + machine.wait_for_unit("network.target") + machine.wait_for_unit("multi-user.target") + machine.wait_for_unit("dbus.socket") + + home_manager = "${../../..}" + + def login_as_alice(): + machine.wait_until_tty_matches("1", "login: ") + machine.send_chars("alice\n") + machine.wait_until_tty_matches("1", "Password: ") + machine.send_chars("foobar\n") + machine.wait_until_tty_matches("1", "alice\\@machine") + + def logout_alice(): + machine.send_chars("exit\n") + + def alice_cmd(cmd): + return f"su -l alice --shell /bin/sh -c $'export XDG_RUNTIME_DIR=/run/user/$UID ; {cmd}'" + + def succeed_as_alice(*cmds): + return machine.succeed(*map(alice_cmd,cmds)) + + def fail_as_alice(*cmds): + return machine.fail(*map(alice_cmd,cmds)) + + def systemctl_succeed_as_alice(cmd): + status, out = machine.systemctl(cmd, "alice") + assert status == 0, f"failed to run systemctl {cmd}" + return out + + def systemctl_fail_as_alice(cmd): + status, out = machine.systemctl(cmd, "alice") + assert status != 0, \ + f"Successfully finished with exit-code {status}, systemctl {cmd} when its expected to fail" + return out + + def assert_list(cmd, expected_list, actual): + assert all([x in actual for x in expected_list]), \ + f"""Expected {cmd} to contain \ + [{" and ".join([x for x in expected_list if x not in actual])}], but got {actual}""" + + def spin_on(unit): + while True: + info = machine.get_unit_info(unit, "alice") + if info["ActiveState"] == "inactive": + return + + # Create a persistent login so that Alice has a systemd session. + login_as_alice() + + # Set up a home-manager channel. + succeed_as_alice(" ; ".join([ + "mkdir -p /home/alice/.nix-defexpr/channels", + f"ln -s {home_manager} /home/alice/.nix-defexpr/channels/home-manager" + ])) + + with subtest("Home Manager installation"): + succeed_as_alice("nix-shell \"\" -A install") + + succeed_as_alice("cp ${ + ./restic-home.nix + } /home/alice/.config/home-manager/home.nix") + + succeed_as_alice("cp -rT ${testDir} /home/alice/files") + succeed_as_alice("cp -rT ${dynDir} /home/alice/dyn-files") + succeed_as_alice("echo password123 > /home/alice/password") + + succeed_as_alice("home-manager switch") + + expectedIncluded = [ + "/home", + "/home/alice", + "/home/alice/files", + "/home/alice/files/a_dir", + "/home/alice/files/a_dir/a_file", + "/home/alice/files/a_dir/a_file_2", + "/home/alice/files/some_file", + "/home/alice/files/some_other_file" + ] + + with subtest("Basic backup"): + systemctl_succeed_as_alice("start restic-backups-basic.service") + actual = succeed_as_alice("restic-basic ls latest") + assert_list("restic-basic ls latest", expectedIncluded, actual) + + assert "exclude" not in actual, \ + f"Paths containing \"*exclude*\" got backed up incorrectly. output: {actual}" + + with subtest("Basic restore"): + succeed_as_alice("restic-basic restore latest --target restore/basic") + actual = fail_as_alice("diff -urNa restore/basic/home/alice/files files") + expected1 = "alices-secret-diary" + expected2 = "alices-bank-details" + assert expected1 in actual and expected2 in actual, \ + f"expected diff -ur restore/basic/home/alice/files files to contain \ + {expected1} and {expected2}, but got {actual}" + + with subtest("Fails to start with an un-initialized repo"): + systemctl_fail_as_alice("start restic-backups-noinit.service") + + with subtest("Start with an initialized repo"): + succeed_as_alice("restic-noinit init") + systemctl_succeed_as_alice("start restic-backups-noinit.service") + + with subtest("Using a repositoryFile"): + systemctl_succeed_as_alice("start restic-backups-repo-file.service") + actual = succeed_as_alice("restic-repo-file ls latest") + assert_list("restic-repo-file ls latest", expectedIncluded, actual) + + assert "exclude" not in actual, \ + f"Paths containing \"*exclude*\" got backed up incorrectly. output: {actual}" + + with subtest("Using an rclone backend"): + systemctl_succeed_as_alice("start restic-backups-rclone.service") + actual = succeed_as_alice("restic-rclone ls latest") + assert_list("restic-rclone ls latest", expectedIncluded, actual) + + assert "exclude" not in actual, \ + f"Paths containing \"*exclude*\" got backed up incorrectly. output: {actual}" + + with subtest("Backup with prepare and cleanup commands"): + systemctl_succeed_as_alice("start restic-backups-pre-post-jobs.service") + spin_on("restic-backups-pre-post-jobs.service") + actual = succeed_as_alice("journalctl --no-pager --user -u restic-backups-pre-post-jobs.service") + + expected_list = [ + "Preparing Backup...", + "Notifying Alice...", + "Ready!", + "Finishing Backup...", + "Mailing alice the results...", + "Done." + ] + assert_list("journalctl --no-pager --user -u restic-backups-pre-post-jobs.service", \ + expected_list, \ + actual) + + expectedIncludedDyn = expectedIncluded + [ + "/home/alice/dyn-files", + "/home/alice/dyn-files/a_dir", + "/home/alice/dyn-files/a_dir/a_file", + "/home/alice/dyn-files/a_dir/a_file_2", + "/home/alice/dyn-files/metadata", + "/home/alice/dyn-files/some_file", + "/home/alice/dyn-files/some_other_file" + ] + + with subtest("Dynamic paths"): + systemctl_succeed_as_alice("start restic-backups-dynamic-paths.service") + actual = succeed_as_alice("restic-dynamic-paths ls latest") + assert_list("restic-dynamic-paths ls latest", expectedIncludedDyn, actual) + + assert "secret" not in actual, \ + f"Paths containing \"*secret*\" got backed up incorrectly. output: {actual}" + + with subtest("Inhibit Sleep"): + systemctl_succeed_as_alice("start --no-block restic-backups-inhibits-sleep.service") + machine.wait_until_succeeds("systemd-inhibit --no-legend --no-pager | grep -q restic", 30) + + spin_on("restic-backups-inhibits-sleep.service") + + actual = succeed_as_alice("restic-inhibits-sleep ls latest") + assert_list("restic-inhibits-sleep ls latest", expectedIncluded, actual) + + assert "exclude" not in actual, \ + f"Paths containing \"*exclude*\" got backed up incorrectly. output: {actual}" + + with subtest("Create a few backups at different times"): + snapshot_count = 0 + + def make_backup(time): + global snapshot_count + machine.succeed(f"timedatectl set-time '{time}'") + systemctl_succeed_as_alice("start restic-backups-prune-me.service") + snapshot_count += 1 + actual = \ + succeed_as_alice("restic-prune-me snapshots --json | ${ + lib.getExe pkgs.jq + } length") + assert int(actual) == snapshot_count, \ + f"Expected a snapshot count of {snapshot_count} but got {actual}" + + # a year with 3 snapshots + make_backup("1970-01-01 12:34") + make_backup("1970-06-01 12:34") + make_backup("1970-12-01 12:34") + # a year with 2 + make_backup("1971-02-11 12:34") + make_backup("1971-03-10 12:34") + # a year with 3 + make_backup("1972-01-02 12:34") + make_backup("1972-03-01 12:34") + make_backup("1972-04-02 12:34") + # a month with 2 + make_backup("1973-04-01 12:34") + make_backup("1973-04-02 12:34") + # a week with 3 + make_backup("1973-06-4 12:34") + make_backup("1973-06-6 12:56") + make_backup("1973-06-9 12:56") + # a week with 2 + make_backup("1973-06-12 12:56") + make_backup("1973-06-13 12:56") + # a day with 3 + make_backup("1973-06-18 01:00") + make_backup("1973-06-18 12:25") + make_backup("1973-06-18 23:01") + # an hour with 3 + make_backup("1973-06-19 21:11") + make_backup("1973-06-19 21:31") + make_backup("1973-06-19 21:41") + # an hour with 2 + make_backup("1973-06-19 23:10") + make_backup("1973-06-19 23:30") + + with subtest("Prune snapshots"): + systemctl_succeed_as_alice("start restic-backups-prune-only.service") + actual = \ + succeed_as_alice("restic-prune-only snapshots --json | ${ + lib.getExe pkgs.jq + } length") + assert int(actual) == 8, \ + f"Expected a snapshot count of 8 but got {actual}" + + logout_alice() + ''; +} diff --git a/tests/modules/services/restic/backup-configs.nix b/tests/modules/services/restic/backup-configs.nix new file mode 100644 index 000000000..b28903e18 --- /dev/null +++ b/tests/modules/services/restic/backup-configs.nix @@ -0,0 +1,78 @@ +{ pkgs, ... }: +let + repository = "/root/restic-backup"; + passwordFile = "/path/to/password"; + paths = [ "/etc" ]; + exclude = [ "/etc/*.cache" "/opt/excluded_file_*" ]; + pruneOpts = [ + "--keep-daily 2" + "--keep-weekly 1" + "--keep-monthly 1" + "--keep-yearly 99" + ]; +in { + local-backup = { + repository = "/mnt/backup-hdd"; + inherit passwordFile paths exclude; + initialize = true; + }; + + remote-backup = { + repository = "sftp:backup@host:/backups/home"; + inherit passwordFile paths; + extraOptions = [ + "sftp.command='ssh backup@host -i /etc/nixos/secrets/backup-private-key -s sftp'" + ]; + timerConfig = { + OnCalendar = "00:05"; + RandomizedDelaySec = "5h"; + }; + }; + + no-timer = { + inherit passwordFile paths exclude pruneOpts repository; + backupPrepareCommand = "dummy-prepare"; + backupCleanupCommand = "dummy-cleanup"; + initialize = true; + timerConfig = null; + }; + + repo-file = { + inherit passwordFile exclude pruneOpts paths; + initialize = true; + repositoryFile = pkgs.writeText "repositoryFile" repository; + dynamicFilesFrom = "find alices files"; + }; + + inhibit-sleep = { + inherit passwordFile paths exclude pruneOpts repository; + initialize = true; + inhibitsSleep = true; + }; + + noinit = { + inherit passwordFile exclude pruneOpts paths repository; + initialize = false; + }; + + rclone = { + inherit passwordFile paths exclude pruneOpts; + initialize = true; + repository = "rclone:local:/root/restic-rclone-backup"; + }; + + prune = { + inherit passwordFile repository; + pruneOpts = [ "--keep-last 1" ]; + }; + + custom-package = { + inherit passwordFile paths; + repository = "some-fake-repository"; + package = pkgs.writeShellScriptBin "my-cool-restic" '' + echo "$@" >> /root/fake-restic.log; + ''; + pruneOpts = [ "--keep-last 1" ]; + checkOpts = [ "--some-check-option" ]; + }; +} diff --git a/tests/modules/services/restic/default.nix b/tests/modules/services/restic/default.nix new file mode 100644 index 000000000..434b3ba5f --- /dev/null +++ b/tests/modules/services/restic/default.nix @@ -0,0 +1 @@ +{ restic-unit-files = ./unit-files.nix; } diff --git a/tests/modules/services/restic/unit-files.nix b/tests/modules/services/restic/unit-files.nix new file mode 100644 index 000000000..de1cd1351 --- /dev/null +++ b/tests/modules/services/restic/unit-files.nix @@ -0,0 +1,126 @@ +{ pkgs, lib, ... }: +let backups = import ./backup-configs.nix { inherit pkgs; }; +in { + services.restic = { + enable = true; + inherit backups; + }; + + nmt.script = '' + backups=( + ${lib.concatLines (lib.attrNames backups)} + ) + + serviceFiles=./home-files/.config/systemd/user + defaultPruneOpts=("forget" "prune" "keep-daily" "2" "keep-weekly" "1" "keep-monthly" "1" "keep-yearly" "99") + inhibitString=("systemd-inhibit" "mode" "block" "who" "restic" "what" "sleep" "why" "Scheduled backup inhibit-sleep") + sftpStrings=("sftp.command=" "ssh" "backup@host" "/etc/nixos/secrets/backup-private-key" "sftp") + + # General prelim tests + for backup in ''${backups[@]}; + do + serviceFile=$serviceFiles/restic-backups-"$backup".service + + # these two are the only ones without pruneOpts + if [ "$backup" != "local-backup" ] && [ "$backup" != "remote-backup" ]; then + assertFileRegex $serviceFile "ExecStart=.*unlock" + assertFileRegex $serviceFile "ExecStart=.*forget.*--prune" + assertFileRegex $serviceFile "ExecStart=.*check" + fi + + if [ "$backup" != "prune" ]; then + assertFileRegex $serviceFile "ExecStart=.*--exclude-file" + assertFileRegex $serviceFile "ExecStart=.*--files-from" + fi + + assertFileExists $serviceFile + assertFileRegex $serviceFile "CacheDirectory=restic-backups-$backup" + assertFileRegex $serviceFile "Environment=.*PATH=.*openssh.*/bin" + assertFileRegex $serviceFile "ExecStartPre" + assertFileRegex $serviceFile "ExecStart=.*restic" + assertFileRegex $serviceFile "ExecStopPost" + assertFileRegex $serviceFile "Description=Restic backup service" + done + + backup=local-backup + serviceFile=$serviceFiles/restic-backups-"$backup".service + assertFileRegex $serviceFile "Environment=RESTIC_REPOSITORY=/mnt/backup-hdd" + assertFileRegex $serviceFile "Environment=RESTIC_PASSWORD_FILE" + + backup=remote-backup + serviceFile=$serviceFiles/restic-backups-"$backup".service + assertFileRegex $serviceFile "Environment=RESTIC_REPOSITORY=sftp:backup@host:/backups/home" + assertFileRegex $serviceFile "Environment=RESTIC_PASSWORD_FILE" + assertFileRegex $serviceFile "ExecStart=.*" + for part in ''${sftpStrings[@]}; do + assertFileRegex $serviceFile "ExecStart=.*$part" + done + + backup=no-timer + serviceFile=$serviceFiles/restic-backups-"$backup".service + assertFileRegex $serviceFile "Environment=RESTIC_REPOSITORY=/root/restic-backup" + assertFileRegex $serviceFile "Environment=RESTIC_PASSWORD_FILE" + assertFileRegex $serviceFile "ExecStart=.*$defaultPruneOpts" + for part in ''${defaultPruneOpts[@]}; do + assertFileRegex $serviceFile "ExecStart=.*$part" + done + # TODO: assertFileNotExists + timerUnit=$serviceFiles/timers.target.wants/restic-backups-"$backup".timer + if [ -f $(_abs $timerUnit) ]; then + fail "restic backup config: \"$backup\" made a timer unit: $timerUnit when \`timerConfig = null\`" + fi + + backup=repo-file + serviceFile=$serviceFiles/restic-backups-"$backup".service + assertFileRegex $serviceFile "Environment=RESTIC_REPOSITORY_FILE=.*repositoryFile" + assertFileRegex $serviceFile "Environment=RESTIC_PASSWORD_FILE" + assertFileRegex $serviceFile "ExecStart=.*$defaultPruneOpts" + for part in ''${defaultPruneOpts[@]}; do + assertFileRegex $serviceFile "ExecStart=.*$part" + done + + backup=inhibit-sleep + serviceFile=$serviceFiles/restic-backups-"$backup".service + assertFileRegex $serviceFile "Environment=RESTIC_REPOSITORY=/root/restic-backup" + assertFileRegex $serviceFile "Environment=RESTIC_PASSWORD_FILE" + assertFileRegex $serviceFile "ExecStart=.*$defaultPruneOpts" + for part in ''${defaultPruneOpts[@]}; do + assertFileRegex $serviceFile "ExecStart=.*$part" + done + for part in ''${inhibitStrings[@]}; do + assertFileRegex $serviceFile "ExecStart=.*$part" + done + + backup=noinit + serviceFile=$serviceFiles/restic-backups-"$backup".service + assertFileRegex $serviceFile "Environment=RESTIC_REPOSITORY=/root/restic-backup" + assertFileRegex $serviceFile "Environment=RESTIC_PASSWORD_FILE" + assertFileRegex $serviceFile "ExecStart=.*$defaultPruneOpts" + for part in ''${defaultPruneOpts[@]}; do + assertFileRegex $serviceFile "ExecStart=.*$part" + done + + backup=rclone + serviceFile=$serviceFiles/restic-backups-"$backup".service + assertFileRegex $serviceFile "Environment=RESTIC_REPOSITORY=rclone:local:/root/restic-rclone-backup" + assertFileRegex $serviceFile "Environment=RESTIC_PASSWORD_FILE" + assertFileRegex $serviceFile "ExecStart=.*$defaultPruneOpts" + for part in ''${defaultPruneOpts[@]}; do + assertFileRegex $serviceFile "ExecStart=.*$part" + done + + backup=prune + serviceFile=$serviceFiles/restic-backups-"$backup".service + assertFileRegex $serviceFile "Environment=RESTIC_REPOSITORY=/root/restic-backup" + assertFileRegex $serviceFile "Environment=RESTIC_PASSWORD_FILE" + assertFileRegex $serviceFile "ExecStart=.*--prune --keep-last 1" + + backup=custom-package + serviceFile=$serviceFiles/restic-backups-"$backup".service + assertFileRegex $serviceFile "Environment=RESTIC_REPOSITORY=some-fake-repository" + assertFileRegex $serviceFile "Environment=RESTIC_PASSWORD_FILE" + assertFileRegex $serviceFile "ExecStart=.*forget --prune --keep-last 1" + assertFileRegex $serviceFile "ExecStart=.*my-cool-restic" + assertFileRegex $serviceFile "ExecStart=.*check --some-check-option" + ''; +}