1{
2 config,
3 pkgs,
4 lib,
5 ...
6}:
7let
8
9 cfg = config.services.hydra;
10
11 baseDir = "/var/lib/hydra";
12
13 hydraConf = pkgs.writeScript "hydra.conf" cfg.extraConfig;
14
15 hydraEnv = {
16 HYDRA_DBI = cfg.dbi;
17 HYDRA_CONFIG = "${baseDir}/hydra.conf";
18 HYDRA_DATA = "${baseDir}";
19 };
20
21 env =
22 {
23 NIX_REMOTE = "daemon";
24 PGPASSFILE = "${baseDir}/pgpass";
25 NIX_REMOTE_SYSTEMS = lib.concatStringsSep ":" cfg.buildMachinesFiles;
26 }
27 // lib.optionalAttrs (cfg.smtpHost != null) {
28 EMAIL_SENDER_TRANSPORT = "SMTP";
29 EMAIL_SENDER_TRANSPORT_host = cfg.smtpHost;
30 }
31 // hydraEnv
32 // cfg.extraEnv;
33
34 serverEnv =
35 env
36 // {
37 HYDRA_TRACKER = cfg.tracker;
38 XDG_CACHE_HOME = "${baseDir}/www/.cache";
39 COLUMNS = "80";
40 PGPASSFILE = "${baseDir}/pgpass-www"; # grrr
41 }
42 // (lib.optionalAttrs cfg.debugServer { DBIC_TRACE = "1"; });
43
44 localDB = "dbi:Pg:dbname=hydra;user=hydra;";
45
46 haveLocalDB = cfg.dbi == localDB;
47
48 hydra-package =
49 let
50 makeWrapperArgs = lib.concatStringsSep " " (
51 lib.mapAttrsToList (key: value: "--set-default \"${key}\" \"${value}\"") hydraEnv
52 );
53 in
54 pkgs.buildEnv rec {
55 name = "hydra-env";
56 nativeBuildInputs = [ pkgs.makeWrapper ];
57 paths = [ cfg.package ];
58
59 postBuild = ''
60 if [ -L "$out/bin" ]; then
61 unlink "$out/bin"
62 fi
63 mkdir -p "$out/bin"
64
65 for path in ${lib.concatStringsSep " " paths}; do
66 if [ -d "$path/bin" ]; then
67 cd "$path/bin"
68 for prg in *; do
69 if [ -f "$prg" ]; then
70 rm -f "$out/bin/$prg"
71 if [ -x "$prg" ]; then
72 makeWrapper "$path/bin/$prg" "$out/bin/$prg" ${makeWrapperArgs}
73 fi
74 fi
75 done
76 fi
77 done
78 '';
79 };
80
81in
82
83{
84 ###### interface
85 options = {
86
87 services.hydra = {
88
89 enable = lib.mkOption {
90 type = lib.types.bool;
91 default = false;
92 description = ''
93 Whether to run Hydra services.
94 '';
95 };
96
97 dbi = lib.mkOption {
98 type = lib.types.str;
99 default = localDB;
100 example = "dbi:Pg:dbname=hydra;host=postgres.example.org;user=foo;";
101 description = ''
102 The DBI string for Hydra database connection.
103
104 NOTE: Attempts to set `application_name` will be overridden by
105 `hydra-TYPE` (where TYPE is e.g. `evaluator`, `queue-runner`,
106 etc.) in all hydra services to more easily distinguish where
107 queries are coming from.
108 '';
109 };
110
111 package = lib.mkPackageOption pkgs "hydra" { };
112
113 hydraURL = lib.mkOption {
114 type = lib.types.str;
115 description = ''
116 The base URL for the Hydra webserver instance. Used for links in emails.
117 '';
118 };
119
120 listenHost = lib.mkOption {
121 type = lib.types.str;
122 default = "*";
123 example = "localhost";
124 description = ''
125 The hostname or address to listen on or `*` to listen
126 on all interfaces.
127 '';
128 };
129
130 port = lib.mkOption {
131 type = lib.types.port;
132 default = 3000;
133 description = ''
134 TCP port the web server should listen to.
135 '';
136 };
137
138 minimumDiskFree = lib.mkOption {
139 type = lib.types.int;
140 default = 0;
141 description = ''
142 Threshold of minimum disk space (GiB) to determine if the queue runner should run or not.
143 '';
144 };
145
146 minimumDiskFreeEvaluator = lib.mkOption {
147 type = lib.types.int;
148 default = 0;
149 description = ''
150 Threshold of minimum disk space (GiB) to determine if the evaluator should run or not.
151 '';
152 };
153
154 notificationSender = lib.mkOption {
155 type = lib.types.str;
156 description = ''
157 Sender email address used for email notifications.
158 '';
159 };
160
161 smtpHost = lib.mkOption {
162 type = lib.types.nullOr lib.types.str;
163 default = null;
164 example = "localhost";
165 description = ''
166 Hostname of the SMTP server to use to send email.
167 '';
168 };
169
170 tracker = lib.mkOption {
171 type = lib.types.str;
172 default = "";
173 description = ''
174 Piece of HTML that is included on all pages.
175 '';
176 };
177
178 logo = lib.mkOption {
179 type = lib.types.nullOr lib.types.path;
180 default = null;
181 description = ''
182 Path to a file containing the logo of your Hydra instance.
183 '';
184 };
185
186 debugServer = lib.mkOption {
187 type = lib.types.bool;
188 default = false;
189 description = "Whether to run the server in debug mode.";
190 };
191
192 maxServers = lib.mkOption {
193 type = lib.types.int;
194 default = 25;
195 description = "Maximum number of starman workers to spawn.";
196 };
197
198 minSpareServers = lib.mkOption {
199 type = lib.types.int;
200 default = 4;
201 description = "Minimum number of spare starman workers to keep.";
202 };
203
204 maxSpareServers = lib.mkOption {
205 type = lib.types.int;
206 default = 5;
207 description = "Maximum number of spare starman workers to keep.";
208 };
209
210 extraConfig = lib.mkOption {
211 type = lib.types.lines;
212 description = "Extra lines for the Hydra configuration.";
213 };
214
215 extraEnv = lib.mkOption {
216 type = lib.types.attrsOf lib.types.str;
217 default = { };
218 description = "Extra environment variables for Hydra.";
219 };
220
221 gcRootsDir = lib.mkOption {
222 type = lib.types.path;
223 default = "/nix/var/nix/gcroots/hydra";
224 description = "Directory that holds Hydra garbage collector roots.";
225 };
226
227 buildMachinesFiles = lib.mkOption {
228 type = lib.types.listOf lib.types.path;
229 default = lib.optional (config.nix.buildMachines != [ ]) "/etc/nix/machines";
230 defaultText = lib.literalExpression ''lib.optional (config.nix.buildMachines != []) "/etc/nix/machines"'';
231 example = [
232 "/etc/nix/machines"
233 "/var/lib/hydra/provisioner/machines"
234 ];
235 description = "List of files containing build machines.";
236 };
237
238 useSubstitutes = lib.mkOption {
239 type = lib.types.bool;
240 default = false;
241 description = ''
242 Whether to use binary caches for downloading store paths. Note that
243 binary substitutions trigger (a potentially large number of) additional
244 HTTP requests that slow down the queue monitor thread significantly.
245 Also, this Hydra instance will serve those downloaded store paths to
246 its users with its own signature attached as if it had built them
247 itself, so don't enable this feature unless your active binary caches
248 are absolute trustworthy.
249 '';
250 };
251 };
252
253 };
254
255 ###### implementation
256
257 config = lib.mkIf cfg.enable {
258 assertions = [
259 {
260 assertion = cfg.maxServers != 0 && cfg.maxSpareServers != 0 && cfg.minSpareServers != 0;
261 message = "services.hydra.{minSpareServers,maxSpareServers,minSpareServers} cannot be 0";
262 }
263 {
264 assertion = cfg.minSpareServers < cfg.maxSpareServers;
265 message = "services.hydra.minSpareServers cannot be bigger than services.hydra.maxSpareServers";
266 }
267 ];
268
269 users.groups.hydra = {
270 gid = config.ids.gids.hydra;
271 };
272
273 users.users.hydra = {
274 description = "Hydra";
275 group = "hydra";
276 # We don't enable `createHome` here because the creation of the home directory is handled by the hydra-init service below.
277 home = baseDir;
278 useDefaultShell = true;
279 uid = config.ids.uids.hydra;
280 };
281
282 users.users.hydra-queue-runner = {
283 description = "Hydra queue runner";
284 group = "hydra";
285 useDefaultShell = true;
286 home = "${baseDir}/queue-runner"; # really only to keep SSH happy
287 uid = config.ids.uids.hydra-queue-runner;
288 };
289
290 users.users.hydra-www = {
291 description = "Hydra web server";
292 group = "hydra";
293 useDefaultShell = true;
294 uid = config.ids.uids.hydra-www;
295 };
296
297 services.hydra.extraConfig = ''
298 using_frontend_proxy = 1
299 base_uri = ${cfg.hydraURL}
300 notification_sender = ${cfg.notificationSender}
301 max_servers = ${toString cfg.maxServers}
302 ${lib.optionalString (cfg.logo != null) ''
303 hydra_logo = ${cfg.logo}
304 ''}
305 gc_roots_dir = ${cfg.gcRootsDir}
306 use-substitutes = ${if cfg.useSubstitutes then "1" else "0"}
307 '';
308
309 environment.systemPackages = [ hydra-package ];
310
311 environment.variables = hydraEnv;
312
313 nix.settings = lib.mkMerge [
314 {
315 keep-outputs = true;
316 keep-derivations = true;
317 trusted-users = [ "hydra-queue-runner" ];
318 }
319
320 (lib.mkIf (lib.versionOlder (lib.getVersion config.nix.package.out) "2.4pre") {
321 # The default (`true') slows Nix down a lot since the build farm
322 # has so many GC roots.
323 gc-check-reachability = false;
324 })
325 ];
326
327 systemd.slices.system-hydra = {
328 description = "Hydra CI Server Slice";
329 documentation = [
330 "file://${cfg.package}/share/doc/hydra/index.html"
331 "https://nixos.org/hydra/manual/"
332 ];
333 };
334
335 systemd.services.hydra-init = {
336 wantedBy = [ "multi-user.target" ];
337 requires = lib.optional haveLocalDB "postgresql.service";
338 after = lib.optional haveLocalDB "postgresql.service";
339 environment = env // {
340 HYDRA_DBI = "${env.HYDRA_DBI};application_name=hydra-init";
341 };
342 path = [ pkgs.util-linux ];
343 preStart = ''
344 mkdir -p ${baseDir}
345 chown hydra:hydra ${baseDir}
346 chmod 0750 ${baseDir}
347
348 ln -sf ${hydraConf} ${baseDir}/hydra.conf
349
350 mkdir -m 0700 ${baseDir}/www || true
351 chown hydra-www:hydra ${baseDir}/www
352
353 mkdir -m 0700 ${baseDir}/queue-runner || true
354 mkdir -m 0750 ${baseDir}/build-logs || true
355 mkdir -m 0750 ${baseDir}/runcommand-logs || true
356 chown hydra-queue-runner:hydra \
357 ${baseDir}/queue-runner \
358 ${baseDir}/build-logs \
359 ${baseDir}/runcommand-logs
360
361 ${lib.optionalString haveLocalDB ''
362 if ! [ -e ${baseDir}/.db-created ]; then
363 runuser -u ${config.services.postgresql.superUser} ${config.services.postgresql.package}/bin/createuser hydra
364 runuser -u ${config.services.postgresql.superUser} ${config.services.postgresql.package}/bin/createdb -- -O hydra hydra
365 touch ${baseDir}/.db-created
366 fi
367 echo "create extension if not exists pg_trgm" | runuser -u ${config.services.postgresql.superUser} -- ${config.services.postgresql.package}/bin/psql hydra
368 ''}
369
370 if [ ! -e ${cfg.gcRootsDir} ]; then
371
372 # Move legacy roots directory.
373 if [ -e /nix/var/nix/gcroots/per-user/hydra/hydra-roots ]; then
374 mv /nix/var/nix/gcroots/per-user/hydra/hydra-roots ${cfg.gcRootsDir}
375 fi
376
377 mkdir -p ${cfg.gcRootsDir}
378 fi
379
380 # Move legacy hydra-www roots.
381 if [ -e /nix/var/nix/gcroots/per-user/hydra-www/hydra-roots ]; then
382 find /nix/var/nix/gcroots/per-user/hydra-www/hydra-roots/ -type f -print0 \
383 | xargs -0 -r mv -f -t ${cfg.gcRootsDir}/
384 rmdir /nix/var/nix/gcroots/per-user/hydra-www/hydra-roots
385 fi
386
387 chown hydra:hydra ${cfg.gcRootsDir}
388 chmod 2775 ${cfg.gcRootsDir}
389 '';
390 serviceConfig.ExecStart = "${hydra-package}/bin/hydra-init";
391 serviceConfig.PermissionsStartOnly = true;
392 serviceConfig.User = "hydra";
393 serviceConfig.Type = "oneshot";
394 serviceConfig.RemainAfterExit = true;
395 serviceConfig.Slice = "system-hydra.slice";
396 };
397
398 systemd.services.hydra-server = {
399 wantedBy = [ "multi-user.target" ];
400 requires = [ "hydra-init.service" ];
401 after = [ "hydra-init.service" ];
402 environment = serverEnv // {
403 HYDRA_DBI = "${serverEnv.HYDRA_DBI};application_name=hydra-server";
404 };
405 restartTriggers = [ hydraConf ];
406 serviceConfig = {
407 ExecStart =
408 "@${hydra-package}/bin/hydra-server hydra-server -f -h '${cfg.listenHost}' "
409 + "-p ${toString cfg.port} --min_spare_servers ${toString cfg.minSpareServers} --max_spare_servers ${toString cfg.maxSpareServers} "
410 + "--max_servers ${toString cfg.maxServers} --max_requests 100 ${lib.optionalString cfg.debugServer "-d"}";
411 User = "hydra-www";
412 PermissionsStartOnly = true;
413 Restart = "always";
414 Slice = "system-hydra.slice";
415 };
416 };
417
418 systemd.services.hydra-queue-runner = {
419 wantedBy = [ "multi-user.target" ];
420 requires = [ "hydra-init.service" ];
421 after = [
422 "hydra-init.service"
423 "network.target"
424 ];
425 path = [
426 hydra-package
427 pkgs.nettools
428 pkgs.openssh
429 pkgs.bzip2
430 config.nix.package
431 ];
432 restartTriggers = [ hydraConf ];
433 environment = env // {
434 PGPASSFILE = "${baseDir}/pgpass-queue-runner"; # grrr
435 IN_SYSTEMD = "1"; # to get log severity levels
436 HYDRA_DBI = "${env.HYDRA_DBI};application_name=hydra-queue-runner";
437 };
438 serviceConfig = {
439 ExecStart = "@${hydra-package}/bin/hydra-queue-runner hydra-queue-runner -v";
440 ExecStopPost = "${hydra-package}/bin/hydra-queue-runner --unlock";
441 User = "hydra-queue-runner";
442 Restart = "always";
443 Slice = "system-hydra.slice";
444
445 # Ensure we can get core dumps.
446 LimitCORE = "infinity";
447 WorkingDirectory = "${baseDir}/queue-runner";
448 };
449 };
450
451 systemd.services.hydra-evaluator = {
452 wantedBy = [ "multi-user.target" ];
453 requires = [ "hydra-init.service" ];
454 wants = [ "network-online.target" ];
455 after = [
456 "hydra-init.service"
457 "network.target"
458 "network-online.target"
459 ];
460 path = with pkgs; [
461 hydra-package
462 nettools
463 jq
464 ];
465 restartTriggers = [ hydraConf ];
466 environment = env // {
467 HYDRA_DBI = "${env.HYDRA_DBI};application_name=hydra-evaluator";
468 };
469 serviceConfig = {
470 ExecStart = "@${hydra-package}/bin/hydra-evaluator hydra-evaluator";
471 User = "hydra";
472 Restart = "always";
473 WorkingDirectory = baseDir;
474 Slice = "system-hydra.slice";
475 };
476 };
477
478 systemd.services.hydra-update-gc-roots = {
479 requires = [ "hydra-init.service" ];
480 after = [ "hydra-init.service" ];
481 environment = env // {
482 HYDRA_DBI = "${env.HYDRA_DBI};application_name=hydra-update-gc-roots";
483 };
484 serviceConfig = {
485 ExecStart = "@${hydra-package}/bin/hydra-update-gc-roots hydra-update-gc-roots";
486 User = "hydra";
487 Slice = "system-hydra.slice";
488 };
489 startAt = "2,14:15";
490 };
491
492 systemd.services.hydra-send-stats = {
493 wantedBy = [ "multi-user.target" ];
494 after = [ "hydra-init.service" ];
495 environment = env // {
496 HYDRA_DBI = "${env.HYDRA_DBI};application_name=hydra-send-stats";
497 };
498 serviceConfig = {
499 ExecStart = "@${hydra-package}/bin/hydra-send-stats hydra-send-stats";
500 User = "hydra";
501 Slice = "system-hydra.slice";
502 };
503 };
504
505 systemd.services.hydra-notify = {
506 wantedBy = [ "multi-user.target" ];
507 requires = [ "hydra-init.service" ];
508 after = [ "hydra-init.service" ];
509 restartTriggers = [ hydraConf ];
510 path = [ pkgs.zstd ];
511 environment = env // {
512 PGPASSFILE = "${baseDir}/pgpass-queue-runner";
513 HYDRA_DBI = "${env.HYDRA_DBI};application_name=hydra-notify";
514 };
515 serviceConfig = {
516 ExecStart = "@${hydra-package}/bin/hydra-notify hydra-notify";
517 # FIXME: run this under a less privileged user?
518 User = "hydra-queue-runner";
519 Restart = "always";
520 RestartSec = 5;
521 Slice = "system-hydra.slice";
522 };
523 };
524
525 # If there is less than a certain amount of free disk space, stop
526 # the queue/evaluator to prevent builds from failing or aborting.
527 systemd.services.hydra-check-space = {
528 script = ''
529 if [ $(($(stat -f -c '%a' /nix/store) * $(stat -f -c '%S' /nix/store))) -lt $((${toString cfg.minimumDiskFree} * 1024**3)) ]; then
530 echo "stopping Hydra queue runner due to lack of free space..."
531 systemctl stop hydra-queue-runner
532 fi
533 if [ $(($(stat -f -c '%a' /nix/store) * $(stat -f -c '%S' /nix/store))) -lt $((${toString cfg.minimumDiskFreeEvaluator} * 1024**3)) ]; then
534 echo "stopping Hydra evaluator due to lack of free space..."
535 systemctl stop hydra-evaluator
536 fi
537 '';
538 startAt = "*:0/5";
539 serviceConfig.Slice = "system-hydra.slice";
540 };
541
542 # Periodically compress build logs. The queue runner compresses
543 # logs automatically after a step finishes, but this doesn't work
544 # if the queue runner is stopped prematurely.
545 systemd.services.hydra-compress-logs = {
546 path = [
547 pkgs.bzip2
548 pkgs.zstd
549 ];
550 script = ''
551 set -eou pipefail
552 compression=$(sed -nr 's/compress_build_logs_compression = ()/\1/p' ${baseDir}/hydra.conf)
553 if [[ $compression == "" || $compression == bzip2 ]]; then
554 compressionCmd=(bzip2)
555 elif [[ $compression == zstd ]]; then
556 compressionCmd=(zstd --rm)
557 fi
558 find ${baseDir}/build-logs -ignore_readdir_race -type f -name "*.drv" -mtime +3 -size +0c -print0 | xargs -0 -r "''${compressionCmd[@]}" --force --quiet
559 '';
560 startAt = "Sun 01:45";
561 serviceConfig.Slice = "system-hydra.slice";
562 };
563
564 services.postgresql.enable = lib.mkIf haveLocalDB true;
565
566 services.postgresql.identMap = lib.optionalString haveLocalDB ''
567 hydra hydra hydra
568 hydra hydra-queue-runner hydra
569 hydra hydra-www hydra
570 hydra root hydra
571 '';
572
573 services.postgresql.authentication = lib.optionalString haveLocalDB ''
574 local all hydra peer map=hydra
575 '';
576
577 };
578
579}