{ config, lib, pkgs, ... }: let cfg = config.services.pgbackrest; settingsFormat = pkgs.formats.ini { listsAsDuplicateKeys = true; }; # pgBackRest "options" settingsType = with lib.types; attrsOf (oneOf [ bool ints.unsigned str (attrsOf str) (listOf str) ]); # Applied to both repoNNN-* and pgNNN-* options in global and stanza sections. flattenWithIndex = attrs: prefix: lib.concatMapAttrs ( name: let index = lib.lists.findFirstIndex (n: n == name) null (lib.attrNames attrs); index1 = index + 1; in lib.mapAttrs' (option: lib.nameValuePair "${prefix}${toString index1}-${option}") ) attrs; # Remove nulls, turn attrsets into lists and bools into y/n normalize = x: lib.pipe x [ (lib.filterAttrs (_: v: v != null)) (lib.mapAttrs (_: v: if lib.isAttrs v then lib.mapAttrsToList (n': v': "${n'}=${v'}") v else v)) (lib.mapAttrs ( _: v: if v == true then "y" else if v == false then "n" else v )) ]; fullConfig = { global = normalize (cfg.settings // flattenWithIndex cfg.repos "repo"); } // lib.mapAttrs ( _: cfg': normalize (cfg'.settings // flattenWithIndex cfg'.instances "pg") ) cfg.stanzas; namedJobs = lib.listToAttrs ( lib.flatten ( lib.mapAttrsToList ( stanza: { jobs, ... }: lib.mapAttrsToList ( job: attrs: lib.nameValuePair "pgbackrest-${stanza}-${job}" (attrs // { inherit stanza job; }) ) jobs ) cfg.stanzas ) ); disabledOption = lib.mkOption { default = null; readOnly = true; internal = true; }; secretPathOption = with lib.types; lib.mkOption { type = nullOr (pathWith { inStore = false; absolute = true; }); default = null; internal = true; }; in { meta = { maintainers = with lib.maintainers; [ wolfgangwalther ]; }; # TODO: Add enableServer option and corresponding pgBackRest TLS server service. # TODO: Allow command-specific options # TODO: Write wrapper around pgbackrest to turn --repo= into --repo= # The following two are dependent on improvements upstream: # https://github.com/pgbackrest/pgbackrest/issues/2621 # TODO: Add support for more repository types # TODO: Support passing encryption key safely options.services.pgbackrest = { enable = lib.mkEnableOption "pgBackRest"; repos = lib.mkOption { type = with lib.types; attrsOf ( submodule ( { config, name, ... }: let setHostForType = type: if name == "localhost" then null # "posix" is the default repo type, which uses the -host option. # Other types use prefixed options, for example -sftp-host. else if config.type or "posix" != type then null else name; in { freeformType = settingsType; options.host = lib.mkOption { type = nullOr str; default = setHostForType "posix"; defaultText = lib.literalExpression "name"; description = "Repository host when operating remotely"; }; options.sftp-host = lib.mkOption { type = nullOr str; default = setHostForType "sftp"; defaultText = lib.literalExpression "name"; description = "SFTP repository host"; }; options.sftp-private-key-file = lib.mkOption { type = nullOr (pathWith { inStore = false; absolute = true; }); default = null; description = '' SFTP private key file. The file must be accessible by both the pgbackrest and the postgres users. ''; }; # The following options should not be used; they would store secrets in the store. options.azure-key = disabledOption; options.cipher-pass = disabledOption; options.s3-key = disabledOption; options.s3-key-secret = disabledOption; options.s3-kms-key-id = disabledOption; # unsure whether that's a secret or not options.s3-sse-customer-key = disabledOption; # unsure whether that's a secret or not options.s3-token = disabledOption; options.sftp-private-key-passphrase = disabledOption; # The following options are not fully supported / tested, yet, but point to files with secrets. # Users can already set those options, but we'll force non-store paths. options.gcs-key = secretPathOption; options.host-cert-file = secretPathOption; options.host-key-file = secretPathOption; } ) ); default = { }; description = '' An attribute set of repositories as described in: Each repository defaults to set `repo-host` to the attribute's name. The special value "localhost" will unset `repo-host`. ::: {.note} The prefix `repoNNN-` is added automatically. Example: Use `path` instead of `repo1-path`. ::: ''; example = lib.literalExpression '' { localhost.path = "/var/lib/backup"; "backup.example.com".host-type = "tls"; } ''; }; stanzas = lib.mkOption { type = with lib.types; attrsOf (submodule { options = { jobs = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { options.schedule = lib.mkOption { type = lib.types.str; description = '' When or how often the backup should run. Must be in the format described in {manpage}`systemd.time(7)`. ''; }; options.type = lib.mkOption { type = lib.types.str; description = '' Backup type as described in: ''; }; } ); default = { }; description = '' Backups jobs to schedule for this stanza as described in: ''; example = lib.literalExpression '' { weekly = { schedule = "Sun, 6:30"; type = "full"; }; daily = { schedule = "Mon..Sat, 6:30"; type = "diff"; }; } ''; }; instances = lib.mkOption { type = with lib.types; attrsOf ( submodule ( { name, ... }: { freeformType = settingsType; options.host = lib.mkOption { type = nullOr str; default = if name == "localhost" then null else name; defaultText = lib.literalExpression ''if name == "localhost" then null else name''; description = "PostgreSQL host for operating remotely."; }; # The following options are not fully supported / tested, yet, but point to files with secrets. # Users can already set those options, but we'll force non-store paths. options.host-cert-file = secretPathOption; options.host-key-file = secretPathOption; } ) ); default = { }; description = '' An attribute set of database instances as described in: Each instance defaults to set `pg-host` to the attribute's name. The special value "localhost" will unset `pg-host`. ::: {.note} The prefix `pgNNN-` is added automatically. Example: Use `user` instead of `pg1-user`. ::: ''; example = lib.literalExpression '' { localhost.database = "app"; "postgres.example.com".port = "5433"; } ''; }; settings = lib.mkOption { type = lib.types.submodule { freeformType = settingsType; # The following options are not fully supported / tested, yet, but point to files with secrets. # Users can already set those options, but we'll force non-store paths. options.tls-server-cert-file = secretPathOption; options.tls-server-key-file = secretPathOption; }; default = { }; description = '' An attribute set of options as described in: All options can be used. Repository options should be set via [`repos`](#opt-services.pgbackrest.repos) instead. Stanza options should be set via [`instances`](#opt-services.pgbackrest.stanzas._name_.instances) instead. ''; example = lib.literalExpression '' { process-max = 2; } ''; }; }; }); default = { }; description = '' An attribute set of stanzas as described in: ''; }; settings = lib.mkOption { type = lib.types.submodule { freeformType = settingsType; # The following options are not fully supported / tested, yet, but point to files with secrets. # Users can already set those options, but we'll force non-store paths. options.tls-server-cert-file = secretPathOption; options.tls-server-key-file = secretPathOption; }; default = { }; description = '' An attribute set of options as described in: All globally available options, i.e. all except stanza options, can be used. Repository options should be set via [`repos`](#opt-services.pgbackrest.repos) instead. ''; example = lib.literalExpression '' { process-max = 2; } ''; }; }; config = lib.mkIf cfg.enable ( lib.mkMerge [ { services.pgbackrest.settings = { log-level-console = lib.mkDefault "info"; log-level-file = lib.mkDefault "off"; cmd-ssh = lib.getExe pkgs.openssh; }; environment.systemPackages = [ pkgs.pgbackrest ]; environment.etc."pgbackrest/pgbackrest.conf".source = settingsFormat.generate "pgbackrest.conf" fullConfig; users.users.pgbackrest = { name = "pgbackrest"; group = "pgbackrest"; description = "pgBackRest service user"; isSystemUser = true; useDefaultShell = true; createHome = true; home = cfg.repos.localhost.path or "/var/lib/pgbackrest"; }; users.groups.pgbackrest = { }; systemd.services = lib.mapAttrs ( _: { stanza, job, type, ... }: { description = "pgBackRest job ${job} for stanza ${stanza}"; serviceConfig = { User = "pgbackrest"; Group = "pgbackrest"; Type = "oneshot"; # stanza-create is idempotent, so safe to always run ExecStartPre = "${lib.getExe pkgs.pgbackrest} --stanza='${stanza}' stanza-create"; ExecStart = "${lib.getExe pkgs.pgbackrest} --stanza='${stanza}' backup --type='${type}'"; }; } ) namedJobs; systemd.timers = lib.mapAttrs ( name: { stanza, job, schedule, ... }: { description = "pgBackRest job ${job} for stanza ${stanza}"; wantedBy = [ "timers.target" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; timerConfig = { OnCalendar = schedule; Persistent = true; Unit = "${name}.service"; }; } ) namedJobs; } # The default stanza is set up for the local postgresql instance. # It does not backup automatically, the systemd timer still needs to be set. (lib.mkIf config.services.postgresql.enable { services.pgbackrest.stanzas.default = { settings.cmd = lib.getExe pkgs.pgbackrest; instances.localhost = { path = config.services.postgresql.dataDir; user = "postgres"; }; }; services.postgresql.identMap = '' postgres pgbackrest postgres ''; services.postgresql.initdbArgs = [ "--allow-group-access" ]; users.users.pgbackrest.extraGroups = [ "postgres" ]; services.postgresql.settings = { archive_command = ''${lib.getExe pkgs.pgbackrest} --stanza=default archive-push "%p"''; archive_mode = lib.mkDefault "on"; }; users.groups.pgbackrest.members = [ "postgres" ]; }) ] ); }