1
0
Fork 0
mirror of https://github.com/nix-community/home-manager.git synced 2025-11-08 19:46:05 +01:00

restic: decouple helper script from linux-specific systemd logic

And make platform support explicit.
This commit is contained in:
Jess 2025-10-19 12:47:22 +13:00 committed by Austin Horstman
parent 12bca6d40a
commit c199de6cd8

View file

@ -43,6 +43,40 @@ let
gnugrep gnugrep
which which
]; ];
# Create the base restic environment variables for both systemd
# and the helper script
mkEnvironment =
backup:
lib.flatten [
"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
))
];
inherit (pkgs.stdenv.hostPlatform) isLinux;
# Until we have launchd support (#7924), mark the options
# not used in the helper script as "linux exclusive"
linuxExclusive =
option:
option
// {
readOnly = pkgs.stdenv.hostPlatform.isDarwin;
description = option.description + ''
This option is only supported on linux.
'';
};
in in
{ {
options.services.restic = { options.services.restic = {
@ -50,7 +84,14 @@ in
backups = lib.mkOption { backups = lib.mkOption {
description = '' description = ''
Periodic backups to create with Restic. Backup configurations for Restic.
On Linux systems, a corresponding systemd user service
(and optionally a systemd timer for automatic scheduling)
will be created, along with a helper wrapper script.
On non-Linux platforms, only the helper wrapper script
will be created.
''; '';
type = lib.types.attrsOf ( type = lib.types.attrsOf (
lib.types.submodule ( lib.types.submodule (
@ -102,16 +143,18 @@ in
}; };
}; };
inhibitsSleep = lib.mkOption { inhibitsSleep = linuxExclusive (
default = false; lib.mkOption {
type = lib.types.bool; default = false;
example = true; type = lib.types.bool;
description = '' example = true;
Prevents the system from sleeping while backing up. This uses systemd-inhibit description = ''
to block system idling so you may need to enable polkitd with Prevents the system from sleeping while backing up. This uses systemd-inhibit
{option}`security.polkit.enable`. to block system idling so you may need to enable polkitd with
''; {option}`security.polkit.enable`.
}; '';
}
);
repository = lib.mkOption { repository = lib.mkOption {
type = with lib.types; nullOr str; type = with lib.types; nullOr str;
@ -136,63 +179,71 @@ in
''; '';
}; };
paths = lib.mkOption { paths = linuxExclusive (
type = with lib.types; listOf str; lib.mkOption {
default = [ ]; type = with lib.types; listOf str;
description = '' default = [ ];
Paths to back up, alongside those defined by the {option}`dynamicFilesFrom` description = ''
option. If left empty and {option}`dynamicFilesFrom` is also not specified, no Paths to back up, alongside those defined by the {option}`dynamicFilesFrom`
backup command will be run. This can be used to create a prune-only job. 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" example = [
"/home/user/backup" "/var/lib/postgresql"
]; "/home/user/backup"
}; ];
}
);
exclude = lib.mkOption { exclude = linuxExclusive (
type = with lib.types; listOf str; lib.mkOption {
default = [ ]; type = with lib.types; listOf str;
description = '' default = [ ];
Patterns to exclude when backing up. See description = ''
<https://restic.readthedocs.io/en/stable/040_backup.html#excluding-files> for Patterns to exclude when backing up. See
details on syntax. <https://restic.readthedocs.io/en/stable/040_backup.html#excluding-files> for
''; details on syntax.
example = [ '';
"/var/cache" example = [
"/home/*/.cache" "/var/cache"
".git" "/home/*/.cache"
]; ".git"
}; ];
}
);
timerConfig = lib.mkOption { timerConfig = linuxExclusive (
type = lib.types.nullOr unitType; lib.mkOption {
default = { type = lib.types.nullOr unitType;
OnCalendar = "daily"; default = {
Persistent = true; OnCalendar = "daily";
}; Persistent = true;
description = '' };
When to run the backup. See {manpage}`systemd.timer(5)` for details. If null description = ''
no timer is created and the backup will only run when explicitly started. 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"; example = {
RandomizedDelaySec = "5h"; OnCalendar = "00:05";
Persistent = true; RandomizedDelaySec = "5h";
}; Persistent = true;
}; };
}
);
extraBackupArgs = lib.mkOption { extraBackupArgs = linuxExclusive (
type = with lib.types; listOf str; lib.mkOption {
default = [ ]; type = with lib.types; listOf str;
description = '' default = [ ];
Extra arguments passed to restic backup. description = ''
''; Extra arguments passed to restic backup.
example = [ '';
"--cleanup-cache" example = [
"--exclude-file=/etc/nixos/restic-ignore" "--cleanup-cache"
]; "--exclude-file=/etc/nixos/restic-ignore"
}; ];
}
);
extraOptions = lib.mkOption { extraOptions = lib.mkOption {
type = with lib.types; listOf str; type = with lib.types; listOf str;
@ -206,77 +257,91 @@ in
]; ];
}; };
initialize = lib.mkOption { initialize = linuxExclusive (
type = lib.types.bool; lib.mkOption {
default = false; type = lib.types.bool;
description = '' default = false;
Create the repository if it does not already exist. description = ''
''; Create the repository if it does not already exist.
}; '';
}
);
pruneOpts = lib.mkOption { pruneOpts = linuxExclusive (
type = with lib.types; listOf str; lib.mkOption {
default = [ ]; type = with lib.types; listOf str;
description = '' default = [ ];
A list of policy options for 'restic forget --prune', to automatically description = ''
prune old snapshots. See A list of policy options for 'restic forget --prune', to automatically
<https://restic.readthedocs.io/en/latest/060_forget.html#removing-snapshots-according-to-a-policy> prune old snapshots. See
for a full list of options. <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 Note: The 'forget' command is run *after* the 'backup' command, so keep
that in mind when constructing the --keep-\* options. that in mind when constructing the --keep-\* options.
''; '';
example = [ example = [
"--keep-daily 7" "--keep-daily 7"
"--keep-weekly 5" "--keep-weekly 5"
"--keep-monthly 12" "--keep-monthly 12"
"--keep-yearly 75" "--keep-yearly 75"
]; ];
}; }
);
runCheck = lib.mkOption { runCheck = linuxExclusive (
type = lib.types.bool; lib.mkOption {
default = lib.length config.checkOpts > 0 || lib.length config.pruneOpts > 0; type = lib.types.bool;
defaultText = lib.literalExpression "lib.length config.checkOpts > 0 || lib.length config.pruneOpts > 0"; default = lib.length config.checkOpts > 0 || lib.length config.pruneOpts > 0;
description = "Whether to run 'restic check' with the provided `checkOpts` options."; defaultText = lib.literalExpression "lib.length config.checkOpts > 0 || lib.length config.pruneOpts > 0";
example = true; description = "Whether to run 'restic check' with the provided `checkOpts` options.";
}; example = true;
}
);
checkOpts = lib.mkOption { checkOpts = linuxExclusive (
type = with lib.types; listOf str; lib.mkOption {
default = [ ]; type = with lib.types; listOf str;
description = '' default = [ ];
A list of options for 'restic check'. description = ''
''; A list of options for 'restic check'.
example = [ "--with-cache" ]; '';
}; example = [ "--with-cache" ];
}
);
dynamicFilesFrom = lib.mkOption { dynamicFilesFrom = linuxExclusive (
type = with lib.types; nullOr str; lib.mkOption {
default = null; type = with lib.types; nullOr str;
description = '' default = null;
A script that produces a list of files to back up. The results of description = ''
this command, along with the paths specified via {option}`paths`, A script that produces a list of files to back up. The results of
are given to the '--files-from' option. 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"; '';
}; example = "find /home/alice/git -type d -name .git";
}
);
backupPrepareCommand = lib.mkOption { backupPrepareCommand = linuxExclusive (
type = with lib.types; nullOr str; lib.mkOption {
default = null; type = with lib.types; nullOr str;
description = '' default = null;
A script that must run before starting the backup process. description = ''
''; A script that must run before starting the backup process.
}; '';
}
);
backupCleanupCommand = lib.mkOption { backupCleanupCommand = linuxExclusive (
type = with lib.types; nullOr str; lib.mkOption {
default = null; type = with lib.types; nullOr str;
description = '' default = null;
A script that must run after finishing the backup process. description = ''
''; A script that must run after finishing the backup process.
}; '';
}
);
createWrapper = lib.mkOption { createWrapper = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
@ -326,224 +391,221 @@ in
}; };
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable (
assertions = lib.mapAttrsToList (n: v: { lib.mkMerge [
assertion = lib.xor (v.repository == null) (v.repositoryFile == null); {
message = "services.restic.backups.${n}: exactly one of repository or repositoryFile should be set"; assertions = lib.mapAttrsToList (n: v: {
}) cfg.backups; assertion = lib.xor (v.repository == null) (v.repositoryFile == null);
message = "services.restic.backups.${n}: exactly one of repository or repositoryFile should be set";
systemd.user.services = lib.mapAttrs' ( }) cfg.backups;
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' ( (lib.mkIf isLinux {
name: backup: systemd.user.services = lib.mapAttrs' (
lib.nameValuePair "restic-backups-${name}" { name: backup:
Unit.Description = "Restic backup service"; let
Install.WantedBy = [ "timers.target" ]; doBackup = backup.dynamicFilesFrom != null || backup.paths != [ ];
doPrune = backup.pruneOpts != [ ];
doCheck = backup.runCheck;
serviceName = "restic-backups-${name}";
Timer = backup.timerConfig; 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 = mkEnvironment backup ++ [ "RESTIC_CACHE_DIR=%C" ];
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;
})
(lib.mkIf isLinux {
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}";
environment = mkEnvironment backup;
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}";
excludeShellChecks = [
# https://github.com/koalaman/shellcheck/issues/1986
"SC2034"
# Allow sourcing environmentFile
"SC1091"
];
bashOptions = [
"errexit"
"nounset"
"allexport"
];
text = ''
${lib.optionalString (backup.environmentFile != null) ''
source ${backup.environmentFile}
''}
# Set same environment variables as the systemd service
${lib.pipe environment [
(lib.filter notPathVar)
lib.concatLines
]}
RESTIC_CACHE_DIR=$HOME/.cache/${serviceName}
PATH=${
lib.pipe environment [
(lib.filter (lib.hasPrefix "PATH="))
lib.head
(lib.removePrefix "PATH=")
]
}:$PATH
exec ${restic} "$@"
'';
}
) (lib.filterAttrs (_: v: v.createWrapper) cfg.backups);
} }
) (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}";
excludeShellChecks = [
# https://github.com/koalaman/shellcheck/issues/1986
"SC2034"
# Allow sourcing environmentFile
"SC1091"
];
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.maintainers.jess ]; meta.maintainers = [ lib.maintainers.jess ];
} }