grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / backup / restic.nix
blob0fb601cce58920e5f28fa1d2270a6d2b566d4bf4
1 { config, lib, pkgs, utils, ... }:
2 let
3   # Type for a valid systemd unit option. Needed for correctly passing "timerConfig" to "systemd.timers"
4   inherit (utils.systemdUtils.unitOptions) unitOption;
5 in
7   options.services.restic.backups = lib.mkOption {
8     description = ''
9       Periodic backups to create with Restic.
10     '';
11     type = lib.types.attrsOf (lib.types.submodule ({ name, ... }: {
12       options = {
13         passwordFile = lib.mkOption {
14           type = lib.types.str;
15           description = ''
16             Read the repository password from a file.
17           '';
18           example = "/etc/nixos/restic-password";
19         };
21         environmentFile = lib.mkOption {
22           type = with lib.types; nullOr str;
23           default = null;
24           description = ''
25             file containing the credentials to access the repository, in the
26             format of an EnvironmentFile as described by systemd.exec(5)
27           '';
28         };
30         rcloneOptions = lib.mkOption {
31           type = with lib.types; nullOr (attrsOf (oneOf [ str bool ]));
32           default = null;
33           description = ''
34             Options to pass to rclone to control its behavior.
35             See <https://rclone.org/docs/#options> for
36             available options. When specifying option names, strip the
37             leading `--`. To set a flag such as
38             `--drive-use-trash`, which does not take a value,
39             set the value to the Boolean `true`.
40           '';
41           example = {
42             bwlimit = "10M";
43             drive-use-trash = "true";
44           };
45         };
47         rcloneConfig = lib.mkOption {
48           type = with lib.types; nullOr (attrsOf (oneOf [ str bool ]));
49           default = null;
50           description = ''
51             Configuration for the rclone remote being used for backup.
52             See the remote's specific options under rclone's docs at
53             <https://rclone.org/docs/>. When specifying
54             option names, use the "config" name specified in the docs.
55             For example, to set `--b2-hard-delete` for a B2
56             remote, use `hard_delete = true` in the
57             attribute set.
58             Warning: Secrets set in here will be world-readable in the Nix
59             store! Consider using the `rcloneConfigFile`
60             option instead to specify secret values separately. Note that
61             options set here will override those set in the config file.
62           '';
63           example = {
64             type = "b2";
65             account = "xxx";
66             key = "xxx";
67             hard_delete = true;
68           };
69         };
71         rcloneConfigFile = lib.mkOption {
72           type = with lib.types; nullOr path;
73           default = null;
74           description = ''
75             Path to the file containing rclone configuration. This file
76             must contain configuration for the remote specified in this backup
77             set and also must be readable by root. Options set in
78             `rcloneConfig` will override those set in this
79             file.
80           '';
81         };
83         inhibitsSleep = lib.mkOption {
84           default = false;
85           type = lib.types.bool;
86           example = true;
87           description = ''
88             Prevents the system from sleeping while backing up.
89           '';
90         };
92         repository = lib.mkOption {
93           type = with lib.types; nullOr str;
94           default = null;
95           description = ''
96             repository to backup to.
97           '';
98           example = "sftp:backup@192.168.1.100:/backups/${name}";
99         };
101         repositoryFile = lib.mkOption {
102           type = with lib.types; nullOr path;
103           default = null;
104           description = ''
105             Path to the file containing the repository location to backup to.
106           '';
107         };
109         paths = lib.mkOption {
110           # This is nullable for legacy reasons only. We should consider making it a pure listOf
111           # after some time has passed since this comment was added.
112           type = lib.types.nullOr (lib.types.listOf lib.types.str);
113           default = [ ];
114           description = ''
115             Which paths to backup, in addition to ones specified via
116             `dynamicFilesFrom`.  If null or an empty array and
117             `dynamicFilesFrom` is also null, no backup command will be run.
118              This can be used to create a prune-only job.
119           '';
120           example = [
121             "/var/lib/postgresql"
122             "/home/user/backup"
123           ];
124         };
126         exclude = lib.mkOption {
127           type = lib.types.listOf lib.types.str;
128           default = [ ];
129           description = ''
130             Patterns to exclude when backing up. See
131             https://restic.readthedocs.io/en/latest/040_backup.html#excluding-files for
132             details on syntax.
133           '';
134           example = [
135             "/var/cache"
136             "/home/*/.cache"
137             ".git"
138           ];
139         };
141         timerConfig = lib.mkOption {
142           type = lib.types.nullOr (lib.types.attrsOf unitOption);
143           default = {
144             OnCalendar = "daily";
145             Persistent = true;
146           };
147           description = ''
148             When to run the backup. See {manpage}`systemd.timer(5)` for
149             details. If null no timer is created and the backup will only
150             run when explicitly started.
151           '';
152           example = {
153             OnCalendar = "00:05";
154             RandomizedDelaySec = "5h";
155             Persistent = true;
156           };
157         };
159         user = lib.mkOption {
160           type = lib.types.str;
161           default = "root";
162           description = ''
163             As which user the backup should run.
164           '';
165           example = "postgresql";
166         };
168         extraBackupArgs = lib.mkOption {
169           type = lib.types.listOf lib.types.str;
170           default = [ ];
171           description = ''
172             Extra arguments passed to restic backup.
173           '';
174           example = [
175             "--exclude-file=/etc/nixos/restic-ignore"
176           ];
177         };
179         extraOptions = lib.mkOption {
180           type = lib.types.listOf lib.types.str;
181           default = [ ];
182           description = ''
183             Extra extended options to be passed to the restic --option flag.
184           '';
185           example = [
186             "sftp.command='ssh backup@192.168.1.100 -i /home/user/.ssh/id_rsa -s sftp'"
187           ];
188         };
190         initialize = lib.mkOption {
191           type = lib.types.bool;
192           default = false;
193           description = ''
194             Create the repository if it doesn't exist.
195           '';
196         };
198         pruneOpts = lib.mkOption {
199           type = lib.types.listOf lib.types.str;
200           default = [ ];
201           description = ''
202             A list of options (--keep-\* et al.) for 'restic forget
203             --prune', to automatically prune old snapshots.  The
204             'forget' command is run *after* the 'backup' command, so
205             keep that in mind when constructing the --keep-\* options.
206           '';
207           example = [
208             "--keep-daily 7"
209             "--keep-weekly 5"
210             "--keep-monthly 12"
211             "--keep-yearly 75"
212           ];
213         };
215         runCheck = lib.mkOption {
216           type = lib.types.bool;
217           default = (builtins.length config.services.restic.backups.${name}.checkOpts > 0);
218           defaultText = lib.literalExpression ''builtins.length config.services.backups.${name}.checkOpts > 0'';
219           description = "Whether to run the `check` command with the provided `checkOpts` options.";
220           example = true;
221         };
223         checkOpts = lib.mkOption {
224           type = lib.types.listOf lib.types.str;
225           default = [ ];
226           description = ''
227             A list of options for 'restic check'.
228           '';
229           example = [
230             "--with-cache"
231           ];
232         };
234         dynamicFilesFrom = lib.mkOption {
235           type = with lib.types; nullOr str;
236           default = null;
237           description = ''
238             A script that produces a list of files to back up.  The
239             results of this command are given to the '--files-from'
240             option. The result is merged with paths specified via `paths`.
241           '';
242           example = "find /home/matt/git -type d -name .git";
243         };
245         backupPrepareCommand = lib.mkOption {
246           type = with lib.types; nullOr str;
247           default = null;
248           description = ''
249             A script that must run before starting the backup process.
250           '';
251         };
253         backupCleanupCommand = lib.mkOption {
254           type = with lib.types; nullOr str;
255           default = null;
256           description = ''
257             A script that must run after finishing the backup process.
258           '';
259         };
261         package = lib.mkPackageOption pkgs "restic" { };
263         createWrapper = lib.mkOption {
264           type = lib.types.bool;
265           default = true;
266           description = ''
267             Whether to generate and add a script to the system path, that has the same environment variables set
268             as the systemd service. This can be used to e.g. mount snapshots or perform other opterations, without
269             having to manually specify most options.
270           '';
271         };
272       };
273     }));
274     default = { };
275     example = {
276       localbackup = {
277         paths = [ "/home" ];
278         exclude = [ "/home/*/.cache" ];
279         repository = "/mnt/backup-hdd";
280         passwordFile = "/etc/nixos/secrets/restic-password";
281         initialize = true;
282       };
283       remotebackup = {
284         paths = [ "/home" ];
285         repository = "sftp:backup@host:/backups/home";
286         passwordFile = "/etc/nixos/secrets/restic-password";
287         extraOptions = [
288           "sftp.command='ssh backup@host -i /etc/nixos/secrets/backup-private-key -s sftp'"
289         ];
290         timerConfig = {
291           OnCalendar = "00:05";
292           RandomizedDelaySec = "5h";
293         };
294       };
295     };
296   };
298   config = {
299     assertions = lib.mapAttrsToList (n: v: {
300       assertion = (v.repository == null) != (v.repositoryFile == null);
301       message = "services.restic.backups.${n}: exactly one of repository or repositoryFile should be set";
302     }) config.services.restic.backups;
303     systemd.services =
304       lib.mapAttrs'
305         (name: backup:
306           let
307             extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions;
308             inhibitCmd = lib.concatStringsSep " " [
309               "${pkgs.systemd}/bin/systemd-inhibit"
310               "--mode='block'"
311               "--who='restic'"
312               "--what='sleep'"
313               "--why=${lib.escapeShellArg "Scheduled backup ${name}"} "
314             ];
315             resticCmd = "${lib.optionalString backup.inhibitsSleep inhibitCmd}${backup.package}/bin/restic${extraOptions}";
316             excludeFlags = lib.optional (backup.exclude != []) "--exclude-file=${pkgs.writeText "exclude-patterns" (lib.concatStringsSep "\n" backup.exclude)}";
317             filesFromTmpFile = "/run/restic-backups-${name}/includes";
318             doBackup = (backup.dynamicFilesFrom != null) || (backup.paths != null && backup.paths != []);
319             pruneCmd = lib.optionals (builtins.length backup.pruneOpts > 0) [
320               (resticCmd + " forget --prune " + (lib.concatStringsSep " " backup.pruneOpts))
321             ];
322             checkCmd = lib.optionals backup.runCheck [
323                 (resticCmd + " check " + (lib.concatStringsSep " " backup.checkOpts))
324             ];
325             # Helper functions for rclone remotes
326             rcloneRemoteName = builtins.elemAt (lib.splitString ":" backup.repository) 1;
327             rcloneAttrToOpt = v: "RCLONE_" + lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v);
328             rcloneAttrToConf = v: "RCLONE_CONFIG_" + lib.toUpper (rcloneRemoteName + "_" + v);
329             toRcloneVal = v: if lib.isBool v then lib.boolToString v else v;
330           in
331           lib.nameValuePair "restic-backups-${name}" ({
332             environment = {
333               # not %C, because that wouldn't work in the wrapper script
334               RESTIC_CACHE_DIR = "/var/cache/restic-backups-${name}";
335               RESTIC_PASSWORD_FILE = backup.passwordFile;
336               RESTIC_REPOSITORY = backup.repository;
337               RESTIC_REPOSITORY_FILE = backup.repositoryFile;
338             } // lib.optionalAttrs (backup.rcloneOptions != null) (lib.mapAttrs'
339               (name: value:
340                 lib.nameValuePair (rcloneAttrToOpt name) (toRcloneVal value)
341               )
342               backup.rcloneOptions) // lib.optionalAttrs (backup.rcloneConfigFile != null) {
343               RCLONE_CONFIG = backup.rcloneConfigFile;
344             } // lib.optionalAttrs (backup.rcloneConfig != null) (lib.mapAttrs'
345               (name: value:
346                 lib.nameValuePair (rcloneAttrToConf name) (toRcloneVal value)
347               )
348               backup.rcloneConfig);
349             path = [ config.programs.ssh.package ];
350             restartIfChanged = false;
351             wants = [ "network-online.target" ];
352             after = [ "network-online.target" ];
353             serviceConfig = {
354               Type = "oneshot";
355               ExecStart = (lib.optionals doBackup [ "${resticCmd} backup ${lib.concatStringsSep " " (backup.extraBackupArgs ++ excludeFlags)} --files-from=${filesFromTmpFile}" ])
356                 ++ pruneCmd ++ checkCmd;
357               User = backup.user;
358               RuntimeDirectory = "restic-backups-${name}";
359               CacheDirectory = "restic-backups-${name}";
360               CacheDirectoryMode = "0700";
361               PrivateTmp = true;
362             } // lib.optionalAttrs (backup.environmentFile != null) {
363               EnvironmentFile = backup.environmentFile;
364             };
365           } // lib.optionalAttrs (backup.initialize || doBackup || backup.backupPrepareCommand != null) {
366             preStart = ''
367               ${lib.optionalString (backup.backupPrepareCommand != null) ''
368                 ${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand}
369               ''}
370               ${lib.optionalString (backup.initialize) ''
371                 ${resticCmd} cat config > /dev/null || ${resticCmd} init
372               ''}
373               ${lib.optionalString (backup.paths != null && backup.paths != []) ''
374                 cat ${pkgs.writeText "staticPaths" (lib.concatLines backup.paths)} >> ${filesFromTmpFile}
375               ''}
376               ${lib.optionalString (backup.dynamicFilesFrom != null) ''
377                 ${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} >> ${filesFromTmpFile}
378               ''}
379             '';
380           } // lib.optionalAttrs (doBackup || backup.backupCleanupCommand != null) {
381             postStop = ''
382               ${lib.optionalString (backup.backupCleanupCommand != null) ''
383                 ${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand}
384               ''}
385               ${lib.optionalString doBackup ''
386                 rm ${filesFromTmpFile}
387               ''}
388             '';
389           })
390         )
391         config.services.restic.backups;
392     systemd.timers =
393       lib.mapAttrs'
394         (name: backup: lib.nameValuePair "restic-backups-${name}" {
395           wantedBy = [ "timers.target" ];
396           timerConfig = backup.timerConfig;
397         })
398         (lib.filterAttrs (_: backup: backup.timerConfig != null) config.services.restic.backups);
400     # generate wrapper scripts, as described in the createWrapper option
401     environment.systemPackages = lib.mapAttrsToList (name: backup: let
402       extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions;
403       resticCmd = "${backup.package}/bin/restic${extraOptions}";
404     in pkgs.writeShellScriptBin "restic-${name}" ''
405       set -a  # automatically export variables
406       ${lib.optionalString (backup.environmentFile != null) "source ${backup.environmentFile}"}
407       # set same environment variables as the systemd service
408       ${lib.pipe config.systemd.services."restic-backups-${name}".environment [
409         (lib.filterAttrs (n: v: v != null && n != "PATH"))
410         (lib.mapAttrsToList (n: v: "${n}=${v}"))
411         (lib.concatStringsSep "\n")
412       ]}
413       PATH=${config.systemd.services."restic-backups-${name}".environment.PATH}:$PATH
415       exec ${resticCmd} $@
416     '') (lib.filterAttrs (_: v: v.createWrapper) config.services.restic.backups);
417   };