diff --git a/modules/services/restic.nix b/modules/services/restic.nix index 20ea8772b..885df92a0 100644 --- a/modules/services/restic.nix +++ b/modules/services/restic.nix @@ -43,6 +43,40 @@ let gnugrep 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 { options.services.restic = { @@ -50,7 +84,14 @@ in backups = lib.mkOption { 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 ( lib.types.submodule ( @@ -102,16 +143,18 @@ in }; }; - 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`. - ''; - }; + inhibitsSleep = linuxExclusive ( + 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; @@ -136,63 +179,71 @@ in ''; }; - 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" - ]; - }; + paths = linuxExclusive ( + lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + description = '' + Paths to back up, alongside those defined by the {option}`dynamicFilesFrom` + option. If left empty and {option}`dynamicFilesFrom` is also not specified, no + backup command will be run. This can be used to create a prune-only job. + ''; + example = [ + "/var/lib/postgresql" + "/home/user/backup" + ]; + } + ); - exclude = lib.mkOption { - type = with lib.types; listOf str; - default = [ ]; - description = '' - Patterns to exclude when backing up. See - for - details on syntax. - ''; - example = [ - "/var/cache" - "/home/*/.cache" - ".git" - ]; - }; + exclude = linuxExclusive ( + lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + description = '' + Patterns to exclude when backing up. See + for + details on syntax. + ''; + example = [ + "/var/cache" + "/home/*/.cache" + ".git" + ]; + } + ); - timerConfig = lib.mkOption { - type = lib.types.nullOr unitType; - default = { - OnCalendar = "daily"; - Persistent = true; - }; - description = '' - When to run the backup. See {manpage}`systemd.timer(5)` for details. If null - no timer is created and the backup will only run when explicitly started. - ''; - example = { - OnCalendar = "00:05"; - RandomizedDelaySec = "5h"; - Persistent = true; - }; - }; + timerConfig = linuxExclusive ( + 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" - ]; - }; + extraBackupArgs = linuxExclusive ( + 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; @@ -206,77 +257,91 @@ in ]; }; - initialize = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Create the repository if it does not already exist. - ''; - }; + initialize = linuxExclusive ( + lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Create the repository if it does not already exist. + ''; + } + ); - pruneOpts = lib.mkOption { - type = with lib.types; listOf str; - default = [ ]; - description = '' - A list of policy options for 'restic forget --prune', to automatically - prune old snapshots. See - - for a full list of options. + pruneOpts = linuxExclusive ( + lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + description = '' + A list of policy options for 'restic forget --prune', to automatically + prune old snapshots. See + + for a full list of options. - Note: The 'forget' command is run *after* the 'backup' command, so keep - that in mind when constructing the --keep-\* options. - ''; - example = [ - "--keep-daily 7" - "--keep-weekly 5" - "--keep-monthly 12" - "--keep-yearly 75" - ]; - }; + 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; - }; + runCheck = linuxExclusive ( + 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" ]; - }; + checkOpts = linuxExclusive ( + 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"; - }; + dynamicFilesFrom = linuxExclusive ( + 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. - ''; - }; + backupPrepareCommand = linuxExclusive ( + 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. - ''; - }; + backupCleanupCommand = linuxExclusive ( + 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; @@ -326,224 +391,221 @@ in }; }; - 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; - }; + config = lib.mkIf cfg.enable ( + lib.mkMerge [ + { + 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; } - ) cfg.backups; - systemd.user.timers = lib.mapAttrs' ( - name: backup: - lib.nameValuePair "restic-backups-${name}" { - Unit.Description = "Restic backup service"; - Install.WantedBy = [ "timers.target" ]; + (lib.mkIf isLinux { + systemd.user.services = lib.mapAttrs' ( + name: backup: + let + 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 ]; }