1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 cfg = config.services.pgbackrest;
10
11 settingsFormat = pkgs.formats.ini {
12 listsAsDuplicateKeys = true;
13 };
14
15 # pgBackRest "options"
16 settingsType =
17 with lib.types;
18 attrsOf (oneOf [
19 bool
20 ints.unsigned
21 str
22 (attrsOf str)
23 (listOf str)
24 ]);
25
26 # Applied to both repoNNN-* and pgNNN-* options in global and stanza sections.
27 flattenWithIndex =
28 attrs: prefix:
29 lib.concatMapAttrs (
30 name:
31 let
32 index = lib.lists.findFirstIndex (n: n == name) null (lib.attrNames attrs);
33 index1 = index + 1;
34 in
35 lib.mapAttrs' (option: lib.nameValuePair "${prefix}${toString index1}-${option}")
36 ) attrs;
37
38 # Remove nulls, turn attrsets into lists and bools into y/n
39 normalize =
40 x:
41 lib.pipe x [
42 (lib.filterAttrs (_: v: v != null))
43 (lib.mapAttrs (_: v: if lib.isAttrs v then lib.mapAttrsToList (n': v': "${n'}=${v'}") v else v))
44 (lib.mapAttrs (
45 _: v:
46 if v == true then
47 "y"
48 else if v == false then
49 "n"
50 else
51 v
52 ))
53 ];
54
55 fullConfig = {
56 global = normalize (cfg.settings // flattenWithIndex cfg.repos "repo");
57 }
58 // lib.mapAttrs' (
59 cmd: settings: lib.nameValuePair "global:${cmd}" (normalize settings)
60 ) cfg.commands
61 // lib.mapAttrs (
62 _: cfg': normalize (cfg'.settings // flattenWithIndex cfg'.instances "pg")
63 ) cfg.stanzas;
64
65 namedJobs = lib.listToAttrs (
66 lib.flatten (
67 lib.mapAttrsToList (
68 stanza:
69 { jobs, ... }:
70 lib.mapAttrsToList (
71 job: attrs: lib.nameValuePair "pgbackrest-${stanza}-${job}" (attrs // { inherit stanza job; })
72 ) jobs
73 ) cfg.stanzas
74 )
75 );
76
77 disabledOption = lib.mkOption {
78 default = null;
79 readOnly = true;
80 internal = true;
81 };
82
83 secretPathOption =
84 with lib.types;
85 lib.mkOption {
86 type = nullOr (pathWith {
87 inStore = false;
88 absolute = true;
89 });
90 default = null;
91 internal = true;
92 };
93in
94
95{
96 meta = {
97 maintainers = with lib.maintainers; [ wolfgangwalther ];
98 };
99
100 # TODO: Add enableServer option and corresponding pgBackRest TLS server service.
101 # TODO: Write wrapper around pgbackrest to turn --repo=<name> into --repo=<number>
102 # The following two are dependent on improvements upstream:
103 # https://github.com/pgbackrest/pgbackrest/issues/2621
104 # TODO: Add support for more repository types
105 # TODO: Support passing encryption key safely
106 options.services.pgbackrest = {
107 enable = lib.mkEnableOption "pgBackRest";
108
109 repos = lib.mkOption {
110 type =
111 with lib.types;
112 attrsOf (
113 submodule (
114 { config, name, ... }:
115 let
116 setHostForType =
117 type:
118 if name == "localhost" then
119 null
120 # "posix" is the default repo type, which uses the -host option.
121 # Other types use prefixed options, for example -sftp-host.
122 else if config.type or "posix" != type then
123 null
124 else
125 name;
126 in
127 {
128 freeformType = settingsType;
129
130 options.host = lib.mkOption {
131 type = nullOr str;
132 default = setHostForType "posix";
133 defaultText = lib.literalExpression "name";
134 description = "Repository host when operating remotely";
135 };
136
137 options.sftp-host = lib.mkOption {
138 type = nullOr str;
139 default = setHostForType "sftp";
140 defaultText = lib.literalExpression "name";
141 description = "SFTP repository host";
142 };
143
144 options.sftp-private-key-file = lib.mkOption {
145 type = nullOr (pathWith {
146 inStore = false;
147 absolute = true;
148 });
149 default = null;
150 description = ''
151 SFTP private key file.
152
153 The file must be accessible by both the pgbackrest and the postgres users.
154 '';
155 };
156
157 # The following options should not be used; they would store secrets in the store.
158 options.azure-key = disabledOption;
159 options.cipher-pass = disabledOption;
160 options.s3-key = disabledOption;
161 options.s3-key-secret = disabledOption;
162 options.s3-kms-key-id = disabledOption; # unsure whether that's a secret or not
163 options.s3-sse-customer-key = disabledOption; # unsure whether that's a secret or not
164 options.s3-token = disabledOption;
165 options.sftp-private-key-passphrase = disabledOption;
166
167 # The following options are not fully supported / tested, yet, but point to files with secrets.
168 # Users can already set those options, but we'll force non-store paths.
169 options.gcs-key = secretPathOption;
170 options.host-cert-file = secretPathOption;
171 options.host-key-file = secretPathOption;
172 }
173 )
174 );
175 default = { };
176 description = ''
177 An attribute set of repositories as described in:
178 <https://pgbackrest.org/configuration.html#section-repository>
179
180 Each repository defaults to set `repo-host` to the attribute's name.
181 The special value "localhost" will unset `repo-host`.
182
183 ::: {.note}
184 The prefix `repoNNN-` is added automatically.
185 Example: Use `path` instead of `repo1-path`.
186 :::
187 '';
188 example = lib.literalExpression ''
189 {
190 localhost.path = "/var/lib/backup";
191 "backup.example.com".host-type = "tls";
192 }
193 '';
194 };
195
196 stanzas = lib.mkOption {
197 type =
198 with lib.types;
199 attrsOf (submodule {
200 options = {
201 jobs = lib.mkOption {
202 type = lib.types.attrsOf (
203 lib.types.submodule {
204 options.schedule = lib.mkOption {
205 type = lib.types.str;
206 description = ''
207 When or how often the backup should run.
208 Must be in the format described in {manpage}`systemd.time(7)`.
209 '';
210 };
211
212 options.type = lib.mkOption {
213 type = lib.types.str;
214 description = ''
215 Backup type as described in:
216 <https://pgbackrest.org/command.html#command-backup/category-command/option-type>
217 '';
218 };
219 }
220 );
221 default = { };
222 description = ''
223 Backups jobs to schedule for this stanza as described in:
224 <https://pgbackrest.org/user-guide.html#quickstart/schedule-backup>
225 '';
226 example = lib.literalExpression ''
227 {
228 weekly = { schedule = "Sun, 6:30"; type = "full"; };
229 daily = { schedule = "Mon..Sat, 6:30"; type = "diff"; };
230 }
231 '';
232 };
233
234 instances = lib.mkOption {
235 type =
236 with lib.types;
237 attrsOf (
238 submodule (
239 { name, ... }:
240 {
241 freeformType = settingsType;
242 options.host = lib.mkOption {
243 type = nullOr str;
244 default = if name == "localhost" then null else name;
245 defaultText = lib.literalExpression ''if name == "localhost" then null else name'';
246 description = "PostgreSQL host for operating remotely.";
247 };
248
249 # The following options are not fully supported / tested, yet, but point to files with secrets.
250 # Users can already set those options, but we'll force non-store paths.
251 options.host-cert-file = secretPathOption;
252 options.host-key-file = secretPathOption;
253 }
254 )
255 );
256 default = { };
257 description = ''
258 An attribute set of database instances as described in:
259 <https://pgbackrest.org/configuration.html#section-stanza>
260
261 Each instance defaults to set `pg-host` to the attribute's name.
262 The special value "localhost" will unset `pg-host`.
263
264 ::: {.note}
265 The prefix `pgNNN-` is added automatically.
266 Example: Use `user` instead of `pg1-user`.
267 :::
268 '';
269 example = lib.literalExpression ''
270 {
271 localhost.database = "app";
272 "postgres.example.com".port = "5433";
273 }
274 '';
275 };
276
277 settings = lib.mkOption {
278 type = lib.types.submodule {
279 freeformType = settingsType;
280
281 # The following options are not fully supported / tested, yet, but point to files with secrets.
282 # Users can already set those options, but we'll force non-store paths.
283 options.tls-server-cert-file = secretPathOption;
284 options.tls-server-key-file = secretPathOption;
285 };
286 default = { };
287 description = ''
288 An attribute set of options as described in:
289 <https://pgbackrest.org/configuration.html>
290
291 All options can be used.
292 Repository options should be set via [`repos`](#opt-services.pgbackrest.repos) instead.
293 Stanza options should be set via [`instances`](#opt-services.pgbackrest.stanzas._name_.instances) instead.
294 '';
295 example = lib.literalExpression ''
296 {
297 process-max = 2;
298 }
299 '';
300 };
301 };
302 });
303 default = { };
304 description = ''
305 An attribute set of stanzas as described in:
306 <https://pgbackrest.org/user-guide.html#quickstart/configure-stanza>
307 '';
308 };
309
310 settings = lib.mkOption {
311 type = lib.types.submodule {
312 freeformType = settingsType;
313
314 # The following options are not fully supported / tested, yet, but point to files with secrets.
315 # Users can already set those options, but we'll force non-store paths.
316 options.tls-server-cert-file = secretPathOption;
317 options.tls-server-key-file = secretPathOption;
318 };
319 default = { };
320 description = ''
321 An attribute set of options as described in:
322 <https://pgbackrest.org/configuration.html>
323
324 All globally available options, i.e. all except stanza options, can be used.
325 Repository options should be set via [`repos`](#opt-services.pgbackrest.repos) instead.
326 '';
327 example = lib.literalExpression ''
328 {
329 process-max = 2;
330 }
331 '';
332 };
333
334 commands =
335 lib.genAttrs
336 [
337 # List of commands from https://pgbackrest.org/command.html:
338 "annotate"
339 "archive-get"
340 "archive-push"
341 "backup"
342 "check"
343 "expire"
344 "help"
345 "info"
346 "repo-get"
347 "repo-ls"
348 "restore"
349 "server"
350 "server-ping"
351 "stanza-create"
352 "stanza-delete"
353 "stanza-upgrade"
354 "start"
355 "stop"
356 "verify"
357 "version"
358 ]
359 (
360 command:
361 lib.mkOption {
362 type = lib.types.submodule {
363 freeformType = settingsType;
364
365 # The following options are not fully supported / tested, yet, but point to files with secrets.
366 # Users can already set those options, but we'll force non-store paths.
367 options.tls-server-cert-file = secretPathOption;
368 options.tls-server-key-file = secretPathOption;
369 };
370 default = { };
371 description = ''
372 Options for the '${command}' command.
373
374 An attribute set of options as described in:
375 <https://pgbackrest.org/configuration.html>
376
377 All globally available options, i.e. all except stanza options, can be used.
378 Repository options should be set via [`repos`](#opt-services.pgbackrest.repos) instead.
379 '';
380 }
381 );
382 };
383
384 config = lib.mkIf cfg.enable (
385 lib.mkMerge [
386 {
387 services.pgbackrest.settings = {
388 log-level-console = lib.mkDefault "info";
389 log-level-file = lib.mkDefault "off";
390 cmd-ssh = lib.getExe pkgs.openssh;
391 };
392
393 environment.systemPackages = [ pkgs.pgbackrest ];
394 environment.etc."pgbackrest/pgbackrest.conf".source =
395 settingsFormat.generate "pgbackrest.conf" fullConfig;
396
397 users.users.pgbackrest = {
398 name = "pgbackrest";
399 group = "pgbackrest";
400 description = "pgBackRest service user";
401 isSystemUser = true;
402 useDefaultShell = true;
403 createHome = true;
404 home = cfg.repos.localhost.path or "/var/lib/pgbackrest";
405 };
406 users.groups.pgbackrest = { };
407
408 systemd.services = lib.mapAttrs (
409 _:
410 {
411 stanza,
412 job,
413 type,
414 ...
415 }:
416 {
417 description = "pgBackRest job ${job} for stanza ${stanza}";
418
419 serviceConfig = {
420 User = "pgbackrest";
421 Group = "pgbackrest";
422 Type = "oneshot";
423 # stanza-create is idempotent, so safe to always run
424 ExecStartPre = "${lib.getExe pkgs.pgbackrest} --stanza='${stanza}' stanza-create";
425 ExecStart = "${lib.getExe pkgs.pgbackrest} --stanza='${stanza}' backup --type='${type}'";
426 };
427 }
428 ) namedJobs;
429
430 systemd.timers = lib.mapAttrs (
431 name:
432 {
433 stanza,
434 job,
435 schedule,
436 ...
437 }:
438 {
439 description = "pgBackRest job ${job} for stanza ${stanza}";
440 wantedBy = [ "timers.target" ];
441 after = [ "network-online.target" ];
442 wants = [ "network-online.target" ];
443 timerConfig = {
444 OnCalendar = schedule;
445 Persistent = true;
446 Unit = "${name}.service";
447 };
448 }
449 ) namedJobs;
450 }
451
452 # The default stanza is set up for the local postgresql instance.
453 # It does not backup automatically, the systemd timer still needs to be set.
454 (lib.mkIf config.services.postgresql.enable {
455 services.pgbackrest.stanzas.default = {
456 settings.cmd = lib.getExe pkgs.pgbackrest;
457 instances.localhost = {
458 path = config.services.postgresql.dataDir;
459 user = "postgres";
460 };
461 };
462 # If PostgreSQL runs on the same machine, any restore will have to be done with that user.
463 # Keeping the lock file in a directory writeable by the postgres user prevents errors.
464 services.pgbackrest.commands.restore.lock-path = "/tmp/postgresql";
465 services.postgresql.identMap = ''
466 postgres pgbackrest postgres
467 '';
468 services.postgresql.initdbArgs = [ "--allow-group-access" ];
469 users.users.pgbackrest.extraGroups = [ "postgres" ];
470
471 services.postgresql.settings = {
472 archive_command = ''${lib.getExe pkgs.pgbackrest} --stanza=default archive-push "%p"'';
473 archive_mode = lib.mkDefault "on";
474 };
475 users.groups.pgbackrest.members = [ "postgres" ];
476 })
477 ]
478 );
479}