Development¶
Everything builds through Nix. nix develop puts Go, controller-gen, the
envtest assets, kubectl, kind, k3d, protoc with its Go and Java
plugins, a JDK 21 and Gradle on the path at the versions this repository
pins, so nothing here depends on what is installed on the machine.
nix develop
make test # unit and envtest tests
make build # bin/spawnery-operator
make agent # both agent plugins and their JUnit suites
make e2e # the driven run: the operator in a real kind cluster
The targets¶
The first six are the commit loop and run anywhere. Everything from
agent-test down needs a container runtime and only works on x86_64-linux;
pass CONTAINER=podman if docker is not your runtime. Three reach the network
and are therefore part of no other target, not even make all: agent-deps,
publish and publish-chart.
| Target | What it does |
|---|---|
make test |
Unit and envtest tests, after manifests, generate, fmt, vet and the linters that guard what a compiler cannot: chart-lint, toolchain-lint, image-tag-lint, chart-version-lint, docs-length-lint and the three generated-docs checks |
make lint |
golangci-lint with errcheck and staticcheck, uncapped |
make build |
bin/spawnery-operator |
make manifests |
CRDs, RBAC, the chart templates, and the generated docs/reference pages |
make proto |
Go code under internal/agentpb from the .proto |
make agent |
Both agent plugins, with their JUnit suites as the check phase |
make docs |
The site, built through Nix; mkdocs build --strict inside it is the project's only link checker |
make docs-assets |
Vendors docs/assets/mermaid.min.js and builds docs/plugin-api/javadoc/; both gitignored, needed once per checkout before mkdocs serve; the Javadoc build costs about 34s cold |
make docs-serve |
mkdocs serve for writing, after docs-assets |
make agent-deps |
Regenerates agent/deps.json. Reaches Maven Central, so it is part of no other target |
make agent-test |
Both real images against the stub operator in cmd/spawnery-stubop |
make paper-pin |
Computes the Paper pin; paper-pin-check fails if nix/paper.nix is behind. purpur-pin does the same for Purpur, and pin-bump.yml runs both on Mondays and opens a pull request |
make purpur-image |
The Purpur base image; purpur-image-load, purpur-image-test |
make canvas-image |
The Canvas base image; canvas-image-load, canvas-image-test (the image checks without the startup cache) and canvas-agent-test (the agent phases against Canvas) |
make canvas-pin |
Computes the Canvas pin; canvas-pin-check fails if nix/canvas.nix is behind. The weekly bump runs it after Paper and Purpur and takes a pin that fails its tests back out of the pull request |
make e2e-worldsync-canvas |
The world-sync run on three kind nodes with the Canvas image; e2e-tutorial-canvas drives the tutorial path, including the login gate, on it |
make velocity-image |
The Velocity image, same three steps scoped to it alone |
make operator-image |
The operator's own image, same three steps |
make image-repro |
Builds each image twice and fails if the bytes differ |
make publish |
Copies the images to ghcr.io/spawnery/ with skopeo |
make publish-chart |
Pushes the Helm chart to oci://ghcr.io/spawnery/charts |
make e2e |
Builds a kind cluster, installs the chart, drives twenty scenarios |
make all |
proto manifests generate fmt vet test build agent |
Generated code¶
make proto regenerates the Go code under internal/agentpb from
proto/spawnery/agent/v1alpha1/agent.proto. It is checked in like
zz_generated.deepcopy.go, and make test does not regenerate it: after a
change to the .proto, run make proto and commit the diff with it.
The agent plugins¶
make agent (nix build .#agents) builds Paper's plugin and Velocity's (they
share the session loop, token source and channel construction in agent/common)
and runs both JUnit suites as the derivations' check phases.
agent/deps.json is the checked-in lockfile pinning every Maven artifact by
hash across the Gradle subprojects. make agent-deps regenerates it, and is
needed only when a build.gradle.kts under agent/ changes a dependency: it
reaches Maven Central, and a Nix build must never depend on the network.
make agent-test runs both real images against the Go stub operator in
cmd/spawnery-stubop and checks the handshake, the authorization header, the
player reports, the overlapping renewal and the bound on a session the operator
never answers. For the Velocity image it also checks that its readiness port stays
closed until a server list has arrived and opens once one does.
AGENT_TEST_ONLY=snapshot runs the world-sync snapshot phase alone, beside
AGENT_TEST_ONLY=voice for the voice phase.
The images¶
make image-test runs the game images (Purpur 26.3 and Velocity) offline
under the same constraints the podspec imposes, loading each first so the
target needs no separate build step of its own.
hack/image-test.sh asserts Paper's behaviour (config rewritten, plugin
loaded, nothing downloaded at start), which Purpur, a Paper fork, keeps.
The Paper image was deprecated in 0.2.15 and is no longer built from 0.23.0;
see the
release notes.
nix/paper.nix stays: Purpur patches with its Mojang jar, and
internal/render's tests read its repo.
Reproducibility¶
make image-repro is the standing check that the images are reproducible, worth
running again after any change to nix/paper.nix, nix/purpur.nix or nix/purpur-image.nix.
The plain build in front of each --rebuild is not redundant. --rebuild
compares a fresh build against the output already in the store, and with
nothing there it does not fail the check, it declines to run it: "some outputs
… are not valid, so checking is not possible". All three image derivations take
the working tree as their source: appending one line to a file in docs/ was
measured to change the derivation hash of purpur-image, velocity-image and
operator-image alike (agents was unaffected). So an edit almost anywhere
empties the store of them.
The operator's image¶
make operator-image-test runs the operator image under the constraints
charts/spawnery/templates/deployment.yaml imposes (non-root and a read-only
root filesystem) rather than more comfortable ones, plus --network none,
which is the script's own choice and not the Deployment's, and cheap here
because the run only asks the binary to print its usage. make image-repro
covers this image beside the other two and the agent jars.
Publishing¶
Five artefacts, three scripts, none of them part of another target.
make publish (hack/publish.sh) copies the images from their Nix
archives straight to ghcr.io/spawnery/ with skopeo, so the registry gets
what the flake describes, not what a previous podman load left in a local
store. It needs a GitHub token with write:packages.
DRY_RUN=1 still builds every image it was asked for (on a machine without
them cached, that is the expensive part), then prints what it would copy where,
needing no credential. FORCE=1 overwrites a tag that already exists, which it
otherwise refuses to do with exit 3. WRITE_DIGEST=1 writes the digest skopeo
copy reported into charts/spawnery/values.yaml's image.digest key. The
chart is the only installation form, so the only place a digest means anything.
Each Purpur image also gets its startup cache. Before copying the image, the
script loads it into a local container store (CONTAINER, default docker),
trains it with hack/aot-train.sh (two boots of the server) and pushes the
result as ghcr.io/spawnery/purpur-aot:<tag> first: an operator running with
--aot-cache mounts that image, and its servers do not start while it cannot
be pulled. DRY_RUN=1 trains too. Canvas gets no startup cache.
Every image carries org.opencontainers.image.source naming this repository,
which links a new ghcr.io package to it. ghcr.io/spawnery/canvas was created
by the v0.30.0 release at 06:45 UTC on 2026-10-27 and answered an anonymous
manifest request for 26.3-0.30.0 with 200 at 07:29 UTC. purpur-aot, created
on 2026-10-06, answered the same request for 26.3-0.30.0 with 200 on
2026-10-27; its visibility right after creation was not measured. If an
anonymous pull of a new package answers 401, set it to public in the
organisation's package settings.
make publish IMAGES=operator-image publishes one image, the ordinary case:
flake.nix keeps operatorVersion apart from imageVersion, so a reconciler
fix makes exactly one tag new. Asking for all three stops at the first tag
already published and never reaches it; FORCE=1 gets past that only by
re-pushing about 1.4 GB over tags that were right.
.github/workflows/release.yml therefore invokes the script once per image on
a v* tag.
make publish-chart (hack/publish-chart.sh) pushes the chart to
oci://ghcr.io/spawnery/charts/spawnery. It packages from git archive HEAD,
not the working tree, because on the release runner those two stop being the
same file: WRITE_DIGEST=1 rewrites charts/spawnery/values.yaml minutes
before the chart step runs, and archiving HEAD makes the ordering irrelevant
instead of a comment somebody must keep obeying. It also refuses, with no
FORCE=1 escape, to publish a chart whose committed image.digest is
non-empty. That is the one state nothing else catches, because internal/rbacaudit's
TestTheOperatorImageIsNotAMutableTag returns early when a digest is set
instead of failing. Its "already there" refusal is exit 3 too, the ordinary
outcome here since most tags change nothing under charts/.
make publish-chart-test drives nine cases past it, none against a registry.
hack/publish-api.sh publishes cloud.spawnery:spawnery-api to Maven Central,
the only artefact that is not a container, so a plugin can compile against the
API without a checkout. It is a script rather than a Gradle plugin because any
plugin speaking the Central Portal's HTTP API is third-party and would enter
agent/deps.json, to save one curl. Two of its inputs are secrets nobody
can grant from inside a workflow: a Central Portal token pair and an
ASCII-armoured signing key, both belonging to a person. release.yml therefore
skips this step rather than failing it when they are absent, and says which
artefact it left out; a hard failure would hold every image and the chart
hostage to a secret that has nothing to do with them. DRY_RUN=1 builds the
bundle, prints what would go where, and needs neither.
A tag whose whole change is under charts/ publishes a chart and no image, and
is a correct release. Both image numbers therefore have gaps, and none is a
miscount: imageVersion reads 0.2.5, 0.2.7, 0.2.9, 0.2.10, 0.2.12, 0.2.13,
operatorVersion reads …, 0.2.9, 0.2.11, 0.2.12, and the chart's own
version tracks neither: it moves whenever anything under charts/ does,
while its appVersion stays with the operator it deploys. A missing number is
the record of a release that built nothing on that side. A local make publish
is for the case a tag cannot cover.
The end-to-end run¶
make e2e (hack/e2e.sh) installs charts/spawnery with helm install
--create-namespace into platform-system, a namespace sharing nothing with the
chart's documented default, spawnery-system. The CRDs come from that install
too, so there is no separate apply. Its Go test package drives the
operator under its own ServiceAccount, then reads the whole operator log and
fails on is forbidden:. The operator runs in the cluster here, from its own
image, so nothing hand-builds a Service; that is the difference between this and the
local flow below. Under rootless Podman the invocation is:
systemd-run --scope --user --property=Delegate=yes -- \
nix develop -c env KIND_EXPERIMENTAL_PROVIDER=podman make e2e
E2E_KEEP=1 leaves the cluster standing afterwards and prints its
KUBECONFIG; a failed run dumps the operator log, the objects and the events
before tearing down.
Running this image accepts
Mojang's EULA on your behalf: the entrypoint
writes eula=true, because Paper does not start otherwise.
Trying it locally against kind¶
This is the hand-driven flow, running the operator outside the cluster
through go run. It gives what make e2e does not (a real Paper image, a
server that reaches Ready, an agent that reports players) at the price of the
workarounds below, which make e2e avoids because its Service has a selector.
Under rootless Podman (measured with 5.8.4) k3d cannot bring up a cluster at
all: its tools node bind-mounts the runtime socket to /var/run/docker.sock
inside itself, and rootless Podman refuses to create that mount point (mkdir
/var/run/docker.sock: permission denied); no DOCKER_HOST fixes it, the
failure being in the tools node's own container creation rather than in the
client reaching the socket. The flow below uses kind under
KIND_EXPERIMENTAL_PROVIDER=podman, which works against the same socket.
kind needs cgroup delegation under systemd as a regular user, hence the
systemd-run --scope --user --property=Delegate=yes wrapper around every kind
command below: without it kind refuses with a Delegate=yes error even when
that property is already set on the user's systemd service; the scope is what
its check looks for.
Running through go run there is no POD_NAMESPACE from the downward API, so
--operator-namespace has to be set explicitly; without it the process refuses
to start (see validateAgentFlags), because the serving certificate would
otherwise carry the wrong SANs.
One gap decides whether a Server reaches Ready: the pod dials
spawnery-operator.<ns>.svc:9443 and nothing creates that Service, the operator
being outside the cluster where no selector could find it. A selector-less
Service with a hand-written Endpoints pointing at the host closes it. The
serving certificate already carries that DNS name, so TLS verifies against the
CA the pod was given.
Which address goes into those Endpoints depends on the runtime. With a real
Docker daemon it is the bridge gateway, 172.17.0.1. Under rootless Podman it
is none of the obvious candidates: the gateway of the kind network
(10.89.0.1 here) lives inside the rootless network namespace, where the
operator is not listening and a connection is refused, and the one address that
does reach the host (the pasta link-local 169.254.1.2, which Podman also
publishes as host.containers.internal) is rejected by the API server in both
Endpoints and EndpointSlice with may not be in the link-local range. What
works, measured, is one more container on the same Podman network relaying to
the host: it gets a routable address on that network, and pods reach it.
systemd-run --scope --user --property=Delegate=yes \
env KIND_EXPERIMENTAL_PROVIDER=podman \
nix develop -c kind create cluster --name spawnery-dev
systemd-run --scope --user --property=Delegate=yes \
env KIND_EXPERIMENTAL_PROVIDER=podman \
nix develop -c kind load docker-image ghcr.io/spawnery/purpur:26.3-0.22.0 --name spawnery-dev
nix develop -c kubectl apply -f config/crd/bases
nix develop -c kubectl apply -f config/samples/network.yaml
# --leader-elect=false is not needed to *start*, but it is what you want here:
# with leader election on, a local run contends for the same lease as an
# operator already installed in that namespace and waits it out first.
nix develop -c go run ./cmd/spawnery-operator --leader-elect=false --operator-namespace minecraft &
# Rootless Podman only. With a real Docker daemon, skip this and use
# 172.17.0.1 as the endpoint address below.
podman run -d --name spawnery-relay --network kind \
-v /nix/store:/nix/store:ro \
--entrypoint "$(nix build --no-link --print-out-paths nixpkgs#socat)/bin/socat" \
ghcr.io/spawnery/purpur:26.3-0.22.0 \
TCP-LISTEN:9443,fork,reuseaddr TCP:host.containers.internal:9443
RELAY_IP=$(podman inspect spawnery-relay \
--format '{{.NetworkSettings.Networks.kind.IPAddress}}')
nix develop -c kubectl apply -f - <<EOF
apiVersion: v1
kind: Service
metadata:
name: spawnery-operator
namespace: minecraft
spec:
ports:
- name: agent
port: 9443
targetPort: 9443
protocol: TCP
---
apiVersion: v1
kind: Endpoints
metadata:
name: spawnery-operator
namespace: minecraft
subsets:
- addresses:
- ip: $RELAY_IP
ports:
- name: agent
port: 9443
protocol: TCP
EOF
sleep 90
nix develop -c kubectl get networks,servergroups,servers,pods -n minecraft
The relay needs only a rootfs, which is why the Paper image stands in for one;
socat comes out of the mounted Nix store.
The first server can take a good half minute: if the ServerGroup meets its
network before the Network controller has accepted it, it retries only after
networkRetryInterval (30 seconds). The 90 seconds also cover Paper's own start
(about seven seconds to a first answered ping) and the agent's handshake.
Loading the image beforehand is its own wait: at 26.2-0.2.1 the Paper image is
372 MB as a tarball and the Velocity one 170 MB. They were 735 MB and 533 MB until 2026-08-25, when both
stopped shipping a whole headless JDK and started shipping a runtime jlink'd
to the modules each actually resolves (see nix/paper-jre.nix and
nix/velocity-jre.nix).
Expected, as measured on 2026-08-10 against kind v1.36.1 under rootless
Podman:
network productionwithAccepted=TrueandSERVER GROUPS 1,servergroup lobbyin phaseReadywithREADY 1andFREE SLOTS 100;READYisstatus.readyReplicas,- a pod
lobby-xxxxinRunningwithREADY 1/1: the readiness probe spoke a real server list ping to a real Paper process, - a
server lobby-xxxxin phaseReadywithSLOTS 100,PLAYERS 0andREGISTERED true.SLOTSis what the agent reported fromSPAWNERY_MAX_PLAYERS,PLAYERSwhat it counted: zero, because nobody can join yet.
If the Server stops in Starting, the agent cannot reach the operator;
kubectl logs on the pod shows the reason, so far always the
Service/Endpoints pair above rather than the agent.
The session renews after eight minutes (--agent-session-renew-after), and if
the replacement stream did not overlap the outgoing one, the server would drop
out of Ready on that rhythm. Measured over thirteen minutes:
nix develop -c kubectl get server lobby-xxxx -n minecraft \
-o jsonpath='{.status.readinessLosses} {.status.readySince} {.status.playersUpdatedAt}'
# 0 2026-08-09T22:37:36Z 2026-08-09T22:49:57Z
readinessLosses still zero and readySince still the original timestamp,
while playersUpdatedAt keeps moving. The renewal happened and cost the
server nothing.
Afterwards, clean up:
kill %1
podman rm -f spawnery-relay
systemd-run --scope --user --property=Delegate=yes \
env KIND_EXPERIMENTAL_PROVIDER=podman \
nix develop -c kind delete cluster --name spawnery-dev