1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8
9 planDescription = ''
10 The znapzend backup plan to use for the source.
11
12 The plan specifies how often to backup and for how long to keep the
13 backups. It consists of a series of retention periods to interval
14 associations:
15
16 ```
17 retA=>intA,retB=>intB,...
18 ```
19
20 Both intervals and retention periods are expressed in standard units
21 of time or multiples of them. You can use both the full name or a
22 shortcut according to the following listing:
23
24 ```
25 second|sec|s, minute|min, hour|h, day|d, week|w, month|mon|m, year|y
26 ```
27
28 See {manpage}`znapzendzetup(1)` for more info.
29 '';
30 planExample = "1h=>10min,1d=>1h,1w=>1d,1m=>1w,1y=>1m";
31
32 # A type for a string of the form number{b|k|M|G}
33 mbufferSizeType = lib.types.str // {
34 check = x: lib.types.str.check x && builtins.isList (builtins.match "^[0-9]+[bkMG]$" x);
35 description = "string of the form number{b|k|M|G}";
36 };
37
38 enabledFeatures = lib.concatLists (
39 lib.mapAttrsToList (name: enabled: lib.optional enabled name) cfg.features
40 );
41
42 # Type for a string that must contain certain other strings (the list parameter).
43 # Note that these would need regex escaping.
44 stringContainingStrings =
45 list:
46 let
47 matching = s: map (str: builtins.match ".*${str}.*" s) list;
48 in
49 lib.types.str
50 // {
51 check = x: lib.types.str.check x && lib.all lib.isList (matching x);
52 description = "string containing all of the characters ${lib.concatStringsSep ", " list}";
53 };
54
55 timestampType = stringContainingStrings [
56 "%Y"
57 "%m"
58 "%d"
59 "%H"
60 "%M"
61 "%S"
62 ];
63
64 destType =
65 srcConfig:
66 lib.types.submodule (
67 { name, ... }:
68 {
69 options = {
70
71 label = lib.mkOption {
72 type = lib.types.str;
73 description = "Label for this destination. Defaults to the attribute name.";
74 };
75
76 plan = lib.mkOption {
77 type = lib.types.str;
78 description = planDescription;
79 example = planExample;
80 };
81
82 dataset = lib.mkOption {
83 type = lib.types.str;
84 description = "Dataset name to send snapshots to.";
85 example = "tank/main";
86 };
87
88 host = lib.mkOption {
89 type = lib.types.nullOr lib.types.str;
90 description = ''
91 Host to use for the destination dataset. Can be prefixed with
92 `user@` to specify the ssh user.
93 '';
94 default = null;
95 example = "john@example.com";
96 };
97
98 presend = lib.mkOption {
99 type = lib.types.nullOr lib.types.str;
100 description = ''
101 Command to run before sending the snapshot to the destination.
102 Intended to run a remote script via {command}`ssh` on the
103 destination, e.g. to bring up a backup disk or server or to put a
104 zpool online/offline. See also {option}`postsend`.
105 '';
106 default = null;
107 example = "ssh root@bserv zpool import -Nf tank";
108 };
109
110 postsend = lib.mkOption {
111 type = lib.types.nullOr lib.types.str;
112 description = ''
113 Command to run after sending the snapshot to the destination.
114 Intended to run a remote script via {command}`ssh` on the
115 destination, e.g. to bring up a backup disk or server or to put a
116 zpool online/offline. See also {option}`presend`.
117 '';
118 default = null;
119 example = "ssh root@bserv zpool export tank";
120 };
121 };
122
123 config = {
124 label = lib.mkDefault name;
125 plan = lib.mkDefault srcConfig.plan;
126 };
127 }
128 );
129
130 srcType = lib.types.submodule (
131 { name, config, ... }:
132 {
133 options = {
134
135 enable = lib.mkOption {
136 type = lib.types.bool;
137 description = "Whether to enable this source.";
138 default = true;
139 };
140
141 recursive = lib.mkOption {
142 type = lib.types.bool;
143 description = "Whether to do recursive snapshots.";
144 default = false;
145 };
146
147 mbuffer = {
148 enable = lib.mkOption {
149 type = lib.types.bool;
150 description = "Whether to use {command}`mbuffer`.";
151 default = false;
152 };
153
154 port = lib.mkOption {
155 type = lib.types.nullOr lib.types.ints.u16;
156 description = ''
157 Port to use for {command}`mbuffer`.
158
159 If this is null, it will run {command}`mbuffer` through
160 ssh.
161
162 If this is not null, it will run {command}`mbuffer`
163 directly through TCP, which is not encrypted but faster. In that
164 case the given port needs to be open on the destination host.
165 '';
166 default = null;
167 };
168
169 size = lib.mkOption {
170 type = mbufferSizeType;
171 description = ''
172 The size for {command}`mbuffer`.
173 Supports the units b, k, M, G.
174 '';
175 default = "1G";
176 example = "128M";
177 };
178 };
179
180 presnap = lib.mkOption {
181 type = lib.types.nullOr lib.types.str;
182 description = ''
183 Command to run before snapshots are taken on the source dataset,
184 e.g. for database locking/flushing. See also
185 {option}`postsnap`.
186 '';
187 default = null;
188 example = lib.literalExpression ''
189 '''''${pkgs.mariadb}/bin/mysql -e "set autocommit=0;flush tables with read lock;\\! ''${pkgs.coreutils}/bin/sleep 600" & ''${pkgs.coreutils}/bin/echo $! > /tmp/mariadblock.pid ; sleep 10'''
190 '';
191 };
192
193 postsnap = lib.mkOption {
194 type = lib.types.nullOr lib.types.str;
195 description = ''
196 Command to run after snapshots are taken on the source dataset,
197 e.g. for database unlocking. See also {option}`presnap`.
198 '';
199 default = null;
200 example = lib.literalExpression ''
201 "''${pkgs.coreutils}/bin/kill `''${pkgs.coreutils}/bin/cat /tmp/mariadblock.pid`;''${pkgs.coreutils}/bin/rm /tmp/mariadblock.pid"
202 '';
203 };
204
205 timestampFormat = lib.mkOption {
206 type = timestampType;
207 description = ''
208 The timestamp format to use for constructing snapshot names.
209 The syntax is `strftime`-like. The string must
210 consist of the mandatory `%Y %m %d %H %M %S`.
211 Optionally `- _ . :` characters as well as any
212 alphanumeric character are allowed. If suffixed by a
213 `Z`, times will be in UTC.
214 '';
215 default = "%Y-%m-%d-%H%M%S";
216 example = "znapzend-%m.%d.%Y-%H%M%SZ";
217 };
218
219 sendDelay = lib.mkOption {
220 type = lib.types.int;
221 description = ''
222 Specify delay (in seconds) before sending snaps to the destination.
223 May be useful if you want to control sending time.
224 '';
225 default = 0;
226 example = 60;
227 };
228
229 plan = lib.mkOption {
230 type = lib.types.str;
231 description = planDescription;
232 example = planExample;
233 };
234
235 dataset = lib.mkOption {
236 type = lib.types.str;
237 description = "The dataset to use for this source.";
238 example = "tank/home";
239 };
240
241 destinations = lib.mkOption {
242 type = lib.types.attrsOf (destType config);
243 description = "Additional destinations.";
244 default = { };
245 example = lib.literalExpression ''
246 {
247 local = {
248 dataset = "btank/backup";
249 presend = "zpool import -N btank";
250 postsend = "zpool export btank";
251 };
252 remote = {
253 host = "john@example.com";
254 dataset = "tank/john";
255 };
256 };
257 '';
258 };
259 };
260
261 config = {
262 dataset = lib.mkDefault name;
263 };
264
265 }
266 );
267
268 ### Generating the configuration from here
269
270 cfg = config.services.znapzend;
271
272 onOff = b: if b then "on" else "off";
273 nullOff = b: if b == null then "off" else toString b;
274 stripSlashes = lib.replaceStrings [ "/" ] [ "." ];
275
276 attrsToFile =
277 config: lib.concatStringsSep "\n" (builtins.attrValues (lib.mapAttrs (n: v: "${n}=${v}") config));
278
279 mkDestAttrs =
280 dst:
281 with dst;
282 lib.mapAttrs' (n: v: lib.nameValuePair "dst_${label}${n}" v) (
283 {
284 "" = lib.optionalString (host != null) "${host}:" + dataset;
285 _plan = plan;
286 }
287 // lib.optionalAttrs (presend != null) {
288 _precmd = presend;
289 }
290 // lib.optionalAttrs (postsend != null) {
291 _pstcmd = postsend;
292 }
293 );
294
295 mkSrcAttrs =
296 srcCfg:
297 with srcCfg;
298 {
299 enabled = onOff enable;
300 # mbuffer is not referenced by its full path to accommodate non-NixOS systems or differing mbuffer versions between source and target
301 mbuffer =
302 with mbuffer;
303 if enable then "mbuffer" + lib.optionalString (port != null) ":${toString port}" else "off";
304 mbuffer_size = mbuffer.size;
305 post_znap_cmd = nullOff postsnap;
306 pre_znap_cmd = nullOff presnap;
307 recursive = onOff recursive;
308 src = dataset;
309 src_plan = plan;
310 tsformat = timestampFormat;
311 zend_delay = toString sendDelay;
312 }
313 // lib.foldr (a: b: a // b) { } (map mkDestAttrs (builtins.attrValues destinations));
314
315 files = lib.mapAttrs' (
316 n: srcCfg:
317 let
318 fileText = attrsToFile (mkSrcAttrs srcCfg);
319 in
320 {
321 name = srcCfg.dataset;
322 value = pkgs.writeText (stripSlashes srcCfg.dataset) fileText;
323 }
324 ) cfg.zetup;
325
326in
327{
328 options = {
329 services.znapzend = {
330 enable = lib.mkEnableOption "ZnapZend ZFS backup daemon";
331
332 logLevel = lib.mkOption {
333 default = "debug";
334 example = "warning";
335 type = lib.types.enum [
336 "debug"
337 "info"
338 "warning"
339 "err"
340 "alert"
341 ];
342 description = ''
343 The log level when logging to file. Any of debug, info, warning, err,
344 alert. Default in daemonized form is debug.
345 '';
346 };
347
348 logTo = lib.mkOption {
349 type = lib.types.str;
350 default = "syslog::daemon";
351 example = "/var/log/znapzend.log";
352 description = ''
353 Where to log to (syslog::\<facility\> or \<filepath\>).
354 '';
355 };
356
357 mailErrorSummaryTo = lib.mkOption {
358 type = lib.types.singleLineStr;
359 default = "";
360 description = ''
361 Email address to send a summary to if "send task(s) failed".
362 '';
363 };
364
365 noDestroy = lib.mkOption {
366 type = lib.types.bool;
367 default = false;
368 description = "Does all changes to the filesystem except destroy.";
369 };
370
371 autoCreation = lib.mkOption {
372 type = lib.types.bool;
373 default = false;
374 description = "Automatically create the destination dataset if it does not exist.";
375 };
376
377 zetup = lib.mkOption {
378 type = lib.types.attrsOf srcType;
379 description = "Znapzend configuration.";
380 default = { };
381 example = lib.literalExpression ''
382 {
383 "tank/home" = {
384 # Make snapshots of tank/home every hour, keep those for 1 day,
385 # keep every days snapshot for 1 month, etc.
386 plan = "1d=>1h,1m=>1d,1y=>1m";
387 recursive = true;
388 # Send all those snapshots to john@example.com:rtank/john as well
389 destinations.remote = {
390 host = "john@example.com";
391 dataset = "rtank/john";
392 };
393 };
394 };
395 '';
396 };
397
398 pure = lib.mkOption {
399 type = lib.types.bool;
400 description = ''
401 Do not persist any stateful znapzend setups. If this option is
402 enabled, your previously set znapzend setups will be cleared and only
403 the ones defined with this module will be applied.
404 '';
405 default = false;
406 };
407
408 features.oracleMode = lib.mkEnableOption ''
409 destroying snapshots one by one instead of using one long argument list.
410 If source and destination are out of sync for a long time, you may have
411 so many snapshots to destroy that the argument gets is too long and the
412 command fails
413 '';
414 features.recvu = lib.mkEnableOption ''
415 recvu feature which uses `-u` on the receiving end to keep the destination
416 filesystem unmounted
417 '';
418 features.compressed = lib.mkEnableOption ''
419 compressed feature which adds the options `-Lce` to
420 the {command}`zfs send` command. When this is enabled, make
421 sure that both the sending and receiving pool have the same relevant
422 features enabled. Using `-c` will skip unnecessary
423 decompress-compress stages, `-L` is for large block
424 support and -e is for embedded data support. see
425 {manpage}`znapzend(1)`
426 and {manpage}`zfs(8)`
427 for more info
428 '';
429 features.sendRaw = lib.mkEnableOption ''
430 sendRaw feature which adds the options `-w` to the
431 {command}`zfs send` command. For encrypted source datasets this
432 instructs zfs not to decrypt before sending which results in a remote
433 backup that can't be read without the encryption key/passphrase, useful
434 when the remote isn't fully trusted or not physically secure. This
435 option must be used consistently, raw incrementals cannot be based on
436 non-raw snapshots and vice versa
437 '';
438 features.skipIntermediates = lib.mkEnableOption ''
439 the skipIntermediates feature to send a single increment
440 between latest common snapshot and the newly made one. It may skip
441 several source snaps if the destination was offline for some time, and
442 it should skip snapshots not managed by znapzend. Normally for online
443 destinations, the new snapshot is sent as soon as it is created on the
444 source, so there are no automatic increments to skip
445 '';
446 features.lowmemRecurse = lib.mkEnableOption ''
447 use lowmemRecurse on systems where you have too many datasets, so a
448 recursive listing of attributes to find backup plans exhausts the
449 memory available to {command}`znapzend`: instead, go the slower
450 way to first list all impacted dataset names, and then query their
451 configs one by one
452 '';
453 features.zfsGetType = lib.mkEnableOption ''
454 using zfsGetType if your {command}`zfs get` supports a
455 `-t` argument for filtering by dataset type at all AND
456 lists properties for snapshots by default when recursing, so that there
457 is too much data to process while searching for backup plans.
458 If these two conditions apply to your system, the time needed for a
459 `--recursive` search for backup plans can literally
460 differ by hundreds of times (depending on the amount of snapshots in
461 that dataset tree... and a decent backup plan will ensure you have a lot
462 of those), so you would benefit from requesting this feature
463 '';
464 };
465 };
466
467 config = lib.mkIf cfg.enable {
468 environment.systemPackages = [ pkgs.znapzend ];
469
470 systemd.services = {
471 znapzend = {
472 description = "ZnapZend - ZFS Backup System";
473 wantedBy = [ "zfs.target" ];
474 after = [ "zfs.target" ];
475
476 path = with pkgs; [
477 config.boot.zfs.package
478 mbuffer
479 openssh
480 ];
481
482 preStart =
483 lib.optionalString cfg.pure ''
484 echo Resetting znapzend zetups
485 ${pkgs.znapzend}/bin/znapzendzetup list \
486 | grep -oP '(?<=\*\*\* backup plan: ).*(?= \*\*\*)' \
487 | xargs -I{} ${pkgs.znapzend}/bin/znapzendzetup delete "{}"
488 ''
489 + lib.concatStringsSep "\n" (
490 lib.mapAttrsToList (dataset: config: ''
491 echo Importing znapzend zetup ${config} for dataset ${dataset}
492 ${pkgs.znapzend}/bin/znapzendzetup import --write ${dataset} ${config} &
493 '') files
494 )
495 + ''
496 wait
497 '';
498
499 serviceConfig = {
500 # znapzendzetup --import apparently tries to connect to the backup
501 # host 3 times with a timeout of 30 seconds, leading to a startup
502 # delay of >90s when the host is down, which is just above the default
503 # service timeout of 90 seconds. Increase the timeout so it doesn't
504 # make the service fail in that case.
505 TimeoutStartSec = 180;
506 # Needs to have write access to ZFS
507 User = "root";
508 ExecStart =
509 let
510 args = lib.concatStringsSep " " [
511 "--logto=${cfg.logTo}"
512 "--loglevel=${cfg.logLevel}"
513 (lib.optionalString cfg.noDestroy "--nodestroy")
514 (lib.optionalString cfg.autoCreation "--autoCreation")
515 (lib.optionalString (cfg.mailErrorSummaryTo != "") "--mailErrorSummaryTo=${cfg.mailErrorSummaryTo}")
516 (lib.optionalString (
517 enabledFeatures != [ ]
518 ) "--features=${lib.concatStringsSep "," enabledFeatures}")
519 ];
520 in
521 "${pkgs.znapzend}/bin/znapzend ${args}";
522 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
523 Restart = "on-failure";
524 };
525 };
526 };
527 };
528
529 meta.maintainers = with lib.maintainers; [ SlothOfAnarchy ];
530}