Monorepo for Tangled
tangled.org
1{
2 config,
3 lib,
4 pkgs,
5 ...
6}: let
7 cfg = config.services.tangled.spindle;
8in
9 with lib; {
10 options = {
11 services.tangled.spindle = {
12 enable = mkOption {
13 type = types.bool;
14 default = false;
15 description = "Enable a tangled spindle";
16 };
17 package = mkOption {
18 type = types.package;
19 description = "Package to use for the spindle";
20 };
21
22 server = {
23 listenAddr = mkOption {
24 type = types.str;
25 default = "0.0.0.0:6555";
26 description = "Address to listen on";
27 };
28
29 dbPath = mkOption {
30 type = types.path;
31 default = "/var/lib/spindle/spindle.db";
32 description = "Path to the database file";
33 };
34
35 hostname = mkOption {
36 type = types.str;
37 example = "my.spindle.com";
38 description = "Hostname for the server (required)";
39 };
40
41 plcUrl = mkOption {
42 type = types.str;
43 default = "https://plc.directory";
44 description = "atproto PLC directory";
45 };
46
47 jetstreamEndpoint = mkOption {
48 type = types.str;
49 default = "wss://jetstream1.us-west.bsky.network/subscribe";
50 description = "Jetstream endpoint to subscribe to";
51 };
52
53 dev = mkOption {
54 type = types.bool;
55 default = false;
56 description = "Enable development mode (disables signature verification)";
57 };
58
59 owner = mkOption {
60 type = types.str;
61 example = "did:plc:qfpnj4og54vl56wngdriaxug";
62 description = "DID of owner (required)";
63 };
64
65 maxJobCount = mkOption {
66 type = types.int;
67 default = 2;
68 example = 5;
69 description = "Maximum number of concurrent jobs to run";
70 };
71
72 queueSize = mkOption {
73 type = types.int;
74 default = 100;
75 example = 100;
76 description = "Maximum number of jobs queue up";
77 };
78
79 secrets = {
80 provider = mkOption {
81 type = types.str;
82 default = "sqlite";
83 description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'.";
84 };
85
86 openbao = {
87 proxyAddr = mkOption {
88 type = types.str;
89 default = "http://127.0.0.1:8200";
90 description = "Address of the OpenBAO proxy server";
91 };
92 mount = mkOption {
93 type = types.str;
94 default = "spindle";
95 description = "Mount path in OpenBAO to read secrets from";
96 };
97 };
98 };
99
100 tap = {
101 embed = mkOption {
102 type = types.bool;
103 default = true;
104 description = "Run an embedded tap inside the spindle process";
105 };
106
107 url = mkOption {
108 type = types.str;
109 default = "http://[::1]:2480";
110 description = "URL the spindle's tap client dials";
111 };
112
113 bind = mkOption {
114 type = types.str;
115 default = "[::1]:2480";
116 description = "Loopback address the embedded tap server listens on";
117 };
118
119 dbPath = mkOption {
120 type = types.path;
121 default = "/var/lib/spindle/tap.db";
122 description = "Path to the embedded tap sqlite database";
123 };
124
125 relayUrl = mkOption {
126 type = types.str;
127 default = "https://bsky.network";
128 description = "Relay used by the embedded tap firehose";
129 };
130 };
131 };
132
133 pipelines = {
134 logBucket = mkOption {
135 type = types.str;
136 default = "tangled-logs";
137 description = "S3 bucket for workflow logs";
138 };
139 workflowTimeout = mkOption {
140 type = types.str;
141 default = "5m";
142 description = "Timeout for each workflow step";
143 };
144
145 nixery = {
146 nixery = mkOption {
147 type = types.str;
148 default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet
149 description = "Nixery instance to use";
150 };
151
152 maxJobMemoryMb = mkOption {
153 type = types.int;
154 default = 6144;
155 description = "Memory limit per nixery workflow container in MiB (default 6 GiB)";
156 };
157 maxConcurrentWorkflows = mkOption {
158 type = types.int;
159 default = 8;
160 description = "Maximum number of nixery workflows running simultaneously. Zero disables this limit.";
161 };
162 };
163
164 microvm = {
165 enableKVM = mkOption {
166 type = types.bool;
167 default = true;
168 description = "Enable KVM hardware acceleration";
169 };
170
171 imageDir = mkOption {
172 type = types.str;
173 default = "/var/lib/spindle/images";
174 description = "Directory containing microVM image spec JSONs or image spec directories";
175 };
176 overlayDir = mkOption {
177 type = types.str;
178 default = "/tmp";
179 description = "Directory to store microVM temporary overlay files";
180 };
181 defaultImage = mkOption {
182 type = types.str;
183 default = "nixos";
184 description = "Default microVM image spec to use if none is specified in workflow";
185 };
186 agentPort = mkOption {
187 type = types.port;
188 default = 10240;
189 description = "Host vsock port the microVM agent connects back to";
190 };
191
192 limits = {
193 total = {
194 memoryMiB = mkOption {
195 type = types.int;
196 default = 0;
197 description = "Maximum declared guest memory in MiB allowed across all running microVM workflows. Zero disables this limit.";
198 };
199 vcpus = mkOption {
200 type = types.int;
201 default = 0;
202 description = "Maximum declared vCPUs allowed across all running microVM workflows. Zero disables this limit.";
203 };
204 diskMiB = mkOption {
205 type = types.int;
206 default = 0;
207 description = "Maximum declared disk in MiB allowed across all running microVM workflows. Zero disables this limit.";
208 };
209 };
210
211 workflow = {
212 memoryMiB = mkOption {
213 type = types.int;
214 default = 0;
215 description = "Maximum declared guest memory in MiB allowed for a single microVM workflow. Zero disables this limit.";
216 };
217 vcpus = mkOption {
218 type = types.int;
219 default = 0;
220 description = "Maximum declared vCPUs allowed for a single microVM workflow. Zero disables this limit.";
221 };
222 diskMiB = mkOption {
223 type = types.int;
224 default = 0;
225 description = "Maximum declared disk in MiB allowed for a single microVM workflow. Zero disables this limit.";
226 };
227 };
228 };
229
230 cgroup = {
231 enable = mkOption {
232 type = types.bool;
233 default = false;
234 description = "Enable cgroup v2 containment for microVM processes.";
235 };
236 parent = mkOption {
237 type = types.str;
238 default = "self";
239 description = "Parent cgroup for microVM workflow cgroups. Use 'self' to resolve the spindle service cgroup.";
240 };
241 pidsMax = mkOption {
242 type = types.int;
243 default = 4096;
244 description = "Maximum number of processes allowed in each microVM workflow cgroup.";
245 };
246 swapMaxMiB = mkOption {
247 type = types.int;
248 default = 0;
249 description = "Maximum swap in MiB allowed in each microVM workflow cgroup. Zero disables swap.";
250 };
251 supervisorMinMiB = mkOption {
252 type = types.int;
253 default = 512;
254 description = ''
255 Amount of memory in MiB that will be protected by the cgroup for the spindle
256 (allowing it to not get OOMed first.)
257 '';
258 };
259 };
260 };
261 };
262
263 cache = {
264 readUrls = mkOption {
265 type = types.listOf types.str;
266 default = [];
267 example = ["http://ncps.internal:8501"];
268 description = "Nix binary cache URLs the Spindle guest should read from.";
269 };
270
271 trustedPublicKeys = mkOption {
272 type = types.listOf types.str;
273 default = [];
274 example = ["ncps.internal-1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="];
275 description = "Public keys trusted for the configured Nix binary caches.";
276 };
277
278 uploadUrl = mkOption {
279 type = types.str;
280 default = "";
281 example = "http://ncps.internal:8501/upload";
282 description = "Optional cache upload URL used by live cache import paths.";
283 };
284 };
285
286 environmentFile = mkOption {
287 type = with types; nullOr path;
288 default = null;
289 example = "/etc/spindle.env";
290 description = ''
291 Additional environment file as defined in {manpage}`systemd.exec(5)`.
292
293 Sensitive secrets such as {env}`AWS_SECRET_ACCESS_KEY`,
294 {env}`AWS_ACCESS_KEY_ID`, {env}`AWS_REGION`
295 may be passed to the service
296 without making them world readable in the nix store.
297 '';
298 };
299 };
300 };
301
302 config = let
303 deps = [
304 pkgs.qemu
305 pkgs.e2fsprogs
306 pkgs.slirp4netns
307 pkgs.iproute2
308 pkgs.util-linux
309 ];
310 in
311 mkIf cfg.enable {
312 environment.systemPackages = [
313 (pkgs.writeShellScriptBin "spindle" ''
314 export PATH="${lib.makeBinPath deps}:$PATH"
315 ${lib.optionalString (cfg.environmentFile != null) "set -a; source ${cfg.environmentFile}; set +a"}
316 ${lib.concatMapStringsSep "\n" (
317 e: "export ${e}"
318 )
319 config.systemd.services.spindle.serviceConfig.Environment}
320 exec ${cfg.package}/bin/spindle "$@"
321 '')
322 ];
323
324 virtualisation.docker.enable = true;
325
326 systemd.services.spindle = {
327 description = "spindle service";
328 after = [
329 "network.target"
330 "docker.service"
331 ];
332 wantedBy = ["multi-user.target"];
333 path = deps;
334 serviceConfig = {
335 LogsDirectory = "spindle";
336 StateDirectory = "spindle";
337 Delegate = cfg.pipelines.microvm.cgroup.enable;
338 EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
339
340 Environment = [
341 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
342 "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
343 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
344 "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
345 "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
346 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
347 "SPINDLE_SERVER_OWNER=${cfg.server.owner}"
348 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
349 "SPINDLE_SERVER_QUEUE_SIZE=${toString cfg.server.queueSize}"
350 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
351 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
352 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
353 "SPINDLE_SERVER_TAP_EMBED=${lib.boolToString cfg.server.tap.embed}"
354 "SPINDLE_SERVER_TAP_URL=${cfg.server.tap.url}"
355 "SPINDLE_SERVER_TAP_BIND=${cfg.server.tap.bind}"
356 "SPINDLE_SERVER_TAP_DB_PATH=${cfg.server.tap.dbPath}"
357 "SPINDLE_SERVER_TAP_RELAY_URL=${cfg.server.tap.relayUrl}"
358 "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery.nixery}"
359 "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
360 "SPINDLE_NIXERY_PIPELINES_MAX_JOB_MEMORY_MB=${toString cfg.pipelines.nixery.maxJobMemoryMb}"
361 "SPINDLE_NIXERY_PIPELINES_MAX_CONCURRENT_WORKFLOWS=${toString cfg.pipelines.nixery.maxConcurrentWorkflows}"
362 "SPINDLE_MICROVM_PIPELINES_IMAGE_DIR=${cfg.pipelines.microvm.imageDir}"
363 "SPINDLE_MICROVM_PIPELINES_OVERLAY_DIR=${cfg.pipelines.microvm.overlayDir}"
364 "SPINDLE_MICROVM_PIPELINES_DEFAULT_IMAGE=${cfg.pipelines.microvm.defaultImage}"
365 "SPINDLE_MICROVM_PIPELINES_AGENT_PORT=${toString cfg.pipelines.microvm.agentPort}"
366 "SPINDLE_MICROVM_PIPELINES_ENABLE_KVM=${lib.boolToString cfg.pipelines.microvm.enableKVM}"
367 "SPINDLE_MICROVM_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
368 "SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_MEMORY_MIB=${toString cfg.pipelines.microvm.limits.total.memoryMiB}"
369 "SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_VCPUS=${toString cfg.pipelines.microvm.limits.total.vcpus}"
370 "SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_DISK_MIB=${toString cfg.pipelines.microvm.limits.total.diskMiB}"
371 "SPINDLE_MICROVM_PIPELINES_MAX_WORKFLOW_MEMORY_MIB=${toString cfg.pipelines.microvm.limits.workflow.memoryMiB}"
372 "SPINDLE_MICROVM_PIPELINES_MAX_WORKFLOW_VCPUS=${toString cfg.pipelines.microvm.limits.workflow.vcpus}"
373 "SPINDLE_MICROVM_PIPELINES_MAX_WORKFLOW_DISK_MIB=${toString cfg.pipelines.microvm.limits.workflow.diskMiB}"
374 "SPINDLE_MICROVM_PIPELINES_ENABLE_CGROUPS=${lib.boolToString cfg.pipelines.microvm.cgroup.enable}"
375 "SPINDLE_MICROVM_PIPELINES_CGROUP_PARENT=${cfg.pipelines.microvm.cgroup.parent}"
376 "SPINDLE_MICROVM_PIPELINES_CGROUP_PIDS_MAX=${toString cfg.pipelines.microvm.cgroup.pidsMax}"
377 "SPINDLE_MICROVM_PIPELINES_CGROUP_SWAP_MAX_MIB=${toString cfg.pipelines.microvm.cgroup.swapMaxMiB}"
378 "SPINDLE_MICROVM_PIPELINES_CGROUP_SUPERVISOR_MEMORY_MIN_MIB=${toString cfg.pipelines.microvm.cgroup.supervisorMinMiB}"
379 "SPINDLE_NIX_CACHE_READ_URLS=${concatStringsSep "," cfg.cache.readUrls}"
380 "SPINDLE_NIX_CACHE_TRUSTED_PUBLIC_KEYS=${concatStringsSep "," cfg.cache.trustedPublicKeys}"
381 "SPINDLE_NIX_CACHE_UPLOAD_URL=${cfg.cache.uploadUrl}"
382 "SPINDLE_S3_LOG_BUCKET=${cfg.pipelines.logBucket}"
383 ];
384 ExecStart = "${cfg.package}/bin/spindle";
385 Restart = "always";
386 };
387 };
388 };
389 }