Skip to content

Upgrading between releases

An upgrade costs whatever it rolls, and two things decide that: whether the new operator renders proxy pods differently than the one it replaces, and whether the agent images in the fleet are older than the operator that will drain them. Before either, find out whether anything is rolling already.

Is a group mid-roll?

kubectl get pods -n <ns> -l spawnery.cloud/role=proxy -L spawnery.cloud/pod-hash

Two distinct values inside one group means that group is mid-roll; one value everywhere means done or never started.

The group's status will not tell you. The replacement pods come up before any old pod is withdrawn, so readyReplicas holds at replicas and the phase reads Ready throughout, as at rest.

What an upgrade rolls, with no spec edited

A proxy pod is stale when its spawnery.cloud/pod-hash label differs from a digest of the pod the operator would render for its group right now, and that digest is taken over the rendered pod rather than over a chosen list of spec fields. So a change to the rendering code (a new default in internal/podspec, an added environment variable, a renamed label) moves the digest for every ProxyGroup while every spec stays byte for byte what it was.

A second trigger is invisible in the spec: the agentEndpoint handed to the renderer feeds the digest, and it is spawnery-operator.<operator-namespace>.svc:9443. Moving the operator to a different namespace, or restarting it with a different --operator-namespace or POD_NAMESPACE, rolls the whole fleet with nothing else changed.

Every group starts as soon as the new operator is up: a server group one pod at a time, a proxy group blue/green across every stale pod at once, and every group at once unless the Network caps concurrent changeovers or the groups set changeover stages. Each replaced pod takes no new connections and is stopped once its players have left; nobody is disconnected unless the group sets spec.update.maxStaleSeconds, the pod's node is leaving, or its player count cannot be read for spec.drain.timeoutSeconds. See Rolling a group.

From a release without changeover stages, the first reconcile takes a proxy group mid-roll from one extra pod to one per stale pod, and a RollingUpdate group whose stale servers are all leaving may release its budget place.

Rolling on the rendered pod rather than on any metadata.generation change is deliberate: replicas is the routine edit on a proxy group, and a generation rule would make every scale-up and scale-down a full replacement, each pod waiting out an attrition-bound drain.

Finding out before you upgrade

internal/podspec/hash_golden_test.go pins DesiredProxyHash and DesiredServerHash over frozen fixtures, so a change to either render path fails on the pull request that makes it. Comparing two builds after the fact, the cheap negative filter is git diff <old>..<new> -- internal/podspec/; if nothing in the pod-render path moved, the digest cannot have. That is how 2026-08-22's v0.1.2 to v0.2.0 upgrade was known to be safe in advance, and it was: both proxies kept pod-hash 2dd6593373a4ffd2 and 46 hours of uptime, because the only file that had moved was netpol.go and only its comments.

Neither the golden tests nor the diff covers the triggers outside the code: the group's own namespace and name, the Network's name, and the agent endpoint above. For those, run the new build against a scratch cluster over the same manifests and compare the pod-hash it stamps with what the running pods carry. Different manifests tell you nothing about your fleet.

Upgrade the proxy images before the operator

A new operator against proxy images older than SetReady empties nobody and disconnects everybody at the deadline. What it looks like first is that nothing happens: spec.replicas goes 2 to 1 and the surplus pod stays Ready, stays in the Service's endpoint slice, and goes on receiving new players for the whole drain window. Then the pod is deleted with all of them on it, and one event is the only record:

Warning  ProxyDrainTimeout  pod/gateway-xxxx  deleting proxy gateway-xxxx after 5m0s with 3 player(s) still connected

An older agent does what protobuf requires of an unknown field and ignores SetReady, so ReadyGate.close() is never reached, the kubelet's probe keeps succeeding, and the endpoint never goes away. The deadline bounds the damage; nothing prevents it. Nothing version-gates the message, which is why the order matters.

The signature is annotation plus Ready. The operator writes spawnery.cloud/draining-since whether or not the agent ever hears the message, so an un-upgraded proxy carries the annotation while its Ready condition is still True and kubectl get endpointslice -l kubernetes.io/service-name=<group> still shows its address as ready. A correctly drained proxy carries the annotation and is NotReady.

Rolling the operator back on its own is safe. An agent that supports SetReady and never receives one opens its gate on the first full sync unless a false was asserted.

A new Minecraft version

A game image's tag is <minecraft>-<release>. When a release moves to a new Minecraft version, the previous version's Paper and Purpur images are published beside it for a short transition, so the operator and the proxies can move first and the worlds later. The one Velocity image serves both. Worlds only go forward, so move a persistent group to the new version once, and not back.

No 26.2 image is built any more; the last release that publishes purpur:26.2-<release> is 0.30.1. A group still on one moves to 26.3 by changing its image to the purpur:26.3-<release> tag of the release it upgrades to.

/cloud start and /cloud stop are gone

Since 0.19.0, /cloud scale <group> <count> [for <duration>] replaces start. Where start raised a group's floor, scale holds the group at exactly that many servers, so it can also take a group below minReplicas. /cloud scale <group> reset replaces stop and removes pins and boosts alike. The node is still spawnery.cloud.scale. A plugin calling boost or stopBoosts keeps working; both are deprecated in favour of scale and resetScale.

Events about private servers stay on the proxies

Since 0.20.0 an event about a member of an on-demand group, or about the group itself, reaches proxies only. A backend plugin subscribed to EventBus no longer hears of private servers, which matches servers(), where they never appeared.

Older installations

Release notes carries the notes release by release: what each one rolled, and what it left stranded. The operator renames nothing. It writes the new name and leaves whatever the old code wrote sitting there, so an installation created before v0.1.0 still carries a PodDisruptionBudget and a ConfigMap under their old names, and a Ready: False condition nothing republishes.