python3Packages.orjson: Disable failing tests on 32 bit
[NixPkgs.git] / nixos / modules / services / backup / borgbackup.nix
blob7b29eb41e72a033b8e9ae9464793d0c7a9317af5
1 { config, lib, pkgs, ... }:
3 with lib;
5 let
7   isLocalPath = x:
8     builtins.substring 0 1 x == "/"      # absolute path
9     || builtins.substring 0 1 x == "."   # relative path
10     || builtins.match "[.*:.*]" == null; # not machine:path
12   mkExcludeFile = cfg:
13     # Write each exclude pattern to a new line
14     pkgs.writeText "excludefile" (concatStringsSep "\n" cfg.exclude);
16   mkKeepArgs = cfg:
17     # If cfg.prune.keep e.g. has a yearly attribute,
18     # its content is passed on as --keep-yearly
19     concatStringsSep " "
20       (mapAttrsToList (x: y: "--keep-${x}=${toString y}") cfg.prune.keep);
22   mkBackupScript = cfg: ''
23     on_exit()
24     {
25       exitStatus=$?
26       ${cfg.postHook}
27       exit $exitStatus
28     }
29     trap on_exit EXIT
31     archiveName="${if cfg.archiveBaseName == null then "" else cfg.archiveBaseName + "-"}$(date ${cfg.dateFormat})"
32     archiveSuffix="${optionalString cfg.appendFailedSuffix ".failed"}"
33     ${cfg.preHook}
34   '' + optionalString cfg.doInit ''
35     # Run borg init if the repo doesn't exist yet
36     if ! borg list $extraArgs > /dev/null; then
37       borg init $extraArgs \
38         --encryption ${cfg.encryption.mode} \
39         $extraInitArgs
40       ${cfg.postInit}
41     fi
42   '' + ''
43     (
44       set -o pipefail
45       ${optionalString (cfg.dumpCommand != null) ''${escapeShellArg cfg.dumpCommand} | \''}
46       borg create $extraArgs \
47         --compression ${cfg.compression} \
48         --exclude-from ${mkExcludeFile cfg} \
49         $extraCreateArgs \
50         "::$archiveName$archiveSuffix" \
51         ${if cfg.paths == null then "-" else escapeShellArgs cfg.paths}
52     )
53   '' + optionalString cfg.appendFailedSuffix ''
54     borg rename $extraArgs \
55       "::$archiveName$archiveSuffix" "$archiveName"
56   '' + ''
57     ${cfg.postCreate}
58   '' + optionalString (cfg.prune.keep != { }) ''
59     borg prune $extraArgs \
60       ${mkKeepArgs cfg} \
61       ${optionalString (cfg.prune.prefix != null) "--prefix ${escapeShellArg cfg.prune.prefix} \\"}
62       $extraPruneArgs
63     ${cfg.postPrune}
64   '';
66   mkPassEnv = cfg: with cfg.encryption;
67     if passCommand != null then
68       { BORG_PASSCOMMAND = passCommand; }
69     else if passphrase != null then
70       { BORG_PASSPHRASE = passphrase; }
71     else { };
73   mkBackupService = name: cfg:
74     let
75       userHome = config.users.users.${cfg.user}.home;
76     in nameValuePair "borgbackup-job-${name}" {
77       description = "BorgBackup job ${name}";
78       path = with pkgs; [
79         borgbackup openssh
80       ];
81       script = mkBackupScript cfg;
82       serviceConfig = {
83         User = cfg.user;
84         Group = cfg.group;
85         # Only run when no other process is using CPU or disk
86         CPUSchedulingPolicy = "idle";
87         IOSchedulingClass = "idle";
88         ProtectSystem = "strict";
89         ReadWritePaths =
90           [ "${userHome}/.config/borg" "${userHome}/.cache/borg" ]
91           ++ cfg.readWritePaths
92           # Borg needs write access to repo if it is not remote
93           ++ optional (isLocalPath cfg.repo) cfg.repo;
94         PrivateTmp = cfg.privateTmp;
95       };
96       environment = {
97         BORG_REPO = cfg.repo;
98         inherit (cfg) extraArgs extraInitArgs extraCreateArgs extraPruneArgs;
99       } // (mkPassEnv cfg) // cfg.environment;
100     };
102   mkBackupTimers = name: cfg:
103     nameValuePair "borgbackup-job-${name}" {
104       description = "BorgBackup job ${name} timer";
105       wantedBy = [ "timers.target" ];
106       timerConfig = {
107         Persistent = cfg.persistentTimer;
108         OnCalendar = cfg.startAt;
109       };
110       # if remote-backup wait for network
111       after = optional (cfg.persistentTimer && !isLocalPath cfg.repo) "network-online.target";
112     };
114   # utility function around makeWrapper
115   mkWrapperDrv = {
116       original, name, set ? {}
117     }:
118     pkgs.runCommand "${name}-wrapper" {
119       nativeBuildInputs = [ pkgs.makeWrapper ];
120     } (with lib; ''
121       makeWrapper "${original}" "$out/bin/${name}" \
122         ${concatStringsSep " \\\n " (mapAttrsToList (name: value: ''--set ${name} "${value}"'') set)}
123     '');
125   mkBorgWrapper = name: cfg: mkWrapperDrv {
126     original = "${pkgs.borgbackup}/bin/borg";
127     name = "borg-job-${name}";
128     set = { BORG_REPO = cfg.repo; } // (mkPassEnv cfg) // cfg.environment;
129   };
131   # Paths listed in ReadWritePaths must exist before service is started
132   mkActivationScript = name: cfg:
133     let
134       install = "install -o ${cfg.user} -g ${cfg.group}";
135     in
136       nameValuePair "borgbackup-job-${name}" (stringAfter [ "users" ] (''
137         # Ensure that the home directory already exists
138         # We can't assert createHome == true because that's not the case for root
139         cd "${config.users.users.${cfg.user}.home}"
140         ${install} -d .config/borg
141         ${install} -d .cache/borg
142       '' + optionalString (isLocalPath cfg.repo && !cfg.removableDevice) ''
143         ${install} -d ${escapeShellArg cfg.repo}
144       ''));
146   mkPassAssertion = name: cfg: {
147     assertion = with cfg.encryption;
148       mode != "none" -> passCommand != null || passphrase != null;
149     message =
150       "passCommand or passphrase has to be specified because"
151       + '' borgbackup.jobs.${name}.encryption != "none"'';
152   };
154   mkRepoService = name: cfg:
155     nameValuePair "borgbackup-repo-${name}" {
156       description = "Create BorgBackup repository ${name} directory";
157       script = ''
158         mkdir -p ${escapeShellArg cfg.path}
159         chown ${cfg.user}:${cfg.group} ${escapeShellArg cfg.path}
160       '';
161       serviceConfig = {
162         # The service's only task is to ensure that the specified path exists
163         Type = "oneshot";
164       };
165       wantedBy = [ "multi-user.target" ];
166     };
168   mkAuthorizedKey = cfg: appendOnly: key:
169     let
170       # Because of the following line, clients do not need to specify an absolute repo path
171       cdCommand = "cd ${escapeShellArg cfg.path}";
172       restrictedArg = "--restrict-to-${if cfg.allowSubRepos then "path" else "repository"} .";
173       appendOnlyArg = optionalString appendOnly "--append-only";
174       quotaArg = optionalString (cfg.quota != null) "--storage-quota ${cfg.quota}";
175       serveCommand = "borg serve ${restrictedArg} ${appendOnlyArg} ${quotaArg}";
176     in
177       ''command="${cdCommand} && ${serveCommand}",restrict ${key}'';
179   mkUsersConfig = name: cfg: {
180     users.${cfg.user} = {
181       openssh.authorizedKeys.keys =
182         (map (mkAuthorizedKey cfg false) cfg.authorizedKeys
183         ++ map (mkAuthorizedKey cfg true) cfg.authorizedKeysAppendOnly);
184       useDefaultShell = true;
185       group = cfg.group;
186       isSystemUser = true;
187     };
188     groups.${cfg.group} = { };
189   };
191   mkKeysAssertion = name: cfg: {
192     assertion = cfg.authorizedKeys != [ ] || cfg.authorizedKeysAppendOnly != [ ];
193     message =
194       "borgbackup.repos.${name} does not make sense"
195       + " without at least one public key";
196   };
198   mkSourceAssertions = name: cfg: {
199     assertion = count isNull [ cfg.dumpCommand cfg.paths ] == 1;
200     message = ''
201       Exactly one of borgbackup.jobs.${name}.paths or borgbackup.jobs.${name}.dumpCommand
202       must be set.
203     '';
204   };
206   mkRemovableDeviceAssertions = name: cfg: {
207     assertion = !(isLocalPath cfg.repo) -> !cfg.removableDevice;
208     message = ''
209       borgbackup.repos.${name}: repo isn't a local path, thus it can't be a removable device!
210     '';
211   };
213 in {
214   meta.maintainers = with maintainers; [ dotlambda ];
215   meta.doc = ./borgbackup.xml;
217   ###### interface
219   options.services.borgbackup.jobs = mkOption {
220     description = lib.mdDoc ''
221       Deduplicating backups using BorgBackup.
222       Adding a job will cause a borg-job-NAME wrapper to be added
223       to your system path, so that you can perform maintenance easily.
224       See also the chapter about BorgBackup in the NixOS manual.
225     '';
226     default = { };
227     example = literalExpression ''
228       { # for a local backup
229         rootBackup = {
230           paths = "/";
231           exclude = [ "/nix" ];
232           repo = "/path/to/local/repo";
233           encryption = {
234             mode = "repokey";
235             passphrase = "secret";
236           };
237           compression = "auto,lzma";
238           startAt = "weekly";
239         };
240       }
241       { # Root backing each day up to a remote backup server. We assume that you have
242         #   * created a password less key: ssh-keygen -N "" -t ed25519 -f /path/to/ssh_key
243         #     best practices are: use -t ed25519, /path/to = /run/keys
244         #   * the passphrase is in the file /run/keys/borgbackup_passphrase
245         #   * you have initialized the repository manually
246         paths = [ "/etc" "/home" ];
247         exclude = [ "/nix" "'**/.cache'" ];
248         doInit = false;
249         repo =  "user3@arep.repo.borgbase.com:repo";
250         encryption = {
251           mode = "repokey-blake2";
252           passCommand = "cat /path/to/passphrase";
253         };
254         environment = { BORG_RSH = "ssh -i /path/to/ssh_key"; };
255         compression = "auto,lzma";
256         startAt = "daily";
257     };
258     '';
259     type = types.attrsOf (types.submodule (let globalConfig = config; in
260       { name, config, ... }: {
261         options = {
263           paths = mkOption {
264             type = with types; nullOr (coercedTo str lib.singleton (listOf str));
265             default = null;
266             description = lib.mdDoc ''
267               Path(s) to back up.
268               Mutually exclusive with {option}`dumpCommand`.
269             '';
270             example = "/home/user";
271           };
273           dumpCommand = mkOption {
274             type = with types; nullOr path;
275             default = null;
276             description = lib.mdDoc ''
277               Backup the stdout of this program instead of filesystem paths.
278               Mutually exclusive with {option}`paths`.
279             '';
280             example = "/path/to/createZFSsend.sh";
281           };
283           repo = mkOption {
284             type = types.str;
285             description = lib.mdDoc "Remote or local repository to back up to.";
286             example = "user@machine:/path/to/repo";
287           };
289           removableDevice = mkOption {
290             type = types.bool;
291             default = false;
292             description = lib.mdDoc "Whether the repo (which must be local) is a removable device.";
293           };
295           archiveBaseName = mkOption {
296             type = types.nullOr (types.strMatching "[^/{}]+");
297             default = "${globalConfig.networking.hostName}-${name}";
298             defaultText = literalExpression ''"''${config.networking.hostName}-<name>"'';
299             description = lib.mdDoc ''
300               How to name the created archives. A timestamp, whose format is
301               determined by {option}`dateFormat`, will be appended. The full
302               name can be modified at runtime (`$archiveName`).
303               Placeholders like `{hostname}` must not be used.
304               Use `null` for no base name.
305             '';
306           };
308           dateFormat = mkOption {
309             type = types.str;
310             description = lib.mdDoc ''
311               Arguments passed to {command}`date`
312               to create a timestamp suffix for the archive name.
313             '';
314             default = "+%Y-%m-%dT%H:%M:%S";
315             example = "-u +%s";
316           };
318           startAt = mkOption {
319             type = with types; either str (listOf str);
320             default = "daily";
321             description = lib.mdDoc ''
322               When or how often the backup should run.
323               Must be in the format described in
324               {manpage}`systemd.time(7)`.
325               If you do not want the backup to start
326               automatically, use `[ ]`.
327               It will generate a systemd service borgbackup-job-NAME.
328               You may trigger it manually via systemctl restart borgbackup-job-NAME.
329             '';
330           };
332           persistentTimer = mkOption {
333             default = false;
334             type = types.bool;
335             example = true;
336             description = lib.mdDoc ''
337               Set the `persistentTimer` option for the
338               {manpage}`systemd.timer(5)`
339               which triggers the backup immediately if the last trigger
340               was missed (e.g. if the system was powered down).
341             '';
342           };
344           user = mkOption {
345             type = types.str;
346             description = lib.mdDoc ''
347               The user {command}`borg` is run as.
348               User or group need read permission
349               for the specified {option}`paths`.
350             '';
351             default = "root";
352           };
354           group = mkOption {
355             type = types.str;
356             description = lib.mdDoc ''
357               The group borg is run as. User or group needs read permission
358               for the specified {option}`paths`.
359             '';
360             default = "root";
361           };
363           encryption.mode = mkOption {
364             type = types.enum [
365               "repokey" "keyfile"
366               "repokey-blake2" "keyfile-blake2"
367               "authenticated" "authenticated-blake2"
368               "none"
369             ];
370             description = lib.mdDoc ''
371               Encryption mode to use. Setting a mode
372               other than `"none"` requires
373               you to specify a {option}`passCommand`
374               or a {option}`passphrase`.
375             '';
376             example = "repokey-blake2";
377           };
379           encryption.passCommand = mkOption {
380             type = with types; nullOr str;
381             description = lib.mdDoc ''
382               A command which prints the passphrase to stdout.
383               Mutually exclusive with {option}`passphrase`.
384             '';
385             default = null;
386             example = "cat /path/to/passphrase_file";
387           };
389           encryption.passphrase = mkOption {
390             type = with types; nullOr str;
391             description = lib.mdDoc ''
392               The passphrase the backups are encrypted with.
393               Mutually exclusive with {option}`passCommand`.
394               If you do not want the passphrase to be stored in the
395               world-readable Nix store, use {option}`passCommand`.
396             '';
397             default = null;
398           };
400           compression = mkOption {
401             # "auto" is optional,
402             # compression mode must be given,
403             # compression level is optional
404             type = types.strMatching "none|(auto,)?(lz4|zstd|zlib|lzma)(,[[:digit:]]{1,2})?";
405             description = lib.mdDoc ''
406               Compression method to use. Refer to
407               {command}`borg help compression`
408               for all available options.
409             '';
410             default = "lz4";
411             example = "auto,lzma";
412           };
414           exclude = mkOption {
415             type = with types; listOf str;
416             description = lib.mdDoc ''
417               Exclude paths matching any of the given patterns. See
418               {command}`borg help patterns` for pattern syntax.
419             '';
420             default = [ ];
421             example = [
422               "/home/*/.cache"
423               "/nix"
424             ];
425           };
427           readWritePaths = mkOption {
428             type = with types; listOf path;
429             description = lib.mdDoc ''
430               By default, borg cannot write anywhere on the system but
431               `$HOME/.config/borg` and `$HOME/.cache/borg`.
432               If, for example, your preHook script needs to dump files
433               somewhere, put those directories here.
434             '';
435             default = [ ];
436             example = [
437               "/var/backup/mysqldump"
438             ];
439           };
441           privateTmp = mkOption {
442             type = types.bool;
443             description = lib.mdDoc ''
444               Set the `PrivateTmp` option for
445               the systemd-service. Set to false if you need sockets
446               or other files from global /tmp.
447             '';
448             default = true;
449           };
451           doInit = mkOption {
452             type = types.bool;
453             description = lib.mdDoc ''
454               Run {command}`borg init` if the
455               specified {option}`repo` does not exist.
456               You should set this to `false`
457               if the repository is located on an external drive
458               that might not always be mounted.
459             '';
460             default = true;
461           };
463           appendFailedSuffix = mkOption {
464             type = types.bool;
465             description = lib.mdDoc ''
466               Append a `.failed` suffix
467               to the archive name, which is only removed if
468               {command}`borg create` has a zero exit status.
469             '';
470             default = true;
471           };
473           prune.keep = mkOption {
474             # Specifying e.g. `prune.keep.yearly = -1`
475             # means there is no limit of yearly archives to keep
476             # The regex is for use with e.g. --keep-within 1y
477             type = with types; attrsOf (either int (strMatching "[[:digit:]]+[Hdwmy]"));
478             description = lib.mdDoc ''
479               Prune a repository by deleting all archives not matching any of the
480               specified retention options. See {command}`borg help prune`
481               for the available options.
482             '';
483             default = { };
484             example = literalExpression ''
485               {
486                 within = "1d"; # Keep all archives from the last day
487                 daily = 7;
488                 weekly = 4;
489                 monthly = -1;  # Keep at least one archive for each month
490               }
491             '';
492           };
494           prune.prefix = mkOption {
495             type = types.nullOr (types.str);
496             description = lib.mdDoc ''
497               Only consider archive names starting with this prefix for pruning.
498               By default, only archives created by this job are considered.
499               Use `""` or `null` to consider all archives.
500             '';
501             default = config.archiveBaseName;
502             defaultText = literalExpression "archiveBaseName";
503           };
505           environment = mkOption {
506             type = with types; attrsOf str;
507             description = lib.mdDoc ''
508               Environment variables passed to the backup script.
509               You can for example specify which SSH key to use.
510             '';
511             default = { };
512             example = { BORG_RSH = "ssh -i /path/to/key"; };
513           };
515           preHook = mkOption {
516             type = types.lines;
517             description = lib.mdDoc ''
518               Shell commands to run before the backup.
519               This can for example be used to mount file systems.
520             '';
521             default = "";
522             example = ''
523               # To add excluded paths at runtime
524               extraCreateArgs="$extraCreateArgs --exclude /some/path"
525             '';
526           };
528           postInit = mkOption {
529             type = types.lines;
530             description = lib.mdDoc ''
531               Shell commands to run after {command}`borg init`.
532             '';
533             default = "";
534           };
536           postCreate = mkOption {
537             type = types.lines;
538             description = lib.mdDoc ''
539               Shell commands to run after {command}`borg create`. The name
540               of the created archive is stored in `$archiveName`.
541             '';
542             default = "";
543           };
545           postPrune = mkOption {
546             type = types.lines;
547             description = lib.mdDoc ''
548               Shell commands to run after {command}`borg prune`.
549             '';
550             default = "";
551           };
553           postHook = mkOption {
554             type = types.lines;
555             description = lib.mdDoc ''
556               Shell commands to run just before exit. They are executed
557               even if a previous command exits with a non-zero exit code.
558               The latter is available as `$exitStatus`.
559             '';
560             default = "";
561           };
563           extraArgs = mkOption {
564             type = types.str;
565             description = lib.mdDoc ''
566               Additional arguments for all {command}`borg` calls the
567               service has. Handle with care.
568             '';
569             default = "";
570             example = "--remote-path=/path/to/borg";
571           };
573           extraInitArgs = mkOption {
574             type = types.str;
575             description = lib.mdDoc ''
576               Additional arguments for {command}`borg init`.
577               Can also be set at runtime using `$extraInitArgs`.
578             '';
579             default = "";
580             example = "--append-only";
581           };
583           extraCreateArgs = mkOption {
584             type = types.str;
585             description = lib.mdDoc ''
586               Additional arguments for {command}`borg create`.
587               Can also be set at runtime using `$extraCreateArgs`.
588             '';
589             default = "";
590             example = "--stats --checkpoint-interval 600";
591           };
593           extraPruneArgs = mkOption {
594             type = types.str;
595             description = lib.mdDoc ''
596               Additional arguments for {command}`borg prune`.
597               Can also be set at runtime using `$extraPruneArgs`.
598             '';
599             default = "";
600             example = "--save-space";
601           };
603         };
604       }
605     ));
606   };
608   options.services.borgbackup.repos = mkOption {
609     description = lib.mdDoc ''
610       Serve BorgBackup repositories to given public SSH keys,
611       restricting their access to the repository only.
612       See also the chapter about BorgBackup in the NixOS manual.
613       Also, clients do not need to specify the absolute path when accessing the repository,
614       i.e. `user@machine:.` is enough. (Note colon and dot.)
615     '';
616     default = { };
617     type = types.attrsOf (types.submodule (
618       { ... }: {
619         options = {
620           path = mkOption {
621             type = types.path;
622             description = lib.mdDoc ''
623               Where to store the backups. Note that the directory
624               is created automatically, with correct permissions.
625             '';
626             default = "/var/lib/borgbackup";
627           };
629           user = mkOption {
630             type = types.str;
631             description = lib.mdDoc ''
632               The user {command}`borg serve` is run as.
633               User or group needs write permission
634               for the specified {option}`path`.
635             '';
636             default = "borg";
637           };
639           group = mkOption {
640             type = types.str;
641             description = lib.mdDoc ''
642               The group {command}`borg serve` is run as.
643               User or group needs write permission
644               for the specified {option}`path`.
645             '';
646             default = "borg";
647           };
649           authorizedKeys = mkOption {
650             type = with types; listOf str;
651             description = lib.mdDoc ''
652               Public SSH keys that are given full write access to this repository.
653               You should use a different SSH key for each repository you write to, because
654               the specified keys are restricted to running {command}`borg serve`
655               and can only access this single repository.
656             '';
657             default = [ ];
658           };
660           authorizedKeysAppendOnly = mkOption {
661             type = with types; listOf str;
662             description = lib.mdDoc ''
663               Public SSH keys that can only be used to append new data (archives) to the repository.
664               Note that archives can still be marked as deleted and are subsequently removed from disk
665               upon accessing the repo with full write access, e.g. when pruning.
666             '';
667             default = [ ];
668           };
670           allowSubRepos = mkOption {
671             type = types.bool;
672             description = lib.mdDoc ''
673               Allow clients to create repositories in subdirectories of the
674               specified {option}`path`. These can be accessed using
675               `user@machine:path/to/subrepo`. Note that a
676               {option}`quota` applies to repositories independently.
677               Therefore, if this is enabled, clients can create multiple
678               repositories and upload an arbitrary amount of data.
679             '';
680             default = false;
681           };
683           quota = mkOption {
684             # See the definition of parse_file_size() in src/borg/helpers/parseformat.py
685             type = with types; nullOr (strMatching "[[:digit:].]+[KMGTP]?");
686             description = lib.mdDoc ''
687               Storage quota for the repository. This quota is ensured for all
688               sub-repositories if {option}`allowSubRepos` is enabled
689               but not for the overall storage space used.
690             '';
691             default = null;
692             example = "100G";
693           };
695         };
696       }
697     ));
698   };
700   ###### implementation
702   config = mkIf (with config.services.borgbackup; jobs != { } || repos != { })
703     (with config.services.borgbackup; {
704       assertions =
705         mapAttrsToList mkPassAssertion jobs
706         ++ mapAttrsToList mkKeysAssertion repos
707         ++ mapAttrsToList mkSourceAssertions jobs
708         ++ mapAttrsToList mkRemovableDeviceAssertions jobs;
710       system.activationScripts = mapAttrs' mkActivationScript jobs;
712       systemd.services =
713         # A job named "foo" is mapped to systemd.services.borgbackup-job-foo
714         mapAttrs' mkBackupService jobs
715         # A repo named "foo" is mapped to systemd.services.borgbackup-repo-foo
716         // mapAttrs' mkRepoService repos;
718       # A job named "foo" is mapped to systemd.timers.borgbackup-job-foo
719       # only generate the timer if interval (startAt) is set
720       systemd.timers = mapAttrs' mkBackupTimers (filterAttrs (_: cfg: cfg.startAt != []) jobs);
722       users = mkMerge (mapAttrsToList mkUsersConfig repos);
724       environment.systemPackages = with pkgs; [ borgbackup ] ++ (mapAttrsToList mkBorgWrapper jobs);
725     });