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