mirror of
https://github.com/nix-community/home-manager.git
synced 2025-03-31 04:04:32 +00:00
Merge 761af0bf76
into b6fd653ef8
This commit is contained in:
commit
14241682ce
9 changed files with 1052 additions and 0 deletions
|
@ -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
468
modules/services/restic.nix
Normal 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 ];
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
111
tests/integration/standalone/restic-home.nix
Normal file
111
tests/integration/standalone/restic-home.nix
Normal 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"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
265
tests/integration/standalone/restic.nix
Normal file
265
tests/integration/standalone/restic.nix
Normal 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()
|
||||
'';
|
||||
}
|
78
tests/modules/services/restic/backup-configs.nix
Normal file
78
tests/modules/services/restic/backup-configs.nix
Normal 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" ];
|
||||
};
|
||||
}
|
1
tests/modules/services/restic/default.nix
Normal file
1
tests/modules/services/restic/default.nix
Normal file
|
@ -0,0 +1 @@
|
|||
{ restic-unit-files = ./unit-files.nix; }
|
126
tests/modules/services/restic/unit-files.nix
Normal file
126
tests/modules/services/restic/unit-files.nix
Normal 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"
|
||||
'';
|
||||
}
|
Loading…
Add table
Reference in a new issue