module.nix

  1# NixOS module: services.ci.{bridge,runner,appview} (DESIGN.md §5, §10, §11).
  2#
  3#   bridge   the forge side: `ci bridge serve`, plus hookCommand for the
  4#            forge's post-receive hook (it spools; the bridge ingests)
  5#   runner   `ci runner serve` as root (it starts sandboxed transient units
  6#            for the `ci` user in ci.slice), the `ci` user and the slice
  7#   appview  `ci appview serve` (put it behind a TLS reverse proxy)
  8#
  9# Secrets (tokens, the runner's age identity and SSH key, the password hash)
 10# are paths to files, e.g. Colmena keys; none of them enters the store.
 11self:
 12{
 13  config,
 14  lib,
 15  pkgs,
 16  ...
 17}:
 18
 19let
 20  inherit (lib)
 21    mkEnableOption
 22    mkIf
 23    mkMerge
 24    mkOption
 25    optionals
 26    types
 27    ;
 28  cfg = config.services.ci;
 29  exe = lib.getExe cfg.package;
 30  nullOrPath = mkOption {
 31    type = types.nullOr types.str;
 32    default = null;
 33  };
 34  flag = name: value: optionals (value != null) [ "--${name}=${toString value}" ];
 35  hardening = {
 36    NoNewPrivileges = true;
 37    PrivateTmp = true;
 38    ProtectHome = true;
 39    ProtectSystem = "strict";
 40    ProtectKernelTunables = true;
 41    ProtectKernelModules = true;
 42    ProtectControlGroups = true;
 43    RestrictSUIDSGID = true;
 44    LockPersonality = true;
 45  };
 46  # SSH for segments (git, colmena, nix copy, ssh): OpenSSH reads the passwd
 47  # home's ~/.ssh/config, so this is the ci user's, and ${VAR} expands per
 48  # process: each unit uses its own credential. The forge on localhost is
 49  # trusted on first use; everything else must be in knownHostsFile.
 50  sshConfig = pkgs.writeText "ci-ssh-config" ''
 51    Host localhost 127.0.0.1 ::1
 52      StrictHostKeyChecking accept-new
 53      UserKnownHostsFile ''${HOME}/.ssh/known_hosts.local
 54    Host *
 55      IdentityFile ''${CREDENTIALS_DIRECTORY}/ssh-key
 56      IdentitiesOnly yes
 57      UserKnownHostsFile ${cfg.runner.knownHostsFile}
 58      StrictHostKeyChecking yes
 59  '';
 60  # What segments find on PATH (plus the engine's own directory, first).
 61  segmentPath = pkgs.buildEnv {
 62    name = "ci-segment-path";
 63    paths = [
 64      cfg.package
 65      pkgs.bashInteractive
 66      pkgs.coreutils
 67      pkgs.findutils
 68      pkgs.gnugrep
 69      pkgs.gnused
 70      pkgs.gawk
 71      pkgs.git
 72      pkgs.openssh
 73      pkgs.jq
 74      pkgs.curl
 75      pkgs.just
 76      config.nix.package
 77    ]
 78    ++ optionals (cfg.runner.shell == "devenv") [ cfg.runner.devenvPackage ]
 79    ++ cfg.runner.extraPackages;
 80  };
 81in
 82{
 83  options.services.ci = {
 84    package = mkOption {
 85      type = types.package;
 86      default = self.packages.${pkgs.stdenv.hostPlatform.system}.ci;
 87      description = "The ci package (engine and hosts).";
 88    };
 89
 90    bridge = {
 91      enable = mkEnableOption "the ci forge bridge (knot-compatible /events)";
 92      listen = mkOption {
 93        type = types.str;
 94        default = "127.0.0.1:8475";
 95      };
 96      forgeHost = mkOption {
 97        type = types.str;
 98        description = "Forge host name; repos are did:web:<forgeHost>:<repo>.";
 99      };
100      ownerDid = nullOrPath // {
101        description = "DID of the repos' owner.";
102      };
103      tokenFile = nullOrPath // {
104        description = "Bearer token required on /events and /ingest.";
105      };
106      spoolDir = mkOption {
107        type = types.str;
108        default = "/var/lib/ci-bridge/spool";
109        description = "Where the forge's hook spools pushes (the hook runs as the forge's user; root on the forge).";
110      };
111      hookCommand = mkOption {
112        type = types.str;
113        readOnly = true;
114        description = "Command for the forge's post-receive hook (stdin: git's ref lines).";
115        default =
116          "${exe} bridge notify --spool ${cfg.bridge.spoolDir} --server http://${cfg.bridge.listen}"
117          + lib.optionalString (cfg.bridge.tokenFile != null) " --token-file ${cfg.bridge.tokenFile}";
118      };
119    };
120
121    runner = {
122      enable = mkEnableOption "the ci runner (spindle-compatible host for the engine)";
123      listen = mkOption {
124        type = types.str;
125        default = "127.0.0.1:8476";
126      };
127      stateDir = mkOption {
128        type = types.str;
129        default = "/var/lib/ci";
130      };
131      bridgeUrl = mkOption {
132        type = types.str;
133        default = "ws://${cfg.bridge.listen}/events";
134        description = "The bridge's /events websocket.";
135      };
136      bridgeTokenFile = nullOrPath;
137      tokenFile = nullOrPath // {
138        description = "Bearer token required by the runner's API (give the same file to the appview).";
139      };
140      knot = mkOption {
141        type = types.str;
142        default = cfg.bridge.forgeHost;
143        description = "The forge host.";
144      };
145      ownerDid = nullOrPath;
146      cloneUrl = mkOption {
147        type = types.str;
148        example = "ssh://git@localhost:23231/{repo}";
149        description = "Clone URL template with {repo} (for mirrors and segments; pushes go there too).";
150      };
151      maxConcurrent = mkOption {
152        type = types.ints.positive;
153        default = 2;
154      };
155      retention = {
156        slimAfter = mkOption {
157          type = types.str;
158          default = "72h";
159          description = ''
160            Finished runs keep only their record (log, results, artifacts, state/) after
161            this long (a Go duration); their checkouts and the Nix GC roots under
162            .devenv/ go, so the paths become collectable. "-1h" turns it off.
163          '';
164        };
165        deleteAfter = mkOption {
166          type = types.str;
167          default = "1440h";
168          description = "Finished runs are deleted entirely after this long (60 days). \"-1h\" turns it off.";
169        };
170      };
171      shell = mkOption {
172        type = types.enum [
173          "devenv"
174          "bash"
175        ];
176        default = "devenv";
177        description = "How actions run (bash: without devenv, for tests).";
178      };
179      devenvPackage = mkOption {
180        type = types.package;
181        default = pkgs.devenv;
182      };
183      extraPackages = mkOption {
184        type = types.listOf types.package;
185        default = [ ];
186        description = "More tools on segments' PATH (each repo's devenv brings its own).";
187      };
188      identityFile = nullOrPath // {
189        description = "age identity for the secrets vault (loaded into segments as a credential).";
190      };
191      sshKeyFile = nullOrPath // {
192        description = "SSH key for the runner's fetches and segments' git/colmena/ssh (forge push access, fleet root).";
193      };
194      knownHostsFile = mkOption {
195        type = types.str;
196        default = "/etc/ssh/ssh_known_hosts";
197        description = "known_hosts for segments' SSH (programs.ssh.knownHosts writes the default).";
198      };
199      pasta = mkOption {
200        type = types.bool;
201        default = false;
202        description = ''
203          Give each segment its own network namespace (egress through pasta), so
204          dev services can't clash with the host's ports. Caveats: commands run
205          as uid 0 of a user namespace (mapped to the ci user; tools that refuse
206          root, like postgres' initdb, won't run), root-owned files look
207          unowned there so OpenSSH refuses its config (no SSH: no deploys), and
208          the hostname reads pasta-<host>. For test-only workflows; off by
209          default. See DESIGN.md §10.
210        '';
211      };
212      pastaHostPorts = mkOption {
213        type = types.listOf types.port;
214        default = [ ];
215        example = [ 23231 ];
216        description = "With pasta: TCP ports of a segment's localhost forwarded to the host's (the forge's SSH port, for a localhost cloneUrl).";
217      };
218      user = mkOption {
219        type = types.str;
220        default = "ci";
221        description = "Unprivileged user segments run as.";
222      };
223      selfHost = mkOption {
224        type = types.str;
225        default = config.networking.fqdnOrHostName;
226        description = "This host's name in the fleet, as CI_SELF_HOST (deploy recipes put it last).";
227      };
228      slice = {
229        cpuWeight = mkOption {
230          type = types.int;
231          default = 20;
232          description = "CPUWeight of ci.slice (default 100 elsewhere): services win.";
233        };
234        ioWeight = mkOption {
235          type = types.int;
236          default = 20;
237        };
238        memoryMax = mkOption {
239          type = types.nullOr types.str;
240          default = null;
241          example = "6G";
242        };
243      };
244      devenvCache = mkOption {
245        type = types.bool;
246        default = true;
247        description = "Add devenv.cachix.org as a substituter (segments aren't trusted users and can't add it).";
248      };
249      ntfy = {
250        url = nullOrPath // {
251          description = "ntfy topic URL for gates and failures.";
252        };
253        tokenFile = nullOrPath;
254        success = mkOption {
255          type = types.bool;
256          default = false;
257        };
258      };
259      appviewUrl = nullOrPath // {
260        description = "Linked from notifications.";
261      };
262    };
263
264    appview = {
265      enable = mkEnableOption "the ci appview (web UI and agent API)";
266      listen = mkOption {
267        type = types.str;
268        default = "127.0.0.1:8477";
269      };
270      runnerUrl = mkOption {
271        type = types.str;
272        default = "http://${cfg.runner.listen}";
273      };
274      runnerTokenFile = mkOption {
275        type = types.nullOr types.str;
276        default = cfg.runner.tokenFile;
277      };
278      passwordHashFile = mkOption {
279        type = types.str;
280        description = "bcrypt hash of the owner's password (mkpasswd -m bcrypt -R 12).";
281      };
282      sessionKeyFile = nullOrPath;
283      agentTokensFile = nullOrPath // {
284        description = ''Lines of "<name> <token>" for agents.'';
285      };
286      knot = mkOption {
287        type = types.str;
288        default = cfg.runner.knot;
289      };
290      ownerDid = mkOption {
291        type = types.nullOr types.str;
292        default = cfg.runner.ownerDid;
293      };
294      secureCookies = mkOption {
295        type = types.bool;
296        default = true;
297      };
298    };
299  };
300
301  config = mkMerge [
302    (mkIf cfg.bridge.enable {
303      users.users.ci-bridge = {
304        isSystemUser = true;
305        group = "ci-bridge";
306      };
307      users.groups.ci-bridge = { };
308      # the hook (root on the forge) writes files here; the bridge reads and
309      # deletes them
310      systemd.tmpfiles.rules = [ "d ${cfg.bridge.spoolDir} 0755 ci-bridge ci-bridge -" ];
311      systemd.services.ci-bridge = {
312        description = "ci forge bridge";
313        wantedBy = [ "multi-user.target" ];
314        after = [ "network.target" ];
315        serviceConfig = hardening // {
316          User = "ci-bridge";
317          StateDirectory = "ci-bridge";
318          ReadWritePaths = [ cfg.bridge.spoolDir ];
319          # The token file may be root-only (it is uploaded before the user
320          # exists, and the forge's hook reads it too): load it as a
321          # credential, %d being the unit's credentials directory.
322          LoadCredential = optionals (cfg.bridge.tokenFile != null) [ "token:${cfg.bridge.tokenFile}" ];
323          ExecStart = lib.escapeShellArgs (
324            [
325              exe
326              "bridge"
327              "serve"
328              "--listen=${cfg.bridge.listen}"
329              "--db=/var/lib/ci-bridge/bridge.db"
330              "--spool=${cfg.bridge.spoolDir}"
331              "--forge-host=${cfg.bridge.forgeHost}"
332            ]
333            ++ flag "owner-did" cfg.bridge.ownerDid
334            ++ optionals (cfg.bridge.tokenFile != null) [ "--token-file=%d/token" ]
335          );
336          Restart = "always";
337          RestartSec = 5;
338        };
339      };
340    })
341
342    (mkIf cfg.runner.enable {
343      users.users.${cfg.runner.user} = {
344        isSystemUser = true;
345        group = cfg.runner.user;
346        home = "${cfg.runner.stateDir}/cache/home";
347      };
348      users.groups.${cfg.runner.user} = { };
349      systemd.tmpfiles.rules = [
350        "d ${cfg.runner.stateDir}/cache/home 0755 ${cfg.runner.user} ${cfg.runner.user} -"
351        "d ${cfg.runner.stateDir}/cache/home/.ssh 0700 ${cfg.runner.user} ${cfg.runner.user} -"
352        "L+ ${cfg.runner.stateDir}/cache/home/.ssh/config - - - - ${sshConfig}"
353      ];
354      systemd.slices.ci = {
355        description = "ci segments";
356        sliceConfig = {
357          CPUWeight = cfg.runner.slice.cpuWeight;
358          IOWeight = cfg.runner.slice.ioWeight;
359        }
360        // lib.optionalAttrs (cfg.runner.slice.memoryMax != null) { MemoryMax = cfg.runner.slice.memoryMax; };
361      };
362      nix.settings = mkIf cfg.runner.devenvCache {
363        extra-substituters = [ "https://devenv.cachix.org" ];
364        extra-trusted-public-keys = [ "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=" ];
365      };
366      systemd.services.ci-runner = {
367        description = "ci runner";
368        wantedBy = [ "multi-user.target" ];
369        after = [
370          "network.target"
371          "ci-bridge.service"
372        ];
373        path = [
374          pkgs.git
375          pkgs.openssh
376          pkgs.systemd
377        ];
378        environment = lib.optionalAttrs (cfg.runner.sshKeyFile != null) {
379          GIT_SSH_COMMAND = "ssh -i ${cfg.runner.sshKeyFile} -o IdentitiesOnly=yes -o UserKnownHostsFile=${cfg.runner.stateDir}/known_hosts -o StrictHostKeyChecking=accept-new";
380        };
381        # A runner restart (or a self-deploy) must not kill segments: they
382        # are transient units of their own, not children of this service.
383        restartIfChanged = true;
384        serviceConfig = {
385          # root: it starts units for the ci user (granting ci polkit's
386          # manage-units would let a segment start root units)
387          StateDirectory = "ci";
388          ExecStart = lib.escapeShellArgs (
389            [
390              exe
391              "runner"
392              "serve"
393              "--listen=${cfg.runner.listen}"
394              "--state-dir=${cfg.runner.stateDir}"
395              "--bridge=${cfg.runner.bridgeUrl}"
396              "--knot=${cfg.runner.knot}"
397              "--clone-url=${cfg.runner.cloneUrl}"
398              "--max-concurrent=${toString cfg.runner.maxConcurrent}"
399              "--slim-after=${cfg.runner.retention.slimAfter}"
400              "--delete-after=${cfg.runner.retention.deleteAfter}"
401              "--launcher=systemd"
402              "--shell=${cfg.runner.shell}"
403              "--slice=ci.slice"
404              "--user=${cfg.runner.user}"
405              "--setenv=PATH=${segmentPath}/bin"
406              "--setenv=CI_SSH_KNOWN_HOSTS=${cfg.runner.knownHostsFile}"
407              "--setenv=NIX_REMOTE=daemon"
408              "--setenv=CI_SELF_HOST=${cfg.runner.selfHost}"
409            ]
410            ++ flag "owner-did" cfg.runner.ownerDid
411            ++ flag "bridge-token-file" cfg.runner.bridgeTokenFile
412            ++ flag "token-file" cfg.runner.tokenFile
413            ++ flag "identity" cfg.runner.identityFile
414            ++ flag "ssh-key" cfg.runner.sshKeyFile
415            ++ optionals cfg.runner.pasta [ "--pasta=${pkgs.passt}/bin/pasta" ]
416            ++ optionals (cfg.runner.pasta && cfg.runner.pastaHostPorts != [ ]) [
417              "--pasta-host-ports=${lib.concatMapStringsSep "," toString cfg.runner.pastaHostPorts}"
418            ]
419            ++ flag "ntfy" cfg.runner.ntfy.url
420            ++ flag "ntfy-token-file" cfg.runner.ntfy.tokenFile
421            ++ optionals cfg.runner.ntfy.success [ "--ntfy-success" ]
422            ++ flag "appview-url" cfg.runner.appviewUrl
423          );
424          Restart = "always";
425          RestartSec = 5;
426          ProtectHome = true;
427          PrivateTmp = true;
428        };
429      };
430    })
431
432    (mkIf cfg.appview.enable {
433      systemd.services.ci-appview = {
434        description = "ci appview";
435        wantedBy = [ "multi-user.target" ];
436        after = [ "ci-runner.service" ];
437        serviceConfig = hardening // {
438          DynamicUser = true;
439          # DynamicUser can't read root-only key files; load them as credentials
440          LoadCredential = [
441            "password-hash:${cfg.appview.passwordHashFile}"
442          ]
443          ++ optionals (cfg.appview.runnerTokenFile != null) [ "runner-token:${cfg.appview.runnerTokenFile}" ]
444          ++ optionals (cfg.appview.sessionKeyFile != null) [ "session-key:${cfg.appview.sessionKeyFile}" ]
445          ++ optionals (cfg.appview.agentTokensFile != null) [ "agent-tokens:${cfg.appview.agentTokensFile}" ];
446          ExecStart = lib.escapeShellArgs (
447            [
448              exe
449              "appview"
450              "serve"
451              "--listen=${cfg.appview.listen}"
452              "--runner=${cfg.appview.runnerUrl}"
453              "--password-hash-file=%d/password-hash"
454              "--knot=${cfg.appview.knot}"
455              "--secure-cookies=${lib.boolToString cfg.appview.secureCookies}"
456            ]
457            ++ optionals (cfg.appview.runnerTokenFile != null) [ "--runner-token-file=%d/runner-token" ]
458            ++ optionals (cfg.appview.sessionKeyFile != null) [ "--session-key-file=%d/session-key" ]
459            ++ optionals (cfg.appview.agentTokensFile != null) [ "--agent-tokens-file=%d/agent-tokens" ]
460            ++ flag "owner-did" cfg.appview.ownerDid
461          );
462          Restart = "always";
463          RestartSec = 5;
464        };
465      };
466    })
467  ];
468}