diff --git a/modules/default.nix b/modules/default.nix index 0d8d559..a9c29f2 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -16,6 +16,7 @@ let nix-builder = import ./nix-builder; nix-config-server = import ./nix-config/server.nix; nix-register-flakes = import ./nix-register-flakes; + patroni = import ./patroni; port-magic = import ./port-magic; shell-config = import ./shell-config; ssh = import ./ssh; diff --git a/modules/patroni/default.nix b/modules/patroni/default.nix new file mode 100644 index 0000000..746d539 --- /dev/null +++ b/modules/patroni/default.nix @@ -0,0 +1,275 @@ +{ config, lib, pkgs, ... }: +with lib; +let + cfg = config.services.patroni; + defaultUser = "patroni"; + defaultGroup = "patroni"; + format = pkgs.formats.yaml { }; + + #boto doesn't support python 3.10 yet + patroni = pkgs.patroni.override { pythonPackages = pkgs.python39Packages; }; + + postgresql = + if cfg.postgresqlExtraPlugins == [ ] + then cfg.postgresqlPackage + else cfg.postgresqlPackage.withPackages (_: cfg.postgresqlExtraPlugins); + + configFile = format.generate "patroni.yml" cfg.settings; +in +{ + options.services.patroni = { + + enable = mkEnableOption "Whether to enable Patroni"; + + postgresqlPackage = mkOption { + type = types.package; + example = literalExpression "pkgs.postgresql_11"; + description = mdDoc '' + PostgreSQL package to use. + ''; + }; + + postgresqlExtraPlugins = mkOption { + type = types.listOf types.path; + default = [ ]; + example = literalExpression "with pkgs.postgresql_11.pkgs; [ postgis pg_repack ]"; + description = mdDoc '' + List of PostgreSQL plugins. PostgreSQL version for each plugin should + match version for `services.postgresql.package` value. + ''; + }; + + postgresqlDataDir = mkOption { + type = types.path; + defaultText = literalExpression ''"/var/lib/postgresql/''${config.services.patroni.postgresqlPackage.psqlSchema}"''; + example = "/var/lib/postgresql/14"; + default = "/var/lib/postgresql/${postgresql.psqlSchema}"; + description = mdDoc '' + The data directory for PostgreSQL. If left as the default value + this directory will automatically be created before the PostgreSQL server starts, otherwise + the sysadmin is responsible for ensuring the directory exists with appropriate ownership + and permissions. + ''; + }; + + postgresqlPort = mkOption { + type = types.port; + default = 5432; + description = mdDoc '' + The port on which PostgreSQL listens. + ''; + }; + + user = mkOption { + type = types.str; + default = defaultUser; + example = "postgres"; + description = mdDoc '' + The user for the service. If left as the default value this user will automatically be created, + otherwise the sysadmin is responsible for ensuring the user exists. + ''; + }; + + group = mkOption { + type = types.str; + default = defaultGroup; + example = "postgres"; + description = mdDoc '' + The group for the service. If left as the default value this group will automatically be created, + otherwise the sysadmin is responsible for ensuring the group exists. + ''; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/patroni"; + description = mdDoc '' + Folder where Patroni data will be written, used by Raft as well if enabled. + ''; + }; + + scope = mkOption { + type = types.str; + example = "cluster1"; + description = mdDoc '' + Cluster name. + ''; + }; + + name = mkOption { + type = types.str; + example = "node1"; + description = mdDoc '' + The name of the host. Must be unique for the cluster. + ''; + }; + + namespace = mkOption { + type = types.str; + default = "/service"; + description = mdDoc '' + Path within the configuration store where Patroni will keep information about the cluster. + ''; + }; + + nodeIp = mkOption { + type = types.str; + example = "192.168.1.1"; + description = mdDoc '' + IP address of this node. + ''; + }; + + otherNodesIps = mkOption { + type = types.listOf types.string; + example = [ "192.168.1.2" "192.168.1.3" ]; + description = mdDoc '' + IP addresses of the other nodes. + ''; + }; + + restApiPort = mkOption { + type = types.port; + default = 8008; + description = mdDoc '' + The port on Patroni's REST api listens. + ''; + }; + + raft = mkOption { + type = types.bool; + default = false; + description = mdDoc '' + This will configure Patroni to use its own RAFT implementation instead of using a dedicated DCS. + ''; + }; + + raftPort = mkOption { + type = types.port; + default = 5010; + description = mdDoc '' + The port on which RAFT listens. + ''; + }; + + softwareWatchdog = mkOption { + type = types.bool; + default = false; + description = mdDoc '' + This will configure Patroni to use the software watchdog built into the Linux kernel + as described in the [documentation](https://patroni.readthedocs.io/en/latest/watchdog.html#setting-up-software-watchdog-on-linux). + ''; + }; + + settings = mkOption { + type = format.type; + default = { }; + description = mdDoc '' + The primary patroni configuration. See the [documentation](https://patroni.readthedocs.io/en/latest/SETTINGS.html) + for possible values. + Secrets should be passed in by using the `environmentFiles` option. + ''; + }; + + environmentFiles = mkOption { + type = with types; attrsOf (nullOr (oneOf [ str path package ])); + default = { }; + example = { + PATRONI_REPLICATION_PASSWORD = "/secret/file"; + PATRONI_SUPERUSER_PASSWORD = "/secret/file"; + }; + description = mdDoc "Environment variables made available to Patroni as files content, useful for providing secrets from files."; + }; + }; + + config = mkIf cfg.enable { + + services.patroni.settings = { + scope = cfg.scope; + name = cfg.name; + namespace = cfg.namespace; + + restapi = { + listen = "${cfg.nodeIp}:${toString(cfg.restApiPort)}"; + connect_address = "${cfg.nodeIp}:${toString(cfg.restApiPort)}"; + }; + + raft = mkIf cfg.raft { + data_dir = "${cfg.dataDir}/raft"; + self_addr = "${cfg.nodeIp}:5010"; + partner_addrs = map (ip: ip + ":5010") cfg.otherNodesIps; + }; + + postgresql = { + listen = "${cfg.nodeIp}:${toString(cfg.postgresqlPort)}"; + connect_address = "${cfg.nodeIp}:${toString(cfg.postgresqlPort)}"; + data_dir = cfg.postgresqlDataDir; + bin_dir = "${postgresql}/bin"; + pgpass = "${cfg.dataDir}/pgpass"; + }; + + watchdog = mkIf cfg.softwareWatchdog { + mode = "required"; + device = "/dev/watchdog"; + safety_margin = 5; + }; + }; + + + users = { + users = mkIf (cfg.user == defaultUser) { + patroni = { + group = cfg.group; + isSystemUser = true; + }; + }; + groups = mkIf (cfg.group == defaultGroup) { + patroni = { }; + }; + }; + + systemd.services = { + patroni = { + description = "Runners to orchestrate a high-availability PostgreSQL"; + + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + script = '' + ${concatStringsSep "\n" (attrValues (mapAttrs (name: path: "export ${name}=\"$(<'${path}')\"") cfg.environmentFiles))} + ${patroni}/bin/patroni ${configFile} + ''; + + serviceConfig = mkMerge [{ + User = cfg.user; + Group = cfg.group; + Type = "simple"; + Restart = "on-failure"; + TimeoutSec = 30; + ExecReload = "${pkgs.coreutils}/bin/kill -s HUP $MAINPID"; + KillMode = "process"; + } + (mkIf (cfg.postgresqlDataDir == "/var/lib/postgresql/${postgresql.psqlSchema}" && cfg.dataDir == "/var/lib/patroni") { + StateDirectory = "patroni patroni/raft postgresql postgresql/${postgresql.psqlSchema}"; + StateDirectoryMode = "0750"; + }) + (mkIf (cfg.softwareWatchdog) { + ExecStartPre = "+" + pkgs.writeShellScript "configure-software-watchdog.sh" '' + ${pkgs.kmod}/bin/modprobe softdog + ${pkgs.coreutils}/bin/chown ${cfg.user} /dev/watchdog + ''; + })]; + }; + }; + + environment.systemPackages = [ + patroni + postgresql + (mkIf cfg.raft pkgs.python310Packages.pysyncobj) + ]; + + environment.sessionVariables = { + PATRONICTL_CONFIG_FILE = "${configFile}"; + }; + }; +}