Runbook: milestone 3's two proofs¶
Status: written at the end of milestone 3c (2026-08-11), for a x86_64-linux
machine with rootless Podman, the same shape docs/development.md's local-cluster
section documents. Corrected 2026-08-12 after the first real run against a
kind cluster: six defects in the procedure below stopped that run at
various points and are fixed in place rather than noted as a fork, and §10 is
new, added for the manual proof this run could not attempt. Run a second time
on 2026-08-13, on a different machine, §0 through §7 plus §10: every section
ran as written and needed no correction, and both proofs §10 was written for —
criterion 8 and the manual criterion-9 retry — landed. What that run measured
is in docs/handover-milestone-4.md under "The manual session"; the runbook
below is unchanged by it.
Design §10 states milestone 3's acceptance in two sentences: a player can
join, automated, and a player can join, manually, with a real Microsoft
account. This is every command between an empty machine and both of those,
plus the drain proof design's criterion 9 asks for. It is deliberately a
runbook and not a script — design §10 says why: the operator still runs
outside the cluster through go run, the local flow hand-builds the
Service and Endpoints its own pods dial, and turning that into a script is
milestone 6's work.
Each step says what to expect. A deviation from the expected output is a
defect somewhere earlier in this milestone, not a documentation problem — see
docs/known-issues.md's "From milestone 3c" section before assuming the
runbook itself is wrong.
Four things this runbook gets right that earlier drafts of the plan did not, established only by tasks 10 and 10b, late in this milestone:
online-modeis turned off throughspec.config.onlineMode: falseon theProxyGroup, never through aconfigOverlay.internal/render.Velocityreasserts the four keys it owns —enabled,online-mode,forwarding-secret-fileandbind— after merging any overlay, so an overlay could never have reachedonline-modeno matter how this runbook phrased it. The field exists on the CRD for exactly this reason and because a security switch belongs on an object a person reviews, not in a ConfigMap.- The probe's username is
spawnery_probe, with an underscore. A hyphen is not a legal Minecraft username. Velocity acceptsspawnery-probeand forwards it anyway; Paper then kills the forwarded connection withInvalid characters in username, and the proxy reports that to the client as a genericdisconnect.genericReasonthat names neither the field nor the character.cmd/spawnery-join's default is alreadyspawnery_probe— do not override it to something with a hyphen while improvising a variant. spawnery-joincloses its connection the moment it returns. By the time a shell prompt comes back and the next line of this runbook runskubectl get, the player has already left. Every use ofspawnery-joinbelow that expectsstatus.connectedPlayersto be non-zero passes--hold.- A held connection is counted in
status.connectedPlayers, and no configuration-phase play-through is needed.spawnery-join --holdstops one packet afterLogin Acknowledged, in the configuration state, and an earlier draft of this runbook forked on whether Velocity's own registry — which is whatProxyStatesamples throughproxy.playerCount— carries a player who has not reached the play state. It does. Disassembled out of the pinned jar (3.5.1 build 615, 2026-08-11):AuthSessionHandler.completeLoginProtocolPhaseAndInitializecallsVelocityServer.registerConnectionin itsLoginEventcontinuation, before it writesServerLoginSuccessPacketto the client — so before Login Acknowledged, beforePostLoginEventand beforeconnectToInitialServer— andVelocityServer.getPlayerCount()isconnectionsByUuid.size(). The player is in that map from login success onwards.
One thing still has to line up, and it is about the operator rather than
the proxy: status.connectedPlayers is only current if a report and a
reconcile happen inside the hold. That is why the holds below are 60
seconds and not 20.
0. Prerequisites¶
x86_64-linux. The image targets do not build on Darwin (docs/known-issues.md, "From milestone 2b").- Rootless Podman, with
dockeraliased to it orCONTAINER=podmanpassed explicitly below. On a machine with nodockerbinary at all — measured on 2026-08-12 —CONTAINER=podmanis not optional: the Makefile defaults todocker, and every target below has to carry the override. TMPDIRpointed at a real filesystem, not the default/tmp. Where/tmpis a tmpfs, Podman's OCI-archive extraction runs out of room silently enough to look like a hang.mkdir -p "$HOME/.cache/spawnery-tmp"once, and passenv TMPDIR="$HOME/.cache/spawnery-tmp"on every command below that builds or loads an image.- A shell with
XDG_RUNTIME_DIRandDBUS_SESSION_BUS_ADDRESSset — true of an interactive login shell, not true of every other shell a terminal multiplexer or a remote session hands you. Everysystemd-run --scope --usercommand below needs both; without them it fails withFailed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined. Fix it once, before the firstsystemd-runcommand in §2:
export XDG_RUNTIME_DIR=/run/user/$(id -u)
export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus
This does not change Podman's own storage — measured, the runroot and
graphroot are identical whether the export ran first or not, so an image
loaded before the export stays visible to a systemd-run command that
comes after it.
- A clone of this repository, nix develop available.
- The images carry no grep or awk. Any kubectl exec that inspects a
file inside a pod has to cat it and filter on the host instead.
- For the manual proof (§7): a second machine or a real Minecraft client
capable of a Microsoft login, and a NodePort this runbook's cluster
publishes to somewhere that client can reach — see "The manual proof, for a
later session" at the end of this document for the case where that machine
is not this one.
1. Build and load both images¶
The Makefile's CONTAINER defaults to docker; where there is no docker
binary at all, it has to be overridden rather than relied on to fall back.
TMPDIR matters for the same reason it is in the prerequisites: Podman
extracts the OCI archive there, and the default /tmp on this machine is a
tmpfs too small for it.
cd /path/to/spawnery
nix develop -c env TMPDIR="$HOME/.cache/spawnery-tmp" \
make image-load CONTAINER=podman # builds .#paper-image, podman load
nix develop -c env TMPDIR="$HOME/.cache/spawnery-tmp" \
make velocity-image-load CONTAINER=podman # builds .#velocity-image, podman load
Expect two image loads to report their tags:
ghcr.io/spawnery/paper:26.2-0.2.0 and
ghcr.io/spawnery/velocity:3.5.1-0.2.0 — the same two tags
config/samples/network.yaml already names. If the tags differ (a Paper or
Velocity bump since this was written), use the ones these two commands
actually print for every step below.
2. Create the kind cluster, with the two NodePorts published to the host¶
kind, not k3d: docs/known-issues.md under "From milestone 2b" records
why k3d cannot start against a rootless Podman socket at all. The
systemd-run wrapper is required for the same reason docs/development.md uses it —
kind needs cgroup delegation kind's own check insists on a scope for, even
when the property is already set elsewhere.
This runbook uses two ProxyGroups on two NodePorts — 30565 for the
automated proof, 30566 for the manual one — so both proofs can be repeated
independently without tearing the cluster down between them. Neither port is
in kind's default port mapping, so both are declared explicitly:
cat >/tmp/spawnery-evidence-kind.yaml <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30565
hostPort: 30565
- containerPort: 30566
hostPort: 30566
EOF
# Needs XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS set — see §0 — or this
# fails with "Failed to connect to user scope bus via local transport".
systemd-run --scope --user --property=Delegate=yes \
env KIND_EXPERIMENTAL_PROVIDER=podman \
nix develop -c kind create cluster --name spawnery-evidence \
--config /tmp/spawnery-evidence-kind.yaml
3. Load the images into the cluster and apply the CRDs¶
kind load docker-image does not work under Podman, no matter what
KIND_EXPERIMENTAL_PROVIDER says. It shells out to the docker binary
unconditionally, so on a machine with no docker at all it fails with
ERROR: image: "ghcr.io/spawnery/paper:26.2-0.2.0" not present locally —
even though podman image inspect on the exact same name returns an ID at
that moment. This is the single most misleading failure in this runbook,
because the error blames the image rather than the tool. The supported path
under Podman is kind load image-archive, and nix build already produces
an archive shaped for it — no separate export step needed:
nix build .#paper-image --out-link "$HOME/.cache/spawnery-tmp/paper-img"
nix build .#velocity-image --out-link "$HOME/.cache/spawnery-tmp/velocity-img"
systemd-run --scope --user --property=Delegate=yes --quiet \
env KIND_EXPERIMENTAL_PROVIDER=podman TMPDIR="$HOME/.cache/spawnery-tmp" \
nix develop -c kind load image-archive \
"$HOME/.cache/spawnery-tmp/paper-img" --name spawnery-evidence
systemd-run --scope --user --property=Delegate=yes --quiet \
env KIND_EXPERIMENTAL_PROVIDER=podman TMPDIR="$HOME/.cache/spawnery-tmp" \
nix develop -c kind load image-archive \
"$HOME/.cache/spawnery-tmp/velocity-img" --name spawnery-evidence
nix develop -c kubectl apply -f config/crd/bases
Verify both landed, since the load itself prints nothing that names the image:
podman exec spawnery-evidence-control-plane crictl images
Expect both ghcr.io/spawnery/paper:26.2-0.2.0 and
ghcr.io/spawnery/velocity:3.5.1-0.2.0 listed.
4. Run the operator outside the cluster, and hand-build what its pods dial¶
The operator has no image of its own — out of scope for all of milestone 3,
named as such in design §7 and again in §9 — so it runs here through go
run, exactly as docs/development.md's local-cluster section does, and the same gap
that section documents applies: a pod dials
spawnery-operator.minecraft.svc:9443, and nothing creates that Service
because the operator has no pod for a selector to find. docs/known-issues.md
under "From milestone 2c" — "The local kind flow needs a Service nothing
creates" — is the record of why this is a hand-built relay rather than a
missing step; do not read it as a shortcut this runbook took.
nix develop -c kubectl create namespace minecraft
nix develop -c go run ./cmd/spawnery-operator \
--leader-elect=false --operator-namespace minecraft &
Rootless Podman's kind network cannot be reached at its gateway address
from inside a pod, and the one address that can reach the host
(host.containers.internal, a pasta link-local address) is rejected by the
API server in Endpoints. docs/development.md measures the same wall and its fix —
a one-container relay on the same Podman network — applies verbatim here:
podman run -d --name spawnery-evidence-relay --network kind \
-v /nix/store:/nix/store:ro \
--entrypoint "$(nix build --no-link --print-out-paths nixpkgs#socat)/bin/socat" \
ghcr.io/spawnery/paper:26.2-0.2.0 \
TCP-LISTEN:9443,fork,reuseaddr TCP:host.containers.internal:9443
RELAY_IP=$(podman inspect spawnery-evidence-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
With a real Docker daemon instead of rootless Podman, skip the relay and
point the Endpoints at the bridge gateway (172.17.0.1) directly, the way
docs/development.md notes.
v1 Endpoints is deprecated as of Kubernetes 1.36, in favour of
discovery.k8s.io/v1 EndpointSlice. It still works — measured, applying it
verbatim above still worked and the pod still routed through the relay — but
kubectl apply prints a deprecation warning, and this section will need
rewriting to EndpointSlice once that stops being merely a warning.
5. Apply the network¶
One Network, one forwarding-secret Secret, two ServerGroups (lobby and
hub, one replica each to start — §8 scales lobby), and two ProxyGroups
that share the same fallbackGroups and differ only in onlineMode and their
NodePort.
Give both ProxyGroups their own, smaller spec.resources. This
runbook's four pods (two backends inheriting Network.defaults.resources at
2Gi request/limit, plus two proxies) ask for 8Gi on a node with roughly 8Gi
allocatable — measured: the fourth pod sits in Pending with 0/1 nodes are
available: 1 Insufficient memory. Velocity does not need a backend's heap.
Both ProxyGroup manifests below carry requests: cpu 500m, memory 1Gi;
limits: memory 1Gi for exactly this reason, rather than as an optional
tuning note — without it this section does not fit an 8Gi machine.
Two groups, in that order, on purpose. fallbackGroups is a try list:
agent/velocity/.../Router.choose takes the first group that holds a
candidate and never looks at a later one, so with lobby ahead of hub every
ordinary join lands in lobby and hub is only reached when lobby offers
nothing. That second path is exactly what §8 drains into, and with a
single-group list it could not be observed at all.
nix develop -c kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Secret
metadata:
name: velocity-forwarding-secret
namespace: minecraft
stringData:
secret: evidence-run-forwarding-secret
---
apiVersion: spawnery.cloud/v1alpha1
kind: Network
metadata:
name: evidence
namespace: minecraft
spec:
forwardingSecretRef:
name: velocity-forwarding-secret
defaults:
minecraftVersion: "26.2"
resources:
requests:
cpu: "1"
memory: 2Gi
limits:
memory: 2Gi
---
apiVersion: spawnery.cloud/v1alpha1
kind: ServerGroup
metadata:
name: lobby
namespace: minecraft
spec:
networkRef:
name: evidence
type: Ephemeral
image: ghcr.io/spawnery/paper:26.2-0.2.0
maxPlayers: 100
drain:
timeoutSeconds: 60
scaling:
minReplicas: 1
maxReplicas: 10
spareSlots: 40
---
apiVersion: spawnery.cloud/v1alpha1
kind: ServerGroup
metadata:
name: hub
namespace: minecraft
spec:
networkRef:
name: evidence
type: Ephemeral
image: ghcr.io/spawnery/paper:26.2-0.2.0
maxPlayers: 100
drain:
timeoutSeconds: 60
scaling:
minReplicas: 1
maxReplicas: 10
spareSlots: 40
---
apiVersion: spawnery.cloud/v1alpha1
kind: ProxyGroup
metadata:
name: gateway-auto
namespace: minecraft
spec:
networkRef:
name: evidence
replicas: 1
image: ghcr.io/spawnery/velocity:3.5.1-0.2.0
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 1Gi
expose:
type: NodePort
nodePort:
port: 30565
routing:
fallbackGroups:
- lobby
- hub
config:
playerLimit: 100
motd: "spawnery-join, automated"
onlineMode: false
---
apiVersion: spawnery.cloud/v1alpha1
kind: ProxyGroup
metadata:
name: gateway-manual
namespace: minecraft
spec:
networkRef:
name: evidence
replicas: 1
image: ghcr.io/spawnery/velocity:3.5.1-0.2.0
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 1Gi
expose:
type: NodePort
nodePort:
port: 30566
routing:
fallbackGroups:
- lobby
- hub
config:
playerLimit: 100
motd: "manual join, online-mode default"
EOF
gateway-manual deliberately omits config.onlineMode; the CRD's own
+kubebuilder:default=true fills it in, which is what a real Microsoft
account needs to answer an encryption request against.
Wait for both groups and the backend to reach Ready. Because the network
retry interval is 30 seconds and Paper's own start plus the agent's handshake
takes about the same again, allow a good 90 seconds before the first check:
sleep 90
nix develop -c kubectl get network,servergroup,proxygroup,servers,pods -n minecraft
Expect, by shape rather than by exact name (pod names carry a random suffix):
NAME ACCEPTED SERVER GROUPS
network.spawnery.cloud/evidence True 1
NAME PHASE READY FREE SLOTS
servergroup.spawnery.cloud/lobby Ready 1 100
servergroup.spawnery.cloud/hub Ready 1 100
NAME PHASE READY ADDRESS
proxygroup.spawnery.cloud/gateway-auto Ready 1 <nodeIP>:30565
proxygroup.spawnery.cloud/gateway-manual Ready 1 <nodeIP>:30566
NAME PHASE SLOTS PLAYERS REGISTERED
server.spawnery.cloud/lobby-xxxx Ready 100 0 true
server.spawnery.cloud/hub-wwww Ready 100 0 true
NAME READY STATUS
pod/lobby-xxxx 1/1 Running
pod/hub-wwww 1/1 Running
pod/gateway-auto-yyyy 1/1 Running
pod/gateway-manual-zzzz 1/1 Running
The operator does not re-spec an existing pod. If a pod is already
Pending on insufficient memory when its ProxyGroup's spec.resources is
patched — for instance because an earlier attempt at this section used
Network.defaults.resources for the proxies too, before finding the fit
problem above — the patch alone does not fix it: the already-created pod
keeps the spec it was born with, and only kubectl delete pod on it lets the
operator recreate it with the new one. Rolling updates that would do this
automatically are milestone 4's, not this milestone's — see
docs/handover-milestone-4.md.
If a ProxyGroup stops in Pending with its pod Running and READY 0/1,
the ready gate did not bind — docs/known-issues.md's "From milestone 3c"
entry on a silent bind failure applies: kubectl logs on the pod is the only
place that says why, the CR will not.
6. The automated proof¶
Design §10 criterion 7: spawnery-join reaches the point where Velocity
connects it to a backend, and the far side shows it — Paper's own log, then
Velocity's own log, then status.connectedPlayers.
--hold has to fit inside --timeout, and the default --timeout is 30s:
--hold 60s with no --timeout is refused outright —
spawnery-join: --hold 1m0s does not fit inside --timeout 30s — naming both
flags in the same line. That refusal is correct behaviour, not a bug to work
around quietly; the fix is to widen the deadline explicitly:
nix develop -c go build -o /tmp/spawnery-join ./cmd/spawnery-join
/tmp/spawnery-join --host 127.0.0.1 --port 30565 --hold 60s --timeout 90s
Expect exit code 0 and one line of JSON on stdout, of the shape
{"protocol":776,"username":"spawnery_probe","uuid":"...","compressed":true}
protocol is whatever the pinned Velocity build announces (776 was measured
against 3.5.1 build 615 in internal/mcjoin's package comment; a Velocity
bump moves this number, not this runbook). While the process is still
inside its 60-second hold, in a second shell, and not in the first ten
seconds of it — the agent samples once a second but the operator's report
interval and the ProxyGroup reconcile that writes the status are what
decide when it appears:
sleep 15
nix develop -c kubectl get proxygroup gateway-auto -n minecraft \
-o jsonpath='{.status.connectedPlayers}'
Expect 1. Item 4 above is why this is an expectation and not a fork: a
held player sits in Velocity's connectionsByUuid from login success
onwards, which is what proxy.playerCount counts and what the agent reports.
A 0 here is therefore a real defect, not an artifact of stopping in the
configuration state, and the place to look is the path between the proxy and
the CR rather than the client: kubectl logs on the gateway-auto pod for
what the agent reported, then whether a reconcile has run since. Record it in
docs/known-issues.md and carry on to the manual proof, which does not depend
on this count.
Then the logs, which are the primary evidence either way:
nix develop -c kubectl logs -n minecraft -l spawnery.cloud/group=lobby | grep -i 'spawnery_probe\|UUID of player'
nix develop -c kubectl logs -n minecraft -l spawnery.cloud/group=gateway-auto | grep -i 'spawnery_probe\|server connection'
Expect a Velocity line of the shape
[server connection] spawnery_probe -> lobby-xxxx has connected
and a Paper line naming the same player's UUID. Both together are the
criterion-7 proof independent of what status.connectedPlayers read: the
far side shows the join whether or not the operator's own counter agrees.
7. The manual proof¶
This section proves criterion 8 only, and stopping here leaves criterion 9
unclosed. §8's automated drain proof cannot succeed as this runbook stands
— see §8's own warning and docs/known-issues.md — so the real client
logged in here is also the only way left to prove criterion 9: continue to
§10, which folds a manual drain retry into the same real-account session
this section starts. Do not treat a completed §7 as this runbook's manual
proof in full; read §10 before disconnecting the client.
Design §10 criterion 8: one join with a real Microsoft account against
online-mode: true, which needs a client that can answer an encryption
request — spawnery-join explicitly cannot (mcjoin.Join returns an error
naming exactly this if it ever receives one).
Point a real Minecraft Java client at 127.0.0.1:30566 if the client runs on
this machine, or at this machine's address on port 30566 from another one —
the extraPortMappings in §2 is what makes the NodePort reachable at all
under kind. Log in with a Microsoft account that has purchased the game.
Expect the same two log lines as §6, this time for the real account's
username, plus — because this is online-mode: true — a successful Mojang
authentication with no manual workaround: unlike the measurement recorded in
internal/mcjoin's package comment, this runs the shipped renderer
unmodified, which is the point of proving it this way rather than by hand.
nix develop -c kubectl logs -n minecraft -l spawnery.cloud/group=gateway-manual | tail -20
nix develop -c kubectl logs -n minecraft -l spawnery.cloud/group=lobby | tail -20
nix develop -c kubectl get proxygroup gateway-manual -n minecraft \
-o jsonpath='{.status.connectedPlayers}'
Record the account's username (or a redacted form of it, if that matters to
whoever reviews this), the timestamps, and the log lines in
docs/handover-milestone-4.md.
8. The drain proof¶
Design §10 criterion 9: deleting a Server with a player on it moves that
player to a fallback rather than disconnecting them.
§8a and §8b cannot be completed with spawnery-join as it stands — do not
expect the outcome described below from a real run of this section today.
Measured on 2026-08-12: deleting a Server under a spawnery-join --hold
connection disconnects the player instead of moving them. The cause is not
the drain logic these sections mean to exercise; it is that --hold stops
one packet after Login Acknowledged, before Paper ever counts the
connection as an online player, so Server.status.players reads zero for a
session the proxy is still holding open, and the drain's own exit condition —
internal/phase/phase.go:224, if !in.Occupied() — reads that zero and
deletes the pod under the player. Full diagnosis in docs/known-issues.md,
"From the milestone 3c evidence run (2026-08-12)". Closing criterion 9 today
means running §10's manual retry with a real client instead, which plays the
configuration phase through and so is actually counted.
The two sections below are kept rather than removed, because they describe
what a real client's drain looks like and become literally correct the
moment spawnery-join is extended to play the configuration phase through —
already scoped as two packet-id constants and one case in holdOpen
(docs/known-issues.md, same entry). Until then, treat every "Expect" below
as the target behaviour this milestone owes, not as what this run will
actually show.
There are two ways the move can land, and this section proves both, in this
order, once the tool above is extended. agent/velocity/.../Router.choose
excludes the server being drained from its own candidate list, so with
lobby still at one replica the exclusion empties the first group entirely
and the try list has to fall through to hub. Scaling lobby up afterwards
gives the second shape, where the first group still holds a candidate and
hub is never consulted. Only the first is new to this milestone's coverage,
and it is the one a single-group fallbackGroups could not have shown at
all.
8a. The move that falls through to the second group¶
lobby still has exactly one server here — do not scale it yet. Join and hold
long enough to survive the drain: the operator repeats DrainPlayers on
roughly a 30-second cadence alongside its periodic FullSync
(internal/proxyreg/fleet.go), so the hold has to clear at least one of
those.
Use a username other than the default here. If §6's own held connection
has not fully closed yet — measured, on a machine going through this runbook
top to bottom without pausing between sections — this join collides with it:
both would be spawnery_probe, and Velocity refuses the second one with
disconnected: You are already connected to this proxy!. Give this join its
own identity, drain_probe (underscore, for the same reason item 2 of this
document's own preamble gives for spawnery_probe's underscore — a hyphen is
not a legal Minecraft username):
/tmp/spawnery-join --host 127.0.0.1 --port 30565 --username drain_probe \
--hold 60s --timeout 90s &
sleep 5
nix develop -c kubectl logs -n minecraft -l spawnery.cloud/group=gateway-auto --tail=5
Expect the log to name the one lobby server — lobby is first in
fallbackGroups and has a candidate, so hub is not consulted on the join.
Then, while the hold is still running, delete it:
nix develop -c kubectl delete server lobby-xxxx -n minecraft
nix develop -c kubectl logs -n minecraft -l spawnery.cloud/group=gateway-auto -f
What to expect is Velocity's own connection log, not a line from the agent
itself. agent/velocity/.../Drain.kt logs a spawnery: line only on
failure — no target available, or a move that threw — and stays silent on
success by design, so its comment reads: "[Drain] itself remembers nothing
between calls — the proxy's own connection state is the only memory this
needs." The proof of a successful move is the same shape of line §6 already
established, this time ending on a server in the other group:
[server connection] drain_probe -> hub-wwww has connected
That line is the fall-through: lobby held one server, the exclusion took it
away, and the router went on to hub instead of giving up. A no target
available line here instead means the router stopped at the emptied group,
which is the defect RouterTest's "a group the exclusion empties falls
through to the next group" pins in the unit suite.
The ServerGroup will bring lobby back to its minReplicas on its own;
wait for it before 8b.
8b. The move that stays inside the first group¶
Scale lobby to two so the exclusion leaves a candidate behind:
nix develop -c kubectl patch servergroup lobby -n minecraft --type=merge \
-p '{"spec":{"scaling":{"minReplicas":2,"maxReplicas":10,"spareSlots":40}}}'
sleep 40
nix develop -c kubectl get servers -n minecraft
Expect two Ready lobby servers, e.g. lobby-aaaaa and lobby-bbbbb, plus
the one hub server. Repeat the join (still --username drain_probe, still
--timeout 90s), the five-second wait and the delete from 8a against
whichever lobby server the log names. This time the move must stay in the
first group:
[server connection] drain_probe -> lobby-bbbbb has connected
hub appearing here instead would mean the try list is being searched rather
than walked in order.
If nothing moves within about 40 seconds of either delete, check the proxy log
for no target available first — that names the actual failure (an empty
toGroups, or the surviving server not yet Ready) rather than leaving a
silent hang to debug from nothing.
9. Clean up¶
# Both steps, and the second is not optional. `go run` does not forward the
# signal to the binary it compiled, and bash's `kill %1` signals one process
# rather than the job's group -- measured 2026-08-25, see docs/known-issues.md
# under milestone 4c-1. Without the second line the operator goes on
# reconciling against a cluster this section is about to delete.
kill %1 # the go run wrapper
pkill -x spawnery-operat || true # the binary it compiled; comm is truncated at 15
ps -eo pid,comm | grep spawnery || true
podman rm -f spawnery-evidence-relay
systemd-run --scope --user --property=Delegate=yes \
env KIND_EXPERIMENTAL_PROVIDER=podman \
nix develop -c kind delete cluster --name spawnery-evidence
rm -f /tmp/spawnery-evidence-kind.yaml /tmp/spawnery-join
10. The manual proof, for a later session¶
Written 2026-08-12, after an automated run of §1–§6 and §8 against a real
kind cluster proved criterion 7 and found the criterion-9 defect recorded
in docs/known-issues.md's "From the milestone 3c evidence run (2026-08-12)"
section. Criterion 8 — a real Microsoft account joining against
online-mode: true — was not attempted that day: it needs a licensed
Minecraft Java client and a person to drive it, neither of which was
available in that session. Start by reading docs/known-issues.md's
criterion-9 entry before running anything below — it explains why the
automated proof cannot cover this section's second half.
Two ways to arrive here. If you just finished §7 in this same session,
the cluster is already up, the real client is already logged in, and
config.onlineMode is already true on gateway-manual — skip straight to
"Criterion 9, folded into this same session, on this same real player" below
and use the connection you already have; nothing before that heading applies
to you. Everything above that heading is written instead for a later
session, quite possibly a different machine, run by someone with no memory
of the one that wrote it — that is the harder case, and it is the one the
rest of this section is ordered for.
The cluster from the automated run is gone. spawnery-evidence was torn
down by §9's cleanup at the end of that session; nothing here can reuse its
state, its images, or its NodePort mapping. Start over from §0: the shell
environment §0 sets up — CONTAINER=podman, TMPDIR, and the D-Bus
variables systemd-run --scope --user needs — is a fresh machine's first two
defects waiting to happen, not a formality to skip because it looks like
prose rather than a command. Then build and load both images, create a fresh
kind cluster with §2's extraPortMappings
(30565 and 30566 published to the cluster host's loopback), load both images
per the corrected §3, run the operator and its relay per §4, and apply the
network manifests — with the ProxyGroup resource requests §5 now
carries — per §5. Do not skip §6 in the automated form either: confirming
criterion 7 still holds on this machine, cheaply, before spending a real
account's login on the manual proof, catches an environment problem before
it looks like a product one.
What this needs beyond the earlier sections:
- A Minecraft Java Edition client at 26.2, protocol 776 — the exact
number
spawnery-join's own JSON output printed in the automated proof ({"protocol":776,...}), and Paper 26.2 will refuse any client that announces a different one with a loud "Outdated client!" naming the version to install instead. This is not a number to approximate. - A Microsoft account that owns the game, able to complete a normal Minecraft login.
- Network reach from the machine running that client to the cluster host's
NodePort 30566 (
gateway-manual, theProxyGroupthat leavesconfig.onlineModeat its CRD default oftrue).
Reaching the NodePort from a different host. kind's
extraPortMappings publishes a NodePort to the cluster host's own
loopback, 127.0.0.1:30566 — nothing else, by default. If the client runs on
the same machine that ran §1–§5, point it at 127.0.0.1:30566 directly and
skip the rest of this paragraph. If the client is on a different machine —
the expected case, since a session with no Minecraft client available is
exactly why this section exists — the two options are opening 30566 on the
cluster host's LAN address in whatever firewall sits in front of it, or an
SSH tunnel, which needs nothing changed on the host at all. From the
client's machine, with SSH access to the cluster host:
ssh -N -L 30566:127.0.0.1:30566 <user>@<cluster-host>
Then point the Minecraft client at 127.0.0.1:30566 on the client's own
machine; the tunnel forwards it to the cluster host's published NodePort from
there.
The join itself. Log in with the Microsoft account, normally, against
127.0.0.1:30566 (or the LAN address, if that path was used instead).
gateway-manual deliberately omits config.onlineMode in §5's manifest, so
the CRD's +kubebuilder:default=true applies and the proxy issues a real
encryption request the client answers with a genuine Mojang session — this
is what spawnery-join cannot do, by design (internal/mcjoin's own package
comment names the limitation). Expect the same two log lines §6 already
established for the automated proof, this time carrying the real account's
username and its real Mojang UUID rather than spawnery_probe's offline-mode
one:
nix develop -c kubectl logs -n minecraft -l spawnery.cloud/group=gateway-manual | tail -20
nix develop -c kubectl logs -n minecraft -l spawnery.cloud/group=lobby | tail -20
The UUID being a real one is the point, not an incidental detail. An offline-mode UUID is derived from the username alone and proves nothing about who is connecting; a real Mojang UUID is only handed back after the client proved its session to Mojang and the proxy verified it — so seeing it in Paper's log is what shows the proxy actually authenticated the player, rather than merely trusting whatever name arrived.
Criterion 9, folded into this same session, on this same real player¶
This is the part the automated run could not do at all — see
docs/known-issues.md's "From the milestone 3c evidence run (2026-08-12)"
section for the full diagnosis of why spawnery-join --hold cannot stand in
for a real client here: it stops one packet after Login
Acknowledged, before Paper's own player list ever contains it, so
Server.status.players reads zero for a connection the proxy is still
counting, and the operator's drain reads that zero and deletes the pod out
from under an occupied session. A real client completes the configuration
phase on its own, Paper counts it, and the drain that depends on that count
actually waits. While the real player from the join above is still connected
and still in lobby, find and delete the Server they are on:
nix develop -c kubectl get servers -n minecraft # find the one the proxy log named
nix develop -c kubectl delete server lobby-xxxx -n minecraft
Expect the proxy to log a move to the other fallback group, the same shape of line established throughout this runbook:
[server connection] <player> -> hub-xxxx has connected
— a move to hub rather than another lobby server, because §5's lobby
ServerGroup starts at one replica and the drain's own exclusion empties it,
the same fall-through §8a already exercised with the held probe. Expect the
player to stay in the game throughout, landing in a different world with no
visible disconnect. A disconnect instead, or a spawnery: line reading no
target available, is a real defect distinct from the one criterion 9's
automated attempt already found — the automated failure was the operator
acting on a stale player count, not the move logic itself, so a failure here
implicates agent/velocity/.../Drain.kt or Router.kt directly and is worth
its own investigation rather than being folded into the known criterion-9
entry.
What to record, all of it into docs/handover-milestone-4.md under the
same heading the automated evidence occupies: the two log lines from each
side of the join (proxy and backend), the player's real Mojang UUID, and the
drain's move line from the criterion-9 step above, each with its timestamp.
Where this goes¶
The output of §6 through §8, and of §10 once it is run — exit codes, JSON
lines, status fields, log lines, timestamps — belongs in
docs/handover-milestone-4.md, in the section that document leaves for it.
This runbook is the procedure; that document is the record of what running
it actually produced.