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