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-forkforknotless-run~/src/socialweb-computer-kcp-forkfor any scoped-credential or tenant-cluster change
- Each fork has
upstreamfor the original project andforkfor our own knotless remote. Treatupstreamas read-only: fetch from it if needed, never push to it. - Push only to our own repositories under
johnandersen777.bsky.social, overknotless://remotes, as work lands. Never push toupstream.
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
runnersarray names the runner DIDs. - A runner with
--discoverwatches 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-runneronly 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
--pullsand--queueuntil it has an isolation story for untrusted code. - Update
docs/reference/cli.mdanddocs/design/runner.md.
Acceptance:
knotless-run --executor kubernetesstarts, 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 -iinto the executor. - Preserve file modes, symlinks, executable bits and empty directories.
- Stream shared steps into
/steps, each at/steps/<n>, as docker does. - Stream
/outputsback before the Job is deleted. - Use an
emptyDirfor/work,/stepsand/outputs; do not use a hostPath. - Bound upload and download by size and by the run timeout.
- Reuse the existing
checkoutput code so images become check outputs.
Acceptance:
- The existing
testworkflow'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
errorcheck, not a stuck Job.
M3: lifecycle, logs and cancellation
Status: done
- Job:
backoffLimit: 0,activeDeadlineSecondsfrom the run timeout,ttlSecondsAfterFinishedwhen not keeping, labelsdev.knotless.runneranddev.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 --followor 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'sremove_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
failurewith 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
logblob 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, thenexec 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 -sand write the payload to stdin. Values are never in argv, the Job spec, the pod spec, a KubernetesSecret, the environment of the runner'skubectlprocess, or a file in the pod. - No
Secretobject is created. The runner therefore needs nosecretsRBAC 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
/procbefore 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.envvalues.
Rejected alternatives:
- A per-run
SecretwithenvFrom: simple, but values live in the API server, every step sees every secret, and the runner gainssecretspermission. - 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
Secretis 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_cachesis 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 reportedwarm. - 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
NetworkPolicyselecting 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
errorcheck.
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, loopbackhttps://status.url. - Runner off host: the gateway's
getKubeconfigis cluster-admin. Runscripts/scoped-kubeconfig.shonce 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 nodesisno;auth can-i create jobsisyes.- 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
--queueon 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 inrepo.runners. - Do not run two runners for the same repository during rollout unless duplicate checks are acceptable.
Acceptance:
--pullsand--queueare 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.putRunOutputas 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
errorcheck. - 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 --queueagainst 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
- Land M1-M3 behind
--executor kubernetes; docker remains default. - Dogfood on this repository, pinned by
runners:. - Run the repository's full workflow set on the tenant cluster.
- Add docs:
docs/guide/kubernetes-runner.md, updates todocs/design/runner.md,docs/reference/cli.md. - Offer the executor to other repositories; keep docker as fallback.
- Only then consider
--queuefor untrusted work.
Acceptance:
- Every workflow this repository defines runs on the socialweb runner:
check,test,docs,packageandpoc. - 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/execstreams. 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: finishand 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,/proccleanup, 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.rstests: 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,84e1733and5762212inknotless://johnandersen777.bsky.social/knotless. scripts/scoped-kubeconfig.shin 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:
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.