1{ config, lib, pkgs, ... }:
2
3let
4 inherit (lib)
5 concatStringsSep
6 flip
7 literalMD
8 literalExpression
9 optionalAttrs
10 optionals
11 recursiveUpdate
12 mdDoc
13 mkEnableOption
14 mkIf
15 mkOption
16 types
17 versionAtLeast
18 ;
19
20 cfg = config.services.cassandra;
21
22 atLeast3 = versionAtLeast cfg.package.version "3";
23 atLeast3_11 = versionAtLeast cfg.package.version "3.11";
24 atLeast4 = versionAtLeast cfg.package.version "4";
25
26 defaultUser = "cassandra";
27
28 cassandraConfig = flip recursiveUpdate cfg.extraConfig (
29 {
30 commitlog_sync = "batch";
31 commitlog_sync_batch_window_in_ms = 2;
32 start_native_transport = cfg.allowClients;
33 cluster_name = cfg.clusterName;
34 partitioner = "org.apache.cassandra.dht.Murmur3Partitioner";
35 endpoint_snitch = "SimpleSnitch";
36 data_file_directories = [ "${cfg.homeDir}/data" ];
37 commitlog_directory = "${cfg.homeDir}/commitlog";
38 saved_caches_directory = "${cfg.homeDir}/saved_caches";
39 } // optionalAttrs (cfg.seedAddresses != [ ]) {
40 seed_provider = [
41 {
42 class_name = "org.apache.cassandra.locator.SimpleSeedProvider";
43 parameters = [{ seeds = concatStringsSep "," cfg.seedAddresses; }];
44 }
45 ];
46 } // optionalAttrs atLeast3 {
47 hints_directory = "${cfg.homeDir}/hints";
48 }
49 );
50
51 cassandraConfigWithAddresses = cassandraConfig // (
52 if cfg.listenAddress == null
53 then { listen_interface = cfg.listenInterface; }
54 else { listen_address = cfg.listenAddress; }
55 ) // (
56 if cfg.rpcAddress == null
57 then { rpc_interface = cfg.rpcInterface; }
58 else { rpc_address = cfg.rpcAddress; }
59 );
60
61 cassandraEtc = pkgs.stdenv.mkDerivation {
62 name = "cassandra-etc";
63
64 cassandraYaml = builtins.toJSON cassandraConfigWithAddresses;
65 cassandraEnvPkg = "${cfg.package}/conf/cassandra-env.sh";
66 cassandraLogbackConfig = pkgs.writeText "logback.xml" cfg.logbackConfig;
67
68 passAsFile = [ "extraEnvSh" ];
69 inherit (cfg) extraEnvSh package;
70
71 buildCommand = ''
72 mkdir -p "$out"
73
74 echo "$cassandraYaml" > "$out/cassandra.yaml"
75 ln -s "$cassandraLogbackConfig" "$out/logback.xml"
76
77 ( cat "$cassandraEnvPkg"
78 echo "# lines from services.cassandra.extraEnvSh: "
79 cat "$extraEnvShPath"
80 ) > "$out/cassandra-env.sh"
81
82 # Delete default JMX Port, otherwise we can't set it using env variable
83 sed -i '/JMX_PORT="7199"/d' "$out/cassandra-env.sh"
84
85 # Delete default password file
86 sed -i '/-Dcom.sun.management.jmxremote.password.file=\/etc\/cassandra\/jmxremote.password/d' "$out/cassandra-env.sh"
87
88 ${lib.optionalString atLeast4 ''
89 cp $package/conf/jvm*.options $out/
90 ''}
91 '';
92 };
93
94 defaultJmxRolesFile =
95 builtins.foldl'
96 (left: right: left + right) ""
97 (map (role: "${role.username} ${role.password}") cfg.jmxRoles);
98
99 fullJvmOptions =
100 cfg.jvmOpts
101 ++ optionals (cfg.jmxRoles != [ ]) [
102 "-Dcom.sun.management.jmxremote.authenticate=true"
103 "-Dcom.sun.management.jmxremote.password.file=${cfg.jmxRolesFile}"
104 ] ++ optionals cfg.remoteJmx [
105 "-Djava.rmi.server.hostname=${cfg.rpcAddress}"
106 ] ++ optionals atLeast4 [
107 # Historically, we don't use a log dir, whereas the upstream scripts do
108 # expect this. We override those by providing our own -Xlog:gc flag.
109 "-Xlog:gc=warning,heap*=warning,age*=warning,safepoint=warning,promotion*=warning"
110 ];
111
112 commonEnv = {
113 # Sufficient for cassandra 2.x, 3.x
114 CASSANDRA_CONF = "${cassandraEtc}";
115
116 # Required since cassandra 4
117 CASSANDRA_LOGBACK_CONF = "${cassandraEtc}/logback.xml";
118 };
119
120in
121{
122 options.services.cassandra = {
123
124 enable = mkEnableOption (lib.mdDoc ''
125 Apache Cassandra – Scalable and highly available database.
126 '');
127
128 clusterName = mkOption {
129 type = types.str;
130 default = "Test Cluster";
131 description = mdDoc ''
132 The name of the cluster.
133 This setting prevents nodes in one logical cluster from joining
134 another. All nodes in a cluster must have the same value.
135 '';
136 };
137
138 user = mkOption {
139 type = types.str;
140 default = defaultUser;
141 description = mdDoc "Run Apache Cassandra under this user.";
142 };
143
144 group = mkOption {
145 type = types.str;
146 default = defaultUser;
147 description = mdDoc "Run Apache Cassandra under this group.";
148 };
149
150 homeDir = mkOption {
151 type = types.path;
152 default = "/var/lib/cassandra";
153 description = mdDoc ''
154 Home directory for Apache Cassandra.
155 '';
156 };
157
158 package = mkOption {
159 type = types.package;
160 default = pkgs.cassandra;
161 defaultText = literalExpression "pkgs.cassandra";
162 example = literalExpression "pkgs.cassandra_3_11";
163 description = mdDoc ''
164 The Apache Cassandra package to use.
165 '';
166 };
167
168 jvmOpts = mkOption {
169 type = types.listOf types.str;
170 default = [ ];
171 description = mdDoc ''
172 Populate the `JVM_OPT` environment variable.
173 '';
174 };
175
176 listenAddress = mkOption {
177 type = types.nullOr types.str;
178 default = "127.0.0.1";
179 example = null;
180 description = mdDoc ''
181 Address or interface to bind to and tell other Cassandra nodes
182 to connect to. You _must_ change this if you want multiple
183 nodes to be able to communicate!
184
185 Set {option}`listenAddress` OR {option}`listenInterface`, not both.
186
187 Leaving it blank leaves it up to
188 `InetAddress.getLocalHost()`. This will always do the "Right
189 Thing" _if_ the node is properly configured (hostname, name
190 resolution, etc), and the Right Thing is to use the address
191 associated with the hostname (it might not be).
192
193 Setting {option}`listenAddress` to `0.0.0.0` is always wrong.
194 '';
195 };
196
197 listenInterface = mkOption {
198 type = types.nullOr types.str;
199 default = null;
200 example = "eth1";
201 description = mdDoc ''
202 Set `listenAddress` OR `listenInterface`, not both. Interfaces
203 must correspond to a single address, IP aliasing is not
204 supported.
205 '';
206 };
207
208 rpcAddress = mkOption {
209 type = types.nullOr types.str;
210 default = "127.0.0.1";
211 example = null;
212 description = mdDoc ''
213 The address or interface to bind the native transport server to.
214
215 Set {option}`rpcAddress` OR {option}`rpcInterface`, not both.
216
217 Leaving {option}`rpcAddress` blank has the same effect as on
218 {option}`listenAddress` (i.e. it will be based on the configured hostname
219 of the node).
220
221 Note that unlike {option}`listenAddress`, you can specify `"0.0.0.0"`, but you
222 must also set `extraConfig.broadcast_rpc_address` to a value other
223 than `"0.0.0.0"`.
224
225 For security reasons, you should not expose this port to the
226 internet. Firewall it if needed.
227 '';
228 };
229
230 rpcInterface = mkOption {
231 type = types.nullOr types.str;
232 default = null;
233 example = "eth1";
234 description = mdDoc ''
235 Set {option}`rpcAddress` OR {option}`rpcInterface`, not both. Interfaces must
236 correspond to a single address, IP aliasing is not supported.
237 '';
238 };
239
240 logbackConfig = mkOption {
241 type = types.lines;
242 default = ''
243 <configuration scan="false">
244 <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
245 <encoder>
246 <pattern>%-5level %date{HH:mm:ss,SSS} %msg%n</pattern>
247 </encoder>
248 </appender>
249
250 <root level="INFO">
251 <appender-ref ref="STDOUT" />
252 </root>
253
254 <logger name="com.thinkaurelius.thrift" level="ERROR"/>
255 </configuration>
256 '';
257 description = mdDoc ''
258 XML logback configuration for cassandra
259 '';
260 };
261
262 seedAddresses = mkOption {
263 type = types.listOf types.str;
264 default = [ "127.0.0.1" ];
265 description = mdDoc ''
266 The addresses of hosts designated as contact points in the cluster. A
267 joining node contacts one of the nodes in the seeds list to learn the
268 topology of the ring.
269 Set to `[ "127.0.0.1" ]` for a single node cluster.
270 '';
271 };
272
273 allowClients = mkOption {
274 type = types.bool;
275 default = true;
276 description = mdDoc ''
277 Enables or disables the native transport server (CQL binary protocol).
278 This server uses the same address as the {option}`rpcAddress`,
279 but the port it uses is not `rpc_port` but
280 `native_transport_port`. See the official Cassandra
281 docs for more information on these variables and set them using
282 {option}`extraConfig`.
283 '';
284 };
285
286 extraConfig = mkOption {
287 type = types.attrs;
288 default = { };
289 example =
290 {
291 commitlog_sync_batch_window_in_ms = 3;
292 };
293 description = mdDoc ''
294 Extra options to be merged into {file}`cassandra.yaml` as nix attribute set.
295 '';
296 };
297
298 extraEnvSh = mkOption {
299 type = types.lines;
300 default = "";
301 example = literalExpression ''"CLASSPATH=$CLASSPATH:''${extraJar}"'';
302 description = mdDoc ''
303 Extra shell lines to be appended onto {file}`cassandra-env.sh`.
304 '';
305 };
306
307 fullRepairInterval = mkOption {
308 type = types.nullOr types.str;
309 default = "3w";
310 example = null;
311 description = mdDoc ''
312 Set the interval how often full repairs are run, i.e.
313 {command}`nodetool repair --full` is executed. See
314 <https://cassandra.apache.org/doc/latest/operating/repair.html>
315 for more information.
316
317 Set to `null` to disable full repairs.
318 '';
319 };
320
321 fullRepairOptions = mkOption {
322 type = types.listOf types.str;
323 default = [ ];
324 example = [ "--partitioner-range" ];
325 description = mdDoc ''
326 Options passed through to the full repair command.
327 '';
328 };
329
330 incrementalRepairInterval = mkOption {
331 type = types.nullOr types.str;
332 default = "3d";
333 example = null;
334 description = mdDoc ''
335 Set the interval how often incremental repairs are run, i.e.
336 {command}`nodetool repair` is executed. See
337 <https://cassandra.apache.org/doc/latest/operating/repair.html>
338 for more information.
339
340 Set to `null` to disable incremental repairs.
341 '';
342 };
343
344 incrementalRepairOptions = mkOption {
345 type = types.listOf types.str;
346 default = [ ];
347 example = [ "--partitioner-range" ];
348 description = mdDoc ''
349 Options passed through to the incremental repair command.
350 '';
351 };
352
353 maxHeapSize = mkOption {
354 type = types.nullOr types.str;
355 default = null;
356 example = "4G";
357 description = mdDoc ''
358 Must be left blank or set together with {option}`heapNewSize`.
359 If left blank a sensible value for the available amount of RAM and CPU
360 cores is calculated.
361
362 Override to set the amount of memory to allocate to the JVM at
363 start-up. For production use you may wish to adjust this for your
364 environment. `MAX_HEAP_SIZE` is the total amount of memory dedicated
365 to the Java heap. `HEAP_NEWSIZE` refers to the size of the young
366 generation.
367
368 The main trade-off for the young generation is that the larger it
369 is, the longer GC pause times will be. The shorter it is, the more
370 expensive GC will be (usually).
371 '';
372 };
373
374 heapNewSize = mkOption {
375 type = types.nullOr types.str;
376 default = null;
377 example = "800M";
378 description = mdDoc ''
379 Must be left blank or set together with {option}`heapNewSize`.
380 If left blank a sensible value for the available amount of RAM and CPU
381 cores is calculated.
382
383 Override to set the amount of memory to allocate to the JVM at
384 start-up. For production use you may wish to adjust this for your
385 environment. `HEAP_NEWSIZE` refers to the size of the young
386 generation.
387
388 The main trade-off for the young generation is that the larger it
389 is, the longer GC pause times will be. The shorter it is, the more
390 expensive GC will be (usually).
391
392 The example `HEAP_NEWSIZE` assumes a modern 8-core+ machine for decent pause
393 times. If in doubt, and if you do not particularly want to tweak, go with
394 100 MB per physical CPU core.
395 '';
396 };
397
398 mallocArenaMax = mkOption {
399 type = types.nullOr types.int;
400 default = null;
401 example = 4;
402 description = mdDoc ''
403 Set this to control the amount of arenas per-thread in glibc.
404 '';
405 };
406
407 remoteJmx = mkOption {
408 type = types.bool;
409 default = false;
410 description = mdDoc ''
411 Cassandra ships with JMX accessible *only* from localhost.
412 To enable remote JMX connections set to true.
413
414 Be sure to also enable authentication and/or TLS.
415 See: <https://wiki.apache.org/cassandra/JmxSecurity>
416 '';
417 };
418
419 jmxPort = mkOption {
420 type = types.int;
421 default = 7199;
422 description = mdDoc ''
423 Specifies the default port over which Cassandra will be available for
424 JMX connections.
425 For security reasons, you should not expose this port to the internet.
426 Firewall it if needed.
427 '';
428 };
429
430 jmxRoles = mkOption {
431 default = [ ];
432 description = mdDoc ''
433 Roles that are allowed to access the JMX (e.g. {command}`nodetool`)
434 BEWARE: The passwords will be stored world readable in the nix store.
435 It's recommended to use your own protected file using
436 {option}`jmxRolesFile`
437
438 Doesn't work in versions older than 3.11 because they don't like that
439 it's world readable.
440 '';
441 type = types.listOf (types.submodule {
442 options = {
443 username = mkOption {
444 type = types.str;
445 description = lib.mdDoc "Username for JMX";
446 };
447 password = mkOption {
448 type = types.str;
449 description = lib.mdDoc "Password for JMX";
450 };
451 };
452 });
453 };
454
455 jmxRolesFile = mkOption {
456 type = types.nullOr types.path;
457 default =
458 if atLeast3_11
459 then pkgs.writeText "jmx-roles-file" defaultJmxRolesFile
460 else null;
461 defaultText = literalMD ''generated configuration file if version is at least 3.11, otherwise `null`'';
462 example = "/var/lib/cassandra/jmx.password";
463 description = lib.mdDoc ''
464 Specify your own jmx roles file.
465
466 Make sure the permissions forbid "others" from reading the file if
467 you're using Cassandra below version 3.11.
468 '';
469 };
470 };
471
472 config = mkIf cfg.enable {
473 assertions = [
474 {
475 assertion = (cfg.listenAddress == null) != (cfg.listenInterface == null);
476 message = "You have to set either listenAddress or listenInterface";
477 }
478 {
479 assertion = (cfg.rpcAddress == null) != (cfg.rpcInterface == null);
480 message = "You have to set either rpcAddress or rpcInterface";
481 }
482 {
483 assertion = (cfg.maxHeapSize == null) == (cfg.heapNewSize == null);
484 message = "If you set either of maxHeapSize or heapNewSize you have to set both";
485 }
486 {
487 assertion = cfg.remoteJmx -> cfg.jmxRolesFile != null;
488 message = ''
489 If you want JMX available remotely you need to set a password using
490 <literal>jmxRoles</literal> or <literal>jmxRolesFile</literal> if
491 using Cassandra older than v3.11.
492 '';
493 }
494 ];
495 users = mkIf (cfg.user == defaultUser) {
496 users.${defaultUser} = {
497 group = cfg.group;
498 home = cfg.homeDir;
499 createHome = true;
500 uid = config.ids.uids.cassandra;
501 description = "Cassandra service user";
502 };
503 groups.${defaultUser}.gid = config.ids.gids.cassandra;
504 };
505
506 systemd.services.cassandra = {
507 description = "Apache Cassandra service";
508 after = [ "network.target" ];
509 environment = commonEnv // {
510 JVM_OPTS = builtins.concatStringsSep " " fullJvmOptions;
511 MAX_HEAP_SIZE = toString cfg.maxHeapSize;
512 HEAP_NEWSIZE = toString cfg.heapNewSize;
513 MALLOC_ARENA_MAX = toString cfg.mallocArenaMax;
514 LOCAL_JMX = if cfg.remoteJmx then "no" else "yes";
515 JMX_PORT = toString cfg.jmxPort;
516 };
517 wantedBy = [ "multi-user.target" ];
518 serviceConfig = {
519 User = cfg.user;
520 Group = cfg.group;
521 ExecStart = "${cfg.package}/bin/cassandra -f";
522 SuccessExitStatus = 143;
523 };
524 };
525
526 systemd.services.cassandra-full-repair = {
527 description = "Perform a full repair on this Cassandra node";
528 after = [ "cassandra.service" ];
529 requires = [ "cassandra.service" ];
530 environment = commonEnv;
531 serviceConfig = {
532 User = cfg.user;
533 Group = cfg.group;
534 ExecStart =
535 concatStringsSep " "
536 ([
537 "${cfg.package}/bin/nodetool"
538 "repair"
539 "--full"
540 ] ++ cfg.fullRepairOptions);
541 };
542 };
543
544 systemd.timers.cassandra-full-repair =
545 mkIf (cfg.fullRepairInterval != null) {
546 description = "Schedule full repairs on Cassandra";
547 wantedBy = [ "timers.target" ];
548 timerConfig = {
549 OnBootSec = cfg.fullRepairInterval;
550 OnUnitActiveSec = cfg.fullRepairInterval;
551 Persistent = true;
552 };
553 };
554
555 systemd.services.cassandra-incremental-repair = {
556 description = "Perform an incremental repair on this cassandra node.";
557 after = [ "cassandra.service" ];
558 requires = [ "cassandra.service" ];
559 environment = commonEnv;
560 serviceConfig = {
561 User = cfg.user;
562 Group = cfg.group;
563 ExecStart =
564 concatStringsSep " "
565 ([
566 "${cfg.package}/bin/nodetool"
567 "repair"
568 ] ++ cfg.incrementalRepairOptions);
569 };
570 };
571
572 systemd.timers.cassandra-incremental-repair =
573 mkIf (cfg.incrementalRepairInterval != null) {
574 description = "Schedule incremental repairs on Cassandra";
575 wantedBy = [ "timers.target" ];
576 timerConfig = {
577 OnBootSec = cfg.incrementalRepairInterval;
578 OnUnitActiveSec = cfg.incrementalRepairInterval;
579 Persistent = true;
580 };
581 };
582 };
583
584 meta.maintainers = with lib.maintainers; [ roberth ];
585}