Spindle
Spindle is the CI runner of Tangled, a social coding platform built on ATProto. Registering my own spindle means the pipelines of my Tangled repositories run on mocha instead of someone else's hardware.
It runs in the spindle namespace on mocha, behind
spindle.mocha.thoughtless.eu.
Building the image myself
There is no published container image for spindle, and that is not an oversight I could work around — I checked before writing any manifest:
- The project does publish images, but on atcr.io, an ATProto-native registry,
and only for knots:
atcr.io/tangled.org/knot:latest.atcr.io/r/tangled.org/spindlereturns a 404, andatcr.io/u/tangled.orglists exactly one image. tngl/spindle:v1.10.0-alphais advertised in the README of thespindlebranch ofpsychedeli.ca/knot-docker, but it was never pushed — the wholetnglDocker Hub namespace only containsknot..tangled/workflows/build.ymlupstream only runsgo build -o spindle.out ./cmd/spindle. No registry login, no push.flake.nixhas nodockerTools/buildLayeredImagecall anywhere.
One trap worth naming: nix build .#spindle-alpine-image looks like it produces a spindle
container. It does not — those *-image attributes build microVM guest rootfs bundles (a
minirootfs plus vmlinuz-virt, tarred up) that spindle boots to run CI jobs inside. Wrong
direction entirely.
So .github/workflows/spindle-image.yaml clones the upstream monorepo at a tag and builds
localinfra/spindle.Dockerfile into ghcr.io/qjoly/spindle. It is workflow_dispatch only, with
the tag as an input:
gh workflow run spindle-image.yaml -f ref=v1.16.1-alpha
Then bump the tag in mocha/apps/spindle/manifests/deployment.yaml. Renovate does not track this,
since upstream does not live on GitHub.
That Dockerfile is marked "Development only. Not for production use." I use it anyway: it is a
plain multi-stage Go build, and the alternative is maintaining my own. The git clone works over
plain HTTPS — it redirects to knot1.tangled.sh.
Which engine, and why the dind sidecar
Spindle ships two engines, and both still work — the microVM one is "an upgrade from the existing
Nixery engine while staying fully compatible with it, so if you already have a working Nixery
workflow, just change nixery to microvm and it will work".
I picked nixery, because it needs nothing but a Docker API: images are pulled on demand from
nixery.tangled.sh. The microvm engine would require me to build NixOS/Alpine guest images with
Nix and ship them into a PVC, since nobody distributes them.
Upstream says spindle must run natively on the host because "Docker-in-Docker hasn't been implemented yet". That turns out not to apply to a sidecar, and the code says why:
spindle/engines/nixery/engine.goconnects withclient.NewClientWithOpts(client.FromEnv, ...), so it honoursDOCKER_HOST.- Its
ContainerCreatecall makes no host bind mounts:/tmpis a tmpfs and the workspace lives inside the workflow container. The only bind it can ever request comes fromSPINDLE_SERVER_DOCKER_SOCKET, which I leave unset.
No shared filesystem is needed, so a docker:dind sidecar reachable over TCP is enough. Both
containers share the pod network namespace, so 127.0.0.1:2375 works across them.
mocha does have /dev/kvm and the vmx CPU flag, so switching to the microVM engine later is
possible. The node even advertises devices.kubevirt.io/kvm as a schedulable resource, which is a
cleaner way to get the device than a hostPath.
Making dind listen on loopback only
The sidecar first crashed with exit code 1. Reading
dockerd-entrypoint.sh
rather than guessing explained it. The guard is:
# no arguments passed
# or first arg is `-f` or `--some-option`
if [ "$#" -eq 0 ] || [ "${1#-}" != "$1" ]; then
My arguments started with -, so the script prepended its own hosts and appended mine:
dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --host=tcp://127.0.0.1:2375 ...
Two binds on port 2375, so the daemon refused to start.
The crash was hiding the real problem. Had I simply dropped my arguments, dind would have started
fine — exposing an unauthenticated root-equivalent Docker API on the pod IP, reachable from
anywhere in the cluster. Workflow containers are created by that very daemon, sit on the same
network, and get host.docker.internal:host-gateway, so untrusted pull-request code could have
reached the socket and escaped to root on the node.
Passing dockerd as the first argument skips the defaults block while still hitting
if [ "$1" = 'dockerd' ] further down, which keeps the actual dind setup. The result is a strict
loopback bind:
$ kubectl -n spindle logs deploy/spindle -c dind | grep "API listen"
API listen on /var/run/docker.sock
API listen on 127.0.0.1:2375
--tls=false states the intent explicitly and skips the deliberate startup delay Docker adds when
it thinks you forgot about TLS.
Memory limits are not decorative
Workflow containers are children of that dockerd, so their memory counts against the dind
container's cgroup, not spindle's. The limits are sized together:
| Setting | Value |
|---|---|
SPINDLE_NIXERY_PIPELINES_MAX_CONCURRENT_WORKFLOWS |
2 |
SPINDLE_NIXERY_PIPELINES_MAX_JOB_MEMORY_MB |
4096 |
dind limits.memory |
10Gi |
Raise either of the first two without raising the limit and the daemon gets OOM-killed mid-job. The upstream defaults (8 concurrent × 6144 MB) would need 48Gi.
The Docker data directory is an emptyDir with a 30Gi limit — a throwaway image cache. Worth moving
to a PVC if re-pulling from nixery on every restart becomes annoying.
What spindle serves over HTTP
Four routes, from spindle/server.go:
| Route | What it is |
|---|---|
/ |
an embedded motd, answered without touching the database, jetstream or docker |
/events |
event stream |
/logs/{knot}/{rkey}/{name} |
workflow logs, read back by the appview |
/xrpc/* |
the XRPC API |
/ is the useful probe target, precisely because it depends on nothing else. Worth spelling out
because grep for route registrations is easy to get wrong here: they use mux.HandleFunc(, so a
pattern like mux\.(Get|Handle|Mount)\( matches only the /xrpc mount and makes it look like the
sole route.
PodSecurity
The dind sidecar is privileged, and mocha enforces the baseline PodSecurity profile cluster-wide
(a Talos default), which forbids exactly that. Symptom: the Argo Application reports Synced while
the ReplicaSet sits at DESIRED 1 / CURRENT 0. The reason only shows up in its events:
Error creating: pods "spindle-…" is forbidden: violates PodSecurity "baseline:latest":
privileged (container "dind" must not set securityContext.privileged=true)
Beware of testing this with a server-side dry run against default — that namespace already carries
enforce: privileged, so it passes and tells you nothing.
The managedNamespaceMetadata pattern used by crowdsec did not work here. The
field was applied to the Application, but the namespace had already been created by an earlier sync
via CreateNamespace=true, and Argo never retrofits the labels onto it — not even after
argocd.argoproj.io/refresh=hard. It does not appear among the Application's managed resources at
all.
So the namespace is declared explicitly in manifests/namespace.yaml, the way bank-vaults does
it, and gets applied like any other resource. managedNamespaceMetadata was removed to keep a
single source of truth.
Log directory
SPINDLE_SERVER_LOG_DIR stays at its default /var/log/spindle, and that is load-bearing.
spindle/models/logger.go opens log files with os.OpenFile and no MkdirAll, so the
directory has to exist beforehand. The only thing that creates it is the image entrypoint, with that
path hardcoded. Pointing the variable elsewhere silently breaks log writing.
Both the SQLite databases and the logs live on one PVC, mounted twice with different subPaths.
Registering the spindle
Nothing needs to be published for did:web to work. serviceauth.DidWeb() derives
did:web:spindle.mocha.thoughtless.eu from the hostname, which suggests a DID document is required
— but the codebase never serves /.well-known/did.json anywhere. That
DID is only ever used as the aud claim when validating inbound service-auth JWTs.
Registration happens in the Tangled UI (settings → spindles), which writes an sh.tangled.spindle
record to my PDS. Spindle is already subscribed to it over jetstream, so no redeploy is needed.
Health check, unauthenticated:
$ curl https://spindle.mocha.thoughtless.eu/xrpc/sh.tangled.owner
{"owner":"did:plc:hqnyog7skad6m4aejb2yujxy"}
One harmless error in the logs
ERRO spindle: failed to save cursor on shutdown system=tap component=firehose error="context canceled"
This is upstream cosmetics, not a misconfiguration. spindle/tap_drain.go shuts the embedded tap
down on purpose once there is nothing left to backfill (stop() then embedTap.Shutdown()), and
the cursor save races that context cancellation. It fires because no repositories are attached yet,
and goes quiet afterwards.