1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# 0002: Kubernetes executor for knotless-run
Status: implemented and dogfooded
Depends on: 0001
## Working rules
- Do not open pull requests, merge requests, or send patches to any
upstream repository, for any project.
- Work only in this repository and in the local forks:
- `~/src/knotless-fork` for `knotless-run`
- `~/src/socialweb-computer-kcp-fork` for any scoped-credential or
tenant-cluster change
- Each fork has `upstream` for the original project and `fork` for our
own knotless remote. Treat `upstream` as read-only: fetch from it if
needed, never push to it.
- Push only to our own repositories under
`johnandersen777.bsky.social`, over `knotless://` remotes, as work
lands. Never push to `upstream`.
## Goal
Make a Kubernetes Job a first-class `knotless-run` executor, so a
repository can name a socialweb-computer runner and have each workflow
run on a hosted tenant k3s cluster.
The runner daemon stays on a stable host. It keeps `state.json`,
`secrets-key.json`, caches, mirrors and login sessions. The Job pod is
only the run's environment, and its Pending state is the tenant's scale
signal.
Non-goal: run the `knotless-run` daemon inside the tenant cluster. That
would keep a node alive, and it would lose runner state when the node is
torn down.
## Selection model
No scheduler changes are needed.
- The repository record's `runners` array names the runner DIDs.
- A runner with `--discover` watches repositories that name it.
- Every named, watching runner runs every workflow unless the workflow's
`runners:` field pins one.
- To avoid duplicate checks, name one runner or pin workflows.
- The merge-queue lander is the first named runner that may push, else the
first named runner. It must run `--queue`.
- `--default-runner` only sets the runner for new repositories.
## M1: executor seam
Status: done
- Add a Kubernetes backend to `crates/knotless-run/src/executor.rs`.
- Prefer a small trait (`start`, `command`, `stop`, `cleanup`) over a
growing closed enum, but a variant is acceptable for the first change.
- Add options: `--executor kubernetes`, `--kubeconfig`, `--context`,
`--namespace`, `--image`, `--cpu-request`, `--memory-request`,
`--service-account`, `--node-selector`.
- Keep docker as the default.
- Reject the Kubernetes backend for `--pulls` and `--queue` until it has
an isolation story for untrusted code.
- Update `docs/reference/cli.md` and `docs/design/runner.md`.
Acceptance:
- `knotless-run --executor kubernetes` starts, runs one step, writes a
check, and stops.
- `cargo fmt --check`, clippy and the existing test suite pass.
- The docker path is unchanged.
## M2: checkout, steps and outputs
Status: done
- Move the PoC's tar-over-`kubectl exec -i` into the executor.
- Preserve file modes, symlinks, executable bits and empty directories.
- Stream shared steps into `/steps`, each at `/steps/<n>`, as docker does.
- Stream `/outputs` back before the Job is deleted.
- Use an `emptyDir` for `/work`, `/steps` and `/outputs`; do not use a
hostPath.
- Bound upload and download by size and by the run timeout.
- Reuse the existing `check` output code so images become check outputs.
Acceptance:
- The existing `test` workflow's checkout works unchanged.
- A 100 MiB checkout and a 10 MiB output each complete.
- A missing or unreadable path fails the run with an `error` check, not a
stuck Job.
## M3: lifecycle, logs and cancellation
Status: done
- Job: `backoffLimit: 0`, `activeDeadlineSeconds` from the run timeout,
`ttlSecondsAfterFinished` when not keeping, labels
`dev.knotless.runner` and `dev.knotless.run`, annotations with the raw
runner DID and check key.
- Pod: `restartPolicy: Never`, no service account token, no service links.
- Wait for the pod Ready, not just the Job.
- Stream `pods/log --follow` or the step's exec output into the run log.
- On step timeout or cancellation, delete the Job, not only kill the local
`kubectl`.
- On runner start, delete Jobs labeled with this runner that are not in
`going`, mirroring the docker executor's `remove_left`.
- Prune old Jobs with the tenant's TTL, and always delete on success,
failure, timeout and cancellation unless `--keep`.
Acceptance:
- A hanging step is killed at its timeout and its check is `failure` with
no exit code.
- A cancelled run leaves no Job or pod.
- A runner restart removes its orphaned Jobs and starts clean.
- Logs reach the check's `log` blob up to the 1 MB cap.
## M4: variables and secrets
Status: done
Today docker passes variable values through the client's environment,
never argv, and per-step secrets through `docker exec -e`. Kubernetes
`exec` has no `-e`, and a `Secret` object would put the values in the
tenant API server and let every step read them.
Design: the step payload goes over the exec stream.
- For each step, build a small POSIX shell payload:
`export NAME='value'` for the run's variables and the step's granted
secrets, then `exec sh -ec '<step script>'`.
- Shell-quote every value with single quotes. A value containing a single
quote becomes `'\''`; newlines are valid inside single quotes. Shell
variables cannot contain NUL.
- Run it as `kubectl exec -i <pod> -- sh -s` and write the payload to
stdin. Values are never in argv, the Job spec, the pod spec, a
Kubernetes `Secret`, the environment of the runner's `kubectl` process,
or a file in the pod.
- No `Secret` object is created. The runner therefore needs no
`secrets` RBAC on the tenant cluster.
- Keep the no-secrets rule for pull and batch runs exactly as today.
- Mask granted values in logs with the existing longest-first masking.
- After a step that was given secrets, list `/proc` before and after and
kill the processes the step left, as the docker executor does. A
background process must not carry a secret into the next step.
- The same stream carries non-secret run variables and `rerun.env` values.
Rejected alternatives:
- A per-run `Secret` with `envFrom`: simple, but values live in the API
server, every step sees every secret, and the runner gains
`secrets` permission.
- Values in `kubectl exec -- env NAME=value ...`: visible in the process
list and easy to log.
- Secret files on the emptyDir: recoverable by later steps and by
anything that can read the pod filesystem.
Acceptance:
- A push run with a granted secret sees it; a pull or batch run does not.
- No Kubernetes `Secret` is created by a run.
- Secret values do not appear in the Job spec, pod spec, argv, a pod
file, the check log or the check record.
- A workflow that asks for an ungranted secret logs the name only.
- A secret-bearing step that leaves a background process has it killed
before the next step.
- A step's environment contains only its own granted values.
## M5: caches
Status: done
A Kubernetes cache is a tar stream between the runner's cache store and
the pod. Before the steps, the run's base is streamed into the pod at the
cache's path. After the steps, and before the Job is deleted, that path is
streamed back into the run's upper layer. The existing promote and keep
rules then run unchanged: a passing push promotes, a trusted pull's pass
may be kept, and an untrusted run's layer is dropped.
- No PV, object store or cache service is needed.
- The first run of a cache is cold.
- `uses_caches` is true for docker and kubernetes; other executors lease
no cache and create no cache directories.
Acceptance:
- A live test ran a step that wrote `/cache/warm`, then a second run saw
it and reported `warm`.
- An untrusted run gets an apart base, exactly as under docker.
## M6: trust and isolation
Status: done
- Pull and batch runs get no secrets, no cache keep and an apart cache
base.
- One Job per node by required hostname anti-affinity, so each run is
alone in its Firecracker guest.
- No hostPath, no privileged containers, no host network, no service
account token, no privilege escalation.
- The runner applies a `NetworkPolicy` selecting its runner label and
denying all ingress, so one run's pod cannot reach another. Egress is
not restricted.
- Tenant node shape: 2 vCPU, 4 GiB memory, 50 GiB rootfs. Resource
requests above it leave the pod Pending, which is an `error` check.
Acceptance:
- A pull run cannot read another run's secrets; the live test with the
scoped credential applied the policy and ran Jobs in `knotless-runs`.
- A malicious workflow cannot reach the runner host or another tenant.
- Requests fit the node, or the run is visibly unschedulable.
## M7: credentials and RBAC
Status: done
Two paths exist:
- Runner on the kcp/gateway host: `internal tenantcredential`, scoped
client cert, loopback `https://status.url`.
- Runner off host: the gateway's `getKubeconfig` is cluster-admin. Run
`scripts/scoped-kubeconfig.sh` once with that admin kubeconfig. It makes
a ServiceAccount, a namespaced Role and a 24-hour token kubeconfig, and
checks that the token can create Jobs but cannot read nodes.
The Role grants Jobs, pods, pods/log, pods/exec, events and
NetworkPolicies, in one namespace. It grants no secrets. The runner needs
no other cluster permission. Refresh the token before 24 hours by running
the script again.
Acceptance:
- The live test ran with the scoped kubeconfig in `knotless-runs`.
- `auth can-i get nodes` is `no`; `auth can-i create jobs` is `yes`.
- A leaked pod has no service account token to mint another credential.
## M8: queue and runner selection
Status: done
- Keep queue landing on a stable runner with persistent state.
- Allow `--queue` on the Kubernetes backend only after M6.
- Keep exactly one lander for a repository; document the current
`lander()` behavior where a named runner that may push is chosen even if
it is not running.
- Pin heavy workflows with `runners:` while dogfooding, and name the
socialweb runner in `repo.runners`.
- Do not run two runners for the same repository during rollout unless
duplicate checks are acceptable.
Acceptance:
- `--pulls` and `--queue` are accepted with the Kubernetes executor.
- Pull and batch runs get no secrets and an apart cache base, as under
docker; one Job per node is the isolation boundary.
- The lander rules are unchanged from the docker executor.
## M9: observability
Status: done
- Check logs stream live through `dev.knotless.putRunOutput` as they do
under docker.
- Check outputs are images from `/outputs`.
- Add structured log lines for Job start, pod Ready, step start, step end,
Job delete and orphan cleanup.
- Add the runner's existing telemetry fields for the Job name, namespace
and image.
- Add a startup sweep count and a cleanup failure count.
Acceptance:
- A failed run's log names the failing step and exit code.
- A missing image or unschedulable pod produces a clear `error` check.
- Orphan cleanup logs what it deleted.
## M10: tests and proof
Status: done
- Unit tests with a fake `kubectl`, as in 0001.
- Manifest tests for labels, resources, TTL, anti-affinity and
`activeDeadlineSeconds`.
- A live test against `knotless-poc`: one Job, two steps, outputs, delete,
node scale to zero.
- A live `knotless-run --executor kubernetes --pulls --queue` against a
disposable repository once M6 lands.
- A failure matrix: bad image, image pull denied, pod unschedulable, step
nonzero, step timeout, runner killed mid-run, node lost mid-run.
- Keep these out of the normal knotless CI until they have a fake API
server; do not make CI depend on a live market.
Acceptance:
- The failure matrix leaves no Job, pod, Secret or cache volume behind.
- The live proof records the node count before and after.
## M11: rollout
Status: done
1. Land M1-M3 behind `--executor kubernetes`; docker remains default.
2. Dogfood on this repository, pinned by `runners:`.
3. Run the repository's full workflow set on the tenant cluster.
4. Add docs: `docs/guide/kubernetes-runner.md`, updates to
`docs/design/runner.md`, `docs/reference/cli.md`.
5. Offer the executor to other repositories; keep docker as fallback.
6. Only then consider `--queue` for untrusted work.
Acceptance:
- Every workflow this repository defines runs on the socialweb runner:
`check`, `test`, `docs`, `package` and `poc`.
- The tenant returns to zero nodes after the runs.
- Rolling back to docker needs only a config change.
## Open questions
- Whether a tenant administrator can inspect `pods/exec` streams. Assume
they can; use a runner-owned cluster, and keep secrets out of every
durable object.
- Whether one Job per node is mandatory for all untrusted work or only
when the workflow runs untrusted code.
- Cost and latency of a market VM per job versus a warmed node pool.
- Multi-tenant runners: one cluster per runner account, or one cluster
per trust domain.
- Interaction with `superseded: finish` and runner handover.
- Image pull egress from tenant nodes and registry authentication.
## Implementation evidence
- `crates/knotless-run/src/executor.rs`: `Executor::Kubernetes`,
`Session::Kubernetes`, Job manifest, kubectl client, tar transfer,
stdin step payload, `/proc` cleanup, orphaned-Job cleanup.
- `crates/knotless-run/src/lib.rs`: `--executor kubernetes`, kubeconfig,
context, namespace, kubectl, service account and node selector flags;
pull and queue validation.
- `crates/knotless-run/tests/kubernetes_live.rs`: ignored live test that
runs a Job on a tenant cluster, checks the output file, deletes the Job
and checks orphan cleanup.
- `crates/knotless-run/src/executor.rs` tests: manifest, label and payload
shape, a full fake-kubectl run, secret-bearing step cleanup, orphan
deletion.
- Docs: `docs/guide/kubernetes-runner.md`, `docs/design/runner.md`,
`docs/reference/cli.md`; change file `.changeset/kubernetes-executor.md`.
- Fork commits: `b1e831a`, `85abaa3`, `afaf890`, `84e1733` and
`5762212` in `knotless://johnandersen777.bsky.social/knotless`.
- `scripts/scoped-kubeconfig.sh` in this repository: a ServiceAccount,
namespaced Role and 24-hour token kubeconfig, checked to allow Jobs and
deny nodes.
## Verification run
`cargo test -p knotless-run --lib`: 56 passed.
`cargo clippy -p knotless-run --all-targets -- -D warnings`: clean.
Live executor test, on the scoped kubeconfig in `knotless-runs`:
```bash
KNOTLESS_K8S_LIVE=1 KNOTLESS_K8S_NAMESPACE=knotless-runs \
KUBECONFIG=$HOME/.tmp/knotless-runner-k8s/tenant/knotless-runner.kubeconfig.yaml \
cargo test -p knotless-run --test kubernetes_live -- --ignored --nocapture
```
Passed in 81.65 s. It ran two Jobs: the first wrote `/cache/warm` and
reported `cold`; the second saw the cache and reported `warm`. It applied
the NetworkPolicy, deleted both Jobs, and the orphan sweep ran.
## Dogfood proof
The repository `knotless://johnandersen777.bsky.social/knotless-runner-k8s`
names the alice runner. Its full workflow set ran at commit
`184f1b1c90b67720a51708e1fa61cff45c54f4da` through the new executor, with
the scoped kubeconfig and the default-deny NetworkPolicy:
| Workflow | Status | Step | Exit | Time |
| --- | --- | --- | --- | --- |
| `check` | success | shell | 0 | 366 ms |
| `test` | success | cargo | 0 | 1382 ms |
| `docs` | success | files | 0 | 419 ms |
| `package` | success | archive | 0 | 465 ms |
| `poc` | success | dogfood | 0 | 436 ms |
The Jobs were deleted, and the tenant returned to zero nodes. The `test`
workflow used `cache: [target]`, so its second run started from the cache
streamed back by the first.
Earlier, the one-step `poc` workflow proved the same path at
`c10c79921c3f3f670706310575d24da2a3727916`. The first attempt failed
because `KNOTLESS_RUN_OUTPUTS` was empty; `85abaa3` carries the run
variables into the Kubernetes step payload. A later concurrent start hit
`AlreadyExists` on the NetworkPolicy; `84e1733` treats an existing policy
as success.
The tenant cluster `knotless-poc` was deleted after the proof.