1
0
Fork 0
mirror of https://github.com/nix-community/home-manager.git synced 2025-03-31 04:04:32 +00:00
This commit is contained in:
Jess 2025-03-30 21:51:29 +02:00 committed by GitHub
commit 14241682ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1052 additions and 0 deletions

View file

@ -398,6 +398,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

468
modules/services/restic.nix Normal file
View file

@ -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 <https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html>
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
<https://rclone.org/docs/#options> 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
<https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html>.
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
<https://restic.readthedocs.io/en/stable/040_backup.html#excluding-files> 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
<https://restic.readthedocs.io/en/latest/060_forget.html#removing-snapshots-according-to-a-policy>
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 ];
}

View file

@ -543,6 +543,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

View file

@ -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;
};

View file

@ -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"
];
};
};
};
}

View file

@ -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 \"<home-manager>\" -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()
'';
}

View file

@ -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" ];
};
}

View file

@ -0,0 +1 @@
{ restic-unit-files = ./unit-files.nix; }

View file

@ -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"
'';
}