Stitch any CI into Tangled
1# NixOS module for the tack spindle.
2{
3 config,
4 lib,
5 ...
6}: let
7 cfg = config.services.tangled.tack;
8in
9 with lib; {
10 options.services.tangled.tack = {
11 enable = mkOption {
12 type = types.bool;
13 default = false;
14 description = "Enable the tack Tangled spindle.";
15 };
16
17 package = mkOption {
18 type = types.package;
19 description = "Package providing the `tack` binary.";
20 };
21
22 listenAddr = mkOption {
23 type = types.str;
24 default = ":8080";
25 description = "HTTP listen address (TACK_LISTEN_ADDR).";
26 };
27
28 hostname = mkOption {
29 type = types.str;
30 example = "tack.example.com";
31 description = ''
32 Public hostname this spindle is registered under in Tangled
33 (matches `sh.tangled.repo.spindle`). Required.
34 '';
35 };
36
37 ownerDid = mkOption {
38 type = types.str;
39 example = "did:plc:qfpnj4og54vl56wngdriaxug";
40 description = "DID of the spindle operator (TACK_OWNER_DID). Required.";
41 };
42
43 dbPath = mkOption {
44 type = types.path;
45 default = "/var/lib/tack/tack.db";
46 description = "Path to the local SQLite store (TACK_DB_PATH).";
47 };
48
49 jetstreamUrl = mkOption {
50 type = types.str;
51 default = "wss://jetstream1.us-west.bsky.network/subscribe";
52 description = "Tangled Jetstream WebSocket URL (TACK_JETSTREAM_URL).";
53 };
54
55 dev = mkOption {
56 type = types.bool;
57 default = false;
58 description = ''
59 Use `ws://` instead of `wss://` for knot event-streams
60 (TACK_DEV). Useful when running against a local knot.
61 '';
62 };
63
64 # Buildkite provider. Token + webhook secret are sensitive and
65 # should be supplied via `environmentFile` so they don't end up
66 # world-readable in the Nix store.
67 buildkite = {
68 org = mkOption {
69 type = types.nullOr types.str;
70 default = null;
71 example = "my-org";
72 description = ''
73 Default Buildkite org for workflows that don't specify one
74 (TACK_BUILDKITE_ORG). Required when the Buildkite token is
75 supplied via `environmentFile`.
76 '';
77 };
78
79 webhookMode = mkOption {
80 type = types.enum ["token" "signature"];
81 default = "token";
82 description = ''
83 How tack authenticates incoming Buildkite webhooks
84 (TACK_BUILDKITE_WEBHOOK_MODE).
85 '';
86 };
87 };
88
89 environmentFile = mkOption {
90 type = with types; nullOr path;
91 default = null;
92 example = "/etc/tack.env";
93 description = ''
94 Additional environment file as defined in {manpage}`systemd.exec(5)`.
95
96 Sensitive values such as {env}`TACK_BUILDKITE_TOKEN` and
97 {env}`TACK_BUILDKITE_WEBHOOK_SECRET` belong here so they
98 don't get baked into the world-readable Nix store.
99 '';
100 };
101
102 # Escape hatch for arbitrary systemd `[Service]` settings (e.g.
103 # `MemoryMax`, `CPUQuota`, additional sandboxing knobs). These
104 # are merged into `serviceConfig` and override the defaults
105 # below on conflict, so callers can both add new settings and
106 # tweak the ones we set out of the box.
107 extraServiceConfig = mkOption {
108 type = types.attrsOf types.unspecified;
109 default = {};
110 example = literalExpression ''
111 {
112 MemoryMax = "512M";
113 CPUQuota = "50%";
114 }
115 '';
116 description = ''
117 Extra settings merged into the systemd service's
118 `[Service]` section. See {manpage}`systemd.exec(5)` and
119 {manpage}`systemd.resource-control(5)` for available
120 options. Values here take precedence over the module's
121 defaults.
122 '';
123 };
124 };
125
126 config = mkIf cfg.enable {
127 systemd.services.tack = {
128 description = "Tack Tangled spindle";
129 after = ["network.target"];
130 wantedBy = ["multi-user.target"];
131
132 # `extraServiceConfig` is merged in last so user-supplied
133 # settings override our defaults on conflict.
134 serviceConfig =
135 {
136 # StateDirectory creates /var/lib/tack with the right
137 # ownership; the default dbPath lives there.
138 StateDirectory = "tack";
139 LogsDirectory = "tack";
140 EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
141
142 # Only the non-secret env vars go inline here. Anything
143 # sensitive (token, webhook secret) must come from
144 # `environmentFile` to stay out of the Nix store.
145 Environment =
146 [
147 "TACK_LISTEN_ADDR=${cfg.listenAddr}"
148 "TACK_HOSTNAME=${cfg.hostname}"
149 "TACK_OWNER_DID=${cfg.ownerDid}"
150 "TACK_DB_PATH=${cfg.dbPath}"
151 "TACK_JETSTREAM_URL=${cfg.jetstreamUrl}"
152 "TACK_BUILDKITE_WEBHOOK_MODE=${cfg.buildkite.webhookMode}"
153 ]
154 ++ optional cfg.dev "TACK_DEV=1"
155 ++ optional (cfg.buildkite.org != null)
156 "TACK_BUILDKITE_ORG=${cfg.buildkite.org}";
157
158 ExecStart = "${cfg.package}/bin/tack -addr ${cfg.listenAddr}";
159 Restart = "always";
160
161 # Light hardening. Tack only needs network access plus its
162 # state directory, so we lock the rest down.
163 DynamicUser = true;
164 NoNewPrivileges = true;
165 ProtectSystem = "strict";
166 ProtectHome = true;
167 PrivateTmp = true;
168 PrivateDevices = true;
169 ProtectKernelTunables = true;
170 ProtectKernelModules = true;
171 ProtectControlGroups = true;
172 RestrictAddressFamilies = ["AF_INET" "AF_INET6" "AF_UNIX"];
173 RestrictNamespaces = true;
174 LockPersonality = true;
175 MemoryDenyWriteExecute = true;
176 }
177 // cfg.extraServiceConfig;
178 };
179 };
180 }