grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / backup / btrbk.nix
blobfa6c67ff7cbfaa51d3f854f622ba152248c74a22
1 { config, pkgs, lib, ... }:
2 let
3   inherit (lib)
4     concatLists
5     concatMap
6     concatMapStringsSep
7     concatStringsSep
8     filterAttrs
9     flatten
10     getAttr
11     isAttrs
12     literalExpression
13     mapAttrs'
14     mapAttrsToList
15     mkIf
16     mkOption
17     optional
18     optionalString
19     sortOn
20     types
21     ;
23   # The priority of an option or section.
24   # The configurations format are order-sensitive. Pairs are added as children of
25   # the last sections if possible, otherwise, they start a new section.
26   # We sort them in topological order:
27   # 1. Leaf pairs.
28   # 2. Sections that may contain (1).
29   # 3. Sections that may contain (1) or (2).
30   # 4. Etc.
31   prioOf = { name, value }:
32     if !isAttrs value then 0 # Leaf options.
33     else {
34       target = 1; # Contains: options.
35       subvolume = 2; # Contains: options, target.
36       volume = 3; # Contains: options, target, subvolume.
37     }.${name} or (throw "Unknow section '${name}'");
39   genConfig' = set: concatStringsSep "\n" (genConfig set);
40   genConfig = set:
41     let
42       pairs = mapAttrsToList (name: value: { inherit name value; }) set;
43       sortedPairs = sortOn prioOf pairs;
44     in
45       concatMap genPair sortedPairs;
46   genSection = sec: secName: value:
47     [ "${sec} ${secName}" ] ++ map (x: " " + x) (genConfig value);
48   genPair = { name, value }:
49     if !isAttrs value
50     then [ "${name} ${value}" ]
51     else concatLists (mapAttrsToList (genSection name) value);
53   sudoRule = {
54     users = [ "btrbk" ];
55     commands = [
56       { command = "${pkgs.btrfs-progs}/bin/btrfs"; options = [ "NOPASSWD" ]; }
57       { command = "${pkgs.coreutils}/bin/mkdir"; options = [ "NOPASSWD" ]; }
58       { command = "${pkgs.coreutils}/bin/readlink"; options = [ "NOPASSWD" ]; }
59       # for ssh, they are not the same than the one hard coded in ${pkgs.btrbk}
60       { command = "/run/current-system/sw/bin/btrfs"; options = [ "NOPASSWD" ]; }
61       { command = "/run/current-system/sw/bin/mkdir"; options = [ "NOPASSWD" ]; }
62       { command = "/run/current-system/sw/bin/readlink"; options = [ "NOPASSWD" ]; }
63     ];
64   };
66   sudo_doas =
67     if config.security.sudo.enable || config.security.sudo-rs.enable then "sudo"
68     else if config.security.doas.enable then "doas"
69     else throw "The btrbk nixos module needs either sudo or doas enabled in the configuration";
71   addDefaults = settings: { backend = "btrfs-progs-${sudo_doas}"; } // settings;
73   mkConfigFile = name: settings: pkgs.writeTextFile {
74     name = "btrbk-${name}.conf";
75     text = genConfig' (addDefaults settings);
76     checkPhase = ''
77       set +e
78       ${pkgs.btrbk}/bin/btrbk -c $out dryrun
79       # According to btrbk(1), exit status 2 means parse error
80       # for CLI options or the config file.
81       if [[ $? == 2 ]]; then
82         echo "Btrbk configuration is invalid:"
83         cat $out
84         exit 1
85       fi
86       set -e
87     '';
88   };
90   streamCompressMap = {
91     gzip = pkgs.gzip;
92     pigz = pkgs.pigz;
93     bzip2 = pkgs.bzip2;
94     pbzip2 = pkgs.pbzip2;
95     bzip3 = pkgs.bzip3;
96     xz = pkgs.xz;
97     lzo = pkgs.lzo;
98     lz4 = pkgs.lz4;
99     zstd = pkgs.zstd;
100   };
102   cfg = config.services.btrbk;
103   sshEnabled = cfg.sshAccess != [ ];
104   serviceEnabled = cfg.instances != { };
107   meta.maintainers = with lib.maintainers; [ oxalica ];
109   options = {
110     services.btrbk = {
111       extraPackages = mkOption {
112         description = ''
113           Extra packages for btrbk, like compression utilities for `stream_compress`.
115           **Note**: This option will get deprecated in future releases.
116           Required compression programs will get automatically provided to btrbk
117           depending on configured compression method in
118           `services.btrbk.instances.<name>.settings` option.
119         '';
120         type = types.listOf types.package;
121         default = [ ];
122         example = literalExpression "[ pkgs.xz ]";
123       };
124       niceness = mkOption {
125         description = "Niceness for local instances of btrbk. Also applies to remote ones connecting via ssh when positive.";
126         type = types.ints.between (-20) 19;
127         default = 10;
128       };
129       ioSchedulingClass = mkOption {
130         description = "IO scheduling class for btrbk (see ionice(1) for a quick description). Applies to local instances, and remote ones connecting by ssh if set to idle.";
131         type = types.enum [ "idle" "best-effort" "realtime" ];
132         default = "best-effort";
133       };
134       instances = mkOption {
135         description = "Set of btrbk instances. The instance named `btrbk` is the default one.";
136         type = with types;
137           attrsOf (
138             submodule {
139               options = {
140                 onCalendar = mkOption {
141                   type = types.nullOr types.str;
142                   default = "daily";
143                   description = ''
144                     How often this btrbk instance is started. See systemd.time(7) for more information about the format.
145                     Setting it to null disables the timer, thus this instance can only be started manually.
146                   '';
147                 };
148                 settings = mkOption {
149                   type = types.submodule {
150                     freeformType = let t = types.attrsOf (types.either types.str (t // { description = "instances of this type recursively"; })); in t;
151                     options = {
152                       stream_compress = mkOption {
153                         description = ''
154                           Compress the btrfs send stream before transferring it from/to remote locations using a
155                           compression command.
156                         '';
157                         type = types.enum ["gzip" "pigz" "bzip2" "pbzip2" "bzip3" "xz" "lzo" "lz4" "zstd" "no"];
158                         default = "no";
159                       };
160                     };
161                   };
162                   default = { };
163                   example = {
164                     snapshot_preserve_min = "2d";
165                     snapshot_preserve = "14d";
166                     volume = {
167                       "/mnt/btr_pool" = {
168                         target = "/mnt/btr_backup/mylaptop";
169                         subvolume = {
170                           "rootfs" = { };
171                           "home" = { snapshot_create = "always"; };
172                         };
173                       };
174                     };
175                   };
176                   description = "configuration options for btrbk. Nested attrsets translate to subsections.";
177                 };
178               };
179             }
180           );
181         default = { };
182       };
183       sshAccess = mkOption {
184         description = "SSH keys that should be able to make or push snapshots on this system remotely with btrbk";
185         type = with types; listOf (
186           submodule {
187             options = {
188               key = mkOption {
189                 type = str;
190                 description = "SSH public key allowed to login as user `btrbk` to run remote backups.";
191               };
192               roles = mkOption {
193                 type = listOf (enum [ "info" "source" "target" "delete" "snapshot" "send" "receive" ]);
194                 example = [ "source" "info" "send" ];
195                 description = "What actions can be performed with this SSH key. See ssh_filter_btrbk(1) for details";
196               };
197             };
198           }
199         );
200         default = [ ];
201       };
202     };
204   };
205   config = mkIf (sshEnabled || serviceEnabled) {
207     environment.systemPackages = [ pkgs.btrbk ] ++ cfg.extraPackages;
209     security.sudo.extraRules = mkIf (sudo_doas == "sudo") [ sudoRule ];
210     security.sudo-rs.extraRules = mkIf (sudo_doas == "sudo") [ sudoRule ];
212     security.doas = mkIf (sudo_doas == "doas") {
213       extraRules = let
214         doasCmdNoPass = cmd: { users = [ "btrbk" ]; cmd = cmd; noPass = true; };
215       in
216         [
217             (doasCmdNoPass "${pkgs.btrfs-progs}/bin/btrfs")
218             (doasCmdNoPass "${pkgs.coreutils}/bin/mkdir")
219             (doasCmdNoPass "${pkgs.coreutils}/bin/readlink")
220             # for ssh, they are not the same than the one hard coded in ${pkgs.btrbk}
221             (doasCmdNoPass "/run/current-system/sw/bin/btrfs")
222             (doasCmdNoPass "/run/current-system/sw/bin/mkdir")
223             (doasCmdNoPass "/run/current-system/sw/bin/readlink")
225             # doas matches command, not binary
226             (doasCmdNoPass "btrfs")
227             (doasCmdNoPass "mkdir")
228             (doasCmdNoPass "readlink")
229         ];
230     };
231     users.users.btrbk = {
232       isSystemUser = true;
233       # ssh needs a home directory
234       home = "/var/lib/btrbk";
235       createHome = true;
236       shell = "${pkgs.bash}/bin/bash";
237       group = "btrbk";
238       openssh.authorizedKeys.keys = map
239         (
240           v:
241           let
242             options = concatMapStringsSep " " (x: "--" + x) v.roles;
243             ioniceClass = {
244               "idle" = 3;
245               "best-effort" = 2;
246               "realtime" = 1;
247             }.${cfg.ioSchedulingClass};
248             sudo_doas_flag = "--${sudo_doas}";
249           in
250           ''command="${pkgs.util-linux}/bin/ionice -t -c ${toString ioniceClass} ${optionalString (cfg.niceness >= 1) "${pkgs.coreutils}/bin/nice -n ${toString cfg.niceness}"} ${pkgs.btrbk}/share/btrbk/scripts/ssh_filter_btrbk.sh ${sudo_doas_flag} ${options}" ${v.key}''
251         )
252         cfg.sshAccess;
253     };
254     users.groups.btrbk = { };
255     systemd.tmpfiles.rules = [
256       "d /var/lib/btrbk 0750 btrbk btrbk"
257       "d /var/lib/btrbk/.ssh 0700 btrbk btrbk"
258       "f /var/lib/btrbk/.ssh/config 0700 btrbk btrbk - StrictHostKeyChecking=accept-new"
259     ];
260     environment.etc = mapAttrs'
261       (
262         name: instance: {
263           name = "btrbk/${name}.conf";
264           value.source = mkConfigFile name instance.settings;
265         }
266       )
267       cfg.instances;
268     systemd.services = mapAttrs'
269       (
270         name: instance: {
271           name = "btrbk-${name}";
272           value = {
273             description = "Takes BTRFS snapshots and maintains retention policies.";
274             unitConfig.Documentation = "man:btrbk(1)";
275             path = [ "/run/wrappers" ]
276               ++ cfg.extraPackages
277               ++ optional (instance.settings.stream_compress != "no")
278                 (getAttr instance.settings.stream_compress streamCompressMap);
279             serviceConfig = {
280               User = "btrbk";
281               Group = "btrbk";
282               Type = "oneshot";
283               ExecStart = "${pkgs.btrbk}/bin/btrbk -c /etc/btrbk/${name}.conf run";
284               Nice = cfg.niceness;
285               IOSchedulingClass = cfg.ioSchedulingClass;
286               StateDirectory = "btrbk";
287             };
288           };
289         }
290       )
291       cfg.instances;
293     systemd.timers = mapAttrs'
294       (
295         name: instance: {
296           name = "btrbk-${name}";
297           value = {
298             description = "Timer to take BTRFS snapshots and maintain retention policies.";
299             wantedBy = [ "timers.target" ];
300             timerConfig = {
301               OnCalendar = instance.onCalendar;
302               AccuracySec = "10min";
303               Persistent = true;
304             };
305           };
306         }
307       )
308       (filterAttrs (name: instance: instance.onCalendar != null)
309         cfg.instances);
310   };