1
0
Fork 0
mirror of https://github.com/nix-community/home-manager.git synced 2025-03-31 04:04:32 +00:00
home-manager/modules/services/restic.nix
2025-03-30 08:41:41 +13:00

468 lines
16 KiB
Nix

{ 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 ];
}