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}