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