1{ config, lib, pkgs, ... }:
2with lib;
3let
4 cfg = config.services.patroni;
5 defaultUser = "patroni";
6 defaultGroup = "patroni";
7 format = pkgs.formats.yaml { };
8
9 #boto doesn't support python 3.10 yet
10 patroni = pkgs.patroni.override { pythonPackages = pkgs.python39Packages; };
11
12 configFileName = "patroni-${cfg.scope}-${cfg.name}.yaml";
13 configFile = format.generate configFileName cfg.settings;
14in
15{
16 options.services.patroni = {
17
18 enable = mkEnableOption (lib.mdDoc "Patroni");
19
20 postgresqlPackage = mkOption {
21 type = types.package;
22 example = literalExpression "pkgs.postgresql_14";
23 description = mdDoc ''
24 PostgreSQL package to use.
25 Plugins can be enabled like this `pkgs.postgresql_14.withPackages (p: [ p.pg_safeupdate p.postgis ])`.
26 '';
27 };
28
29 postgresqlDataDir = mkOption {
30 type = types.path;
31 defaultText = literalExpression ''"/var/lib/postgresql/''${config.services.patroni.postgresqlPackage.psqlSchema}"'';
32 example = "/var/lib/postgresql/14";
33 default = "/var/lib/postgresql/${cfg.postgresqlPackage.psqlSchema}";
34 description = mdDoc ''
35 The data directory for PostgreSQL. If left as the default value
36 this directory will automatically be created before the PostgreSQL server starts, otherwise
37 the sysadmin is responsible for ensuring the directory exists with appropriate ownership
38 and permissions.
39 '';
40 };
41
42 postgresqlPort = mkOption {
43 type = types.port;
44 default = 5432;
45 description = mdDoc ''
46 The port on which PostgreSQL listens.
47 '';
48 };
49
50 user = mkOption {
51 type = types.str;
52 default = defaultUser;
53 example = "postgres";
54 description = mdDoc ''
55 The user for the service. If left as the default value this user will automatically be created,
56 otherwise the sysadmin is responsible for ensuring the user exists.
57 '';
58 };
59
60 group = mkOption {
61 type = types.str;
62 default = defaultGroup;
63 example = "postgres";
64 description = mdDoc ''
65 The group for the service. If left as the default value this group will automatically be created,
66 otherwise the sysadmin is responsible for ensuring the group exists.
67 '';
68 };
69
70 dataDir = mkOption {
71 type = types.path;
72 default = "/var/lib/patroni";
73 description = mdDoc ''
74 Folder where Patroni data will be written, used by Raft as well if enabled.
75 '';
76 };
77
78 scope = mkOption {
79 type = types.str;
80 example = "cluster1";
81 description = mdDoc ''
82 Cluster name.
83 '';
84 };
85
86 name = mkOption {
87 type = types.str;
88 example = "node1";
89 description = mdDoc ''
90 The name of the host. Must be unique for the cluster.
91 '';
92 };
93
94 namespace = mkOption {
95 type = types.str;
96 default = "/service";
97 description = mdDoc ''
98 Path within the configuration store where Patroni will keep information about the cluster.
99 '';
100 };
101
102 nodeIp = mkOption {
103 type = types.str;
104 example = "192.168.1.1";
105 description = mdDoc ''
106 IP address of this node.
107 '';
108 };
109
110 otherNodesIps = mkOption {
111 type = types.listOf types.string;
112 example = [ "192.168.1.2" "192.168.1.3" ];
113 description = mdDoc ''
114 IP addresses of the other nodes.
115 '';
116 };
117
118 restApiPort = mkOption {
119 type = types.port;
120 default = 8008;
121 description = mdDoc ''
122 The port on Patroni's REST api listens.
123 '';
124 };
125
126 raft = mkOption {
127 type = types.bool;
128 default = false;
129 description = mdDoc ''
130 This will configure Patroni to use its own RAFT implementation instead of using a dedicated DCS.
131 '';
132 };
133
134 raftPort = mkOption {
135 type = types.port;
136 default = 5010;
137 description = mdDoc ''
138 The port on which RAFT listens.
139 '';
140 };
141
142 softwareWatchdog = mkOption {
143 type = types.bool;
144 default = false;
145 description = mdDoc ''
146 This will configure Patroni to use the software watchdog built into the Linux kernel
147 as described in the [documentation](https://patroni.readthedocs.io/en/latest/watchdog.html#setting-up-software-watchdog-on-linux).
148 '';
149 };
150
151 settings = mkOption {
152 type = format.type;
153 default = { };
154 description = mdDoc ''
155 The primary patroni configuration. See the [documentation](https://patroni.readthedocs.io/en/latest/SETTINGS.html)
156 for possible values.
157 Secrets should be passed in by using the `environmentFiles` option.
158 '';
159 };
160
161 environmentFiles = mkOption {
162 type = with types; attrsOf (nullOr (oneOf [ str path package ]));
163 default = { };
164 example = {
165 PATRONI_REPLICATION_PASSWORD = "/secret/file";
166 PATRONI_SUPERUSER_PASSWORD = "/secret/file";
167 };
168 description = mdDoc "Environment variables made available to Patroni as files content, useful for providing secrets from files.";
169 };
170 };
171
172 config = mkIf cfg.enable {
173
174 services.patroni.settings = {
175 scope = cfg.scope;
176 name = cfg.name;
177 namespace = cfg.namespace;
178
179 restapi = {
180 listen = "${cfg.nodeIp}:${toString cfg.restApiPort}";
181 connect_address = "${cfg.nodeIp}:${toString cfg.restApiPort}";
182 };
183
184 raft = mkIf cfg.raft {
185 data_dir = "${cfg.dataDir}/raft";
186 self_addr = "${cfg.nodeIp}:5010";
187 partner_addrs = map (ip: ip + ":5010") cfg.otherNodesIps;
188 };
189
190 postgresql = {
191 listen = "${cfg.nodeIp}:${toString cfg.postgresqlPort}";
192 connect_address = "${cfg.nodeIp}:${toString cfg.postgresqlPort}";
193 data_dir = cfg.postgresqlDataDir;
194 bin_dir = "${cfg.postgresqlPackage}/bin";
195 pgpass = "${cfg.dataDir}/pgpass";
196 };
197
198 watchdog = mkIf cfg.softwareWatchdog {
199 mode = "required";
200 device = "/dev/watchdog";
201 safety_margin = 5;
202 };
203 };
204
205
206 users = {
207 users = mkIf (cfg.user == defaultUser) {
208 patroni = {
209 group = cfg.group;
210 isSystemUser = true;
211 };
212 };
213 groups = mkIf (cfg.group == defaultGroup) {
214 patroni = { };
215 };
216 };
217
218 systemd.services = {
219 patroni = {
220 description = "Runners to orchestrate a high-availability PostgreSQL";
221
222 wantedBy = [ "multi-user.target" ];
223 after = [ "network.target" ];
224
225 script = ''
226 ${concatStringsSep "\n" (attrValues (mapAttrs (name: path: ''export ${name}="$(< ${escapeShellArg path})"'') cfg.environmentFiles))}
227 exec ${patroni}/bin/patroni ${configFile}
228 '';
229
230 serviceConfig = mkMerge [
231 {
232 User = cfg.user;
233 Group = cfg.group;
234 Type = "simple";
235 Restart = "on-failure";
236 TimeoutSec = 30;
237 ExecReload = "${pkgs.coreutils}/bin/kill -s HUP $MAINPID";
238 KillMode = "process";
239 }
240 (mkIf (cfg.postgresqlDataDir == "/var/lib/postgresql/${cfg.postgresqlPackage.psqlSchema}" && cfg.dataDir == "/var/lib/patroni") {
241 StateDirectory = "patroni patroni/raft postgresql postgresql/${cfg.postgresqlPackage.psqlSchema}";
242 StateDirectoryMode = "0750";
243 })
244 ];
245 };
246 };
247
248 boot.kernelModules = mkIf cfg.softwareWatchdog [ "softdog" ];
249
250 services.udev.extraRules = mkIf cfg.softwareWatchdog ''
251 KERNEL=="watchdog", OWNER="${cfg.user}", GROUP="${cfg.group}", MODE="0600"
252 '';
253
254 environment.systemPackages = [
255 patroni
256 cfg.postgresqlPackage
257 (mkIf cfg.raft pkgs.python310Packages.pysyncobj)
258 ];
259
260 environment.etc."${configFileName}".source = configFile;
261
262 environment.sessionVariables = {
263 PATRONICTL_CONFIG_FILE = "/etc/${configFileName}";
264 };
265 };
266
267 meta.maintainers = [ maintainers.phfroidmont ];
268}