1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 inherit (lib)
10 any
11 attrNames
12 attrValues
13 count
14 escapeShellArg
15 filterAttrs
16 flatten
17 flip
18 getExe
19 hasAttr
20 hasInfix
21 listToAttrs
22 literalExpression
23 mapAttrsToList
24 mkEnableOption
25 mkPackageOption
26 mkIf
27 mkOption
28 nameValuePair
29 optional
30 subtractLists
31 types
32 unique
33 ;
34
35 format = pkgs.formats.json { };
36 cfg = config.services.influxdb2;
37 configFile = format.generate "config.json" cfg.settings;
38
39 validPermissions = [
40 "authorizations"
41 "buckets"
42 "dashboards"
43 "orgs"
44 "tasks"
45 "telegrafs"
46 "users"
47 "variables"
48 "secrets"
49 "labels"
50 "views"
51 "documents"
52 "notificationRules"
53 "notificationEndpoints"
54 "checks"
55 "dbrp"
56 "annotations"
57 "sources"
58 "scrapers"
59 "notebooks"
60 "remotes"
61 "replications"
62 ];
63
64 # Determines whether at least one active api token is defined
65 anyAuthDefined = flip any (attrValues cfg.provision.organizations) (
66 o: o.present && flip any (attrValues o.auths) (a: a.present && a.tokenFile != null)
67 );
68
69 provisionState = pkgs.writeText "provision_state.json" (
70 builtins.toJSON {
71 inherit (cfg.provision) organizations users;
72 }
73 );
74
75 influxHost = "http://${
76 escapeShellArg (
77 if
78 !hasAttr "http-bind-address" cfg.settings || hasInfix "0.0.0.0" cfg.settings.http-bind-address
79 then
80 "localhost:8086"
81 else
82 cfg.settings.http-bind-address
83 )
84 }";
85
86 waitUntilServiceIsReady = pkgs.writeShellScript "wait-until-service-is-ready" ''
87 set -euo pipefail
88 export INFLUX_HOST=${influxHost}
89 count=0
90 while ! influx ping &>/dev/null; do
91 if [ "$count" -eq 300 ]; then
92 echo "Tried for 30 seconds, giving up..."
93 exit 1
94 fi
95
96 if ! kill -0 "$MAINPID"; then
97 echo "Main server died, giving up..."
98 exit 1
99 fi
100
101 sleep 0.1
102 count=$((count++))
103 done
104 '';
105
106 provisioningScript = pkgs.writeShellScript "post-start-provision" ''
107 set -euo pipefail
108 export INFLUX_HOST=${influxHost}
109
110 # Do the initial database setup. Pass /dev/null as configs-path to
111 # avoid saving the token as the active config.
112 if test -e "$STATE_DIRECTORY/.first_startup"; then
113 influx setup \
114 --configs-path /dev/null \
115 --org ${escapeShellArg cfg.provision.initialSetup.organization} \
116 --bucket ${escapeShellArg cfg.provision.initialSetup.bucket} \
117 --username ${escapeShellArg cfg.provision.initialSetup.username} \
118 --password "$(< "$CREDENTIALS_DIRECTORY/admin-password")" \
119 --token "$(< "$CREDENTIALS_DIRECTORY/admin-token")" \
120 --retention ${toString cfg.provision.initialSetup.retention}s \
121 --force >/dev/null
122
123 rm -f "$STATE_DIRECTORY/.first_startup"
124 fi
125
126 provision_result=$(${getExe pkgs.influxdb2-provision} ${provisionState} "$INFLUX_HOST" "$(< "$CREDENTIALS_DIRECTORY/admin-token")")
127 if [[ "$(jq '[.auths[] | select(.action == "created")] | length' <<< "$provision_result")" -gt 0 ]]; then
128 echo "Created at least one new token, queueing service restart so we can manipulate secrets"
129 touch "$STATE_DIRECTORY/.needs_restart"
130 fi
131 '';
132
133 restarterScript = pkgs.writeShellScript "post-start-restarter" ''
134 set -euo pipefail
135 if test -e "$STATE_DIRECTORY/.needs_restart"; then
136 rm -f "$STATE_DIRECTORY/.needs_restart"
137 /run/current-system/systemd/bin/systemctl restart influxdb2
138 fi
139 '';
140
141 organizationSubmodule = types.submodule (
142 organizationSubmod:
143 let
144 org = organizationSubmod.config._module.args.name;
145 in
146 {
147 options = {
148 present = mkOption {
149 description = "Whether to ensure that this organization is present or absent.";
150 type = types.bool;
151 default = true;
152 };
153
154 description = mkOption {
155 description = "Optional description for the organization.";
156 default = null;
157 type = types.nullOr types.str;
158 };
159
160 buckets = mkOption {
161 description = "Buckets to provision in this organization.";
162 default = { };
163 type = types.attrsOf (
164 types.submodule (
165 bucketSubmod:
166 let
167 bucket = bucketSubmod.config._module.args.name;
168 in
169 {
170 options = {
171 present = mkOption {
172 description = "Whether to ensure that this bucket is present or absent.";
173 type = types.bool;
174 default = true;
175 };
176
177 description = mkOption {
178 description = "Optional description for the bucket.";
179 default = null;
180 type = types.nullOr types.str;
181 };
182
183 retention = mkOption {
184 type = types.ints.unsigned;
185 default = 0;
186 description = "The duration in seconds for which the bucket will retain data (0 is infinite).";
187 };
188 };
189 }
190 )
191 );
192 };
193
194 auths = mkOption {
195 description = "API tokens to provision for the user in this organization.";
196 default = { };
197 type = types.attrsOf (
198 types.submodule (
199 authSubmod:
200 let
201 auth = authSubmod.config._module.args.name;
202 in
203 {
204 options = {
205 id = mkOption {
206 description = "A unique identifier for this authentication token. Since influx doesn't store names for tokens, this will be hashed and appended to the description to identify the token.";
207 readOnly = true;
208 default = builtins.substring 0 32 (builtins.hashString "sha256" "${org}:${auth}");
209 defaultText = "<a hash derived from org and name>";
210 type = types.str;
211 };
212
213 present = mkOption {
214 description = "Whether to ensure that this user is present or absent.";
215 type = types.bool;
216 default = true;
217 };
218
219 description = mkOption {
220 description = ''
221 Optional description for the API token.
222 Note that the actual token will always be created with a descriptionregardless
223 of whether this is given or not. The name is always added plus a unique suffix
224 to later identify the token to track whether it has already been created.
225 '';
226 default = null;
227 type = types.nullOr types.str;
228 };
229
230 tokenFile = mkOption {
231 type = types.nullOr types.path;
232 default = null;
233 description = "The token value. If not given, influx will automatically generate one.";
234 };
235
236 operator = mkOption {
237 description = "Grants all permissions in all organizations.";
238 default = false;
239 type = types.bool;
240 };
241
242 allAccess = mkOption {
243 description = "Grants all permissions in the associated organization.";
244 default = false;
245 type = types.bool;
246 };
247
248 readPermissions = mkOption {
249 description = ''
250 The read permissions to include for this token. Access is usually granted only
251 for resources in the associated organization.
252
253 Available permissions are `authorizations`, `buckets`, `dashboards`,
254 `orgs`, `tasks`, `telegrafs`, `users`, `variables`, `secrets`, `labels`, `views`,
255 `documents`, `notificationRules`, `notificationEndpoints`, `checks`, `dbrp`,
256 `annotations`, `sources`, `scrapers`, `notebooks`, `remotes`, `replications`.
257
258 Refer to `influx auth create --help` for a full list with descriptions.
259
260 `buckets` grants read access to all associated buckets. Use `readBuckets` to define
261 more granular access permissions.
262 '';
263 default = [ ];
264 type = types.listOf (types.enum validPermissions);
265 };
266
267 writePermissions = mkOption {
268 description = ''
269 The read permissions to include for this token. Access is usually granted only
270 for resources in the associated organization.
271
272 Available permissions are `authorizations`, `buckets`, `dashboards`,
273 `orgs`, `tasks`, `telegrafs`, `users`, `variables`, `secrets`, `labels`, `views`,
274 `documents`, `notificationRules`, `notificationEndpoints`, `checks`, `dbrp`,
275 `annotations`, `sources`, `scrapers`, `notebooks`, `remotes`, `replications`.
276
277 Refer to `influx auth create --help` for a full list with descriptions.
278
279 `buckets` grants write access to all associated buckets. Use `writeBuckets` to define
280 more granular access permissions.
281 '';
282 default = [ ];
283 type = types.listOf (types.enum validPermissions);
284 };
285
286 readBuckets = mkOption {
287 description = "The organization's buckets which should be allowed to be read";
288 default = [ ];
289 type = types.listOf types.str;
290 };
291
292 writeBuckets = mkOption {
293 description = "The organization's buckets which should be allowed to be written";
294 default = [ ];
295 type = types.listOf types.str;
296 };
297 };
298 }
299 )
300 );
301 };
302 };
303 }
304 );
305in
306{
307 options = {
308 services.influxdb2 = {
309 enable = mkEnableOption "the influxdb2 server";
310
311 package = mkPackageOption pkgs "influxdb2" { };
312
313 settings = mkOption {
314 default = { };
315 description = ''configuration options for influxdb2, see <https://docs.influxdata.com/influxdb/v2.0/reference/config-options> for details.'';
316 type = format.type;
317 };
318
319 provision = {
320 enable = mkEnableOption "initial database setup and provisioning";
321
322 initialSetup = {
323 organization = mkOption {
324 type = types.str;
325 example = "main";
326 description = "Primary organization name";
327 };
328
329 bucket = mkOption {
330 type = types.str;
331 example = "example";
332 description = "Primary bucket name";
333 };
334
335 username = mkOption {
336 type = types.str;
337 default = "admin";
338 description = "Primary username";
339 };
340
341 retention = mkOption {
342 type = types.ints.unsigned;
343 default = 0;
344 description = "The duration in seconds for which the bucket will retain data (0 is infinite).";
345 };
346
347 passwordFile = mkOption {
348 type = types.path;
349 description = "Password for primary user. Don't use a file from the nix store!";
350 };
351
352 tokenFile = mkOption {
353 type = types.path;
354 description = "API Token to set for the admin user. Don't use a file from the nix store!";
355 };
356 };
357
358 organizations = mkOption {
359 description = "Organizations to provision.";
360 example = literalExpression ''
361 {
362 myorg = {
363 description = "My organization";
364 buckets.mybucket = {
365 description = "My bucket";
366 retention = 31536000; # 1 year
367 };
368 auths.mytoken = {
369 readBuckets = ["mybucket"];
370 tokenFile = "/run/secrets/mytoken";
371 };
372 };
373 }
374 '';
375 default = { };
376 type = types.attrsOf organizationSubmodule;
377 };
378
379 users = mkOption {
380 description = "Users to provision.";
381 default = { };
382 example = literalExpression ''
383 {
384 # admin = {}; /* The initialSetup.username will automatically be added. */
385 myuser.passwordFile = "/run/secrets/myuser_password";
386 }
387 '';
388 type = types.attrsOf (
389 types.submodule (
390 userSubmod:
391 let
392 user = userSubmod.config._module.args.name;
393 org = userSubmod.config.org;
394 in
395 {
396 options = {
397 present = mkOption {
398 description = "Whether to ensure that this user is present or absent.";
399 type = types.bool;
400 default = true;
401 };
402
403 passwordFile = mkOption {
404 description = "Password for the user. If unset, the user will not be able to log in until a password is set by an operator! Don't use a file from the nix store!";
405 default = null;
406 type = types.nullOr types.path;
407 };
408 };
409 }
410 )
411 );
412 };
413 };
414 };
415 };
416
417 config = mkIf cfg.enable {
418 assertions =
419 [
420 {
421 assertion = !(hasAttr "bolt-path" cfg.settings) && !(hasAttr "engine-path" cfg.settings);
422 message = "services.influxdb2.config: bolt-path and engine-path should not be set as they are managed by systemd";
423 }
424 ]
425 ++ flatten (
426 flip mapAttrsToList cfg.provision.organizations (
427 orgName: org:
428 flip mapAttrsToList org.auths (
429 authName: auth: [
430 {
431 assertion =
432 1 == count (x: x) [
433 auth.operator
434 auth.allAccess
435 (
436 auth.readPermissions != [ ]
437 || auth.writePermissions != [ ]
438 || auth.readBuckets != [ ]
439 || auth.writeBuckets != [ ]
440 )
441 ];
442 message = "influxdb2: provision.organizations.${orgName}.auths.${authName}: The `operator` and `allAccess` options are mutually exclusive with each other and the granular permission settings.";
443 }
444 (
445 let
446 unknownBuckets = subtractLists (attrNames org.buckets) auth.readBuckets;
447 in
448 {
449 assertion = unknownBuckets == [ ];
450 message = "influxdb2: provision.organizations.${orgName}.auths.${authName}: Refers to invalid buckets in readBuckets: ${toString unknownBuckets}";
451 }
452 )
453 (
454 let
455 unknownBuckets = subtractLists (attrNames org.buckets) auth.writeBuckets;
456 in
457 {
458 assertion = unknownBuckets == [ ];
459 message = "influxdb2: provision.organizations.${orgName}.auths.${authName}: Refers to invalid buckets in writeBuckets: ${toString unknownBuckets}";
460 }
461 )
462 ]
463 )
464 )
465 );
466
467 services.influxdb2.provision = mkIf cfg.provision.enable {
468 organizations.${cfg.provision.initialSetup.organization} = {
469 buckets.${cfg.provision.initialSetup.bucket} = {
470 inherit (cfg.provision.initialSetup) retention;
471 };
472 };
473 users.${cfg.provision.initialSetup.username} = {
474 inherit (cfg.provision.initialSetup) passwordFile;
475 };
476 };
477
478 systemd.services.influxdb2 = {
479 description = "InfluxDB is an open-source, distributed, time series database";
480 documentation = [ "https://docs.influxdata.com/influxdb/" ];
481 wantedBy = [ "multi-user.target" ];
482 after = [ "network.target" ];
483 environment = {
484 INFLUXD_CONFIG_PATH = configFile;
485 ZONEINFO = "${pkgs.tzdata}/share/zoneinfo";
486 };
487 serviceConfig = {
488 Type = "exec"; # When credentials are used with systemd before v257 this is necessary to make the service start reliably (see systemd/systemd#33953)
489 ExecStart = "${cfg.package}/bin/influxd --bolt-path \${STATE_DIRECTORY}/influxd.bolt --engine-path \${STATE_DIRECTORY}/engine";
490 StateDirectory = "influxdb2";
491 User = "influxdb2";
492 Group = "influxdb2";
493 CapabilityBoundingSet = "";
494 SystemCallFilter = "@system-service";
495 LimitNOFILE = 65536;
496 KillMode = "control-group";
497 Restart = "on-failure";
498 LoadCredential = mkIf cfg.provision.enable [
499 "admin-password:${cfg.provision.initialSetup.passwordFile}"
500 "admin-token:${cfg.provision.initialSetup.tokenFile}"
501 ];
502
503 ExecStartPost =
504 [
505 waitUntilServiceIsReady
506 ]
507 ++ (lib.optionals cfg.provision.enable (
508 [ provisioningScript ]
509 ++
510 # Only the restarter runs with elevated privileges
511 optional anyAuthDefined "+${restarterScript}"
512 ));
513 };
514
515 path = [
516 pkgs.influxdb2-cli
517 pkgs.jq
518 ];
519
520 # Mark if this is the first startup so postStart can do the initial setup.
521 # Also extract any token secret mappings and apply them if this isn't the first start.
522 preStart =
523 let
524 tokenPaths = listToAttrs (
525 flatten
526 # For all organizations
527 (
528 flip mapAttrsToList cfg.provision.organizations
529 # For each contained token that has a token file
530 (
531 _: org:
532 flip mapAttrsToList (filterAttrs (_: x: x.tokenFile != null) org.auths)
533 # Collect id -> tokenFile for the mapping
534 (_: auth: nameValuePair auth.id auth.tokenFile)
535 )
536 )
537 );
538 tokenMappings = pkgs.writeText "token_mappings.json" (builtins.toJSON tokenPaths);
539 in
540 mkIf cfg.provision.enable ''
541 if ! test -e "$STATE_DIRECTORY/influxd.bolt"; then
542 touch "$STATE_DIRECTORY/.first_startup"
543 else
544 # Manipulate provisioned api tokens if necessary
545 ${getExe pkgs.influxdb2-token-manipulator} "$STATE_DIRECTORY/influxd.bolt" ${tokenMappings}
546 fi
547 '';
548 };
549
550 users.extraUsers.influxdb2 = {
551 isSystemUser = true;
552 group = "influxdb2";
553 };
554
555 users.extraGroups.influxdb2 = { };
556 };
557
558 meta.maintainers = with lib.maintainers; [
559 nickcao
560 oddlama
561 ];
562}