~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless-runner-k8s

0
0
397 lines · 15.6 KB Raw History
# 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.