Runbook: rotating a Network's forwarding secret¶
Status: standing operating procedure. Unlike
docs/archive/runbooks/runbook-milestone-5a-evidence.md,
docs/archive/runbooks/runbook-milestone-5b-evidence.md and the other evidence runbooks beside
it, this document is not a record of a run. It is the procedure itself, and it
stays true after milestone 5c ships. Milestone 5c is what makes the operator
report a rotation; every restart below is still a human's to perform.
Throughout, <ns> is the namespace holding the Network, <net> the Network's
name and <secret> the Secret named by spec.forwardingSecretRef.name.
1. What this is for, and when it applies¶
Rotating the Velocity modern forwarding secret of one Network: the value the
proxies present and the backends check when a player is handed from one to the
other. It is the Secret named by spec.forwardingSecretRef, under the key
secret (internal/podspec/server.go:121, podspec.ForwardingSecretKey).
The rotation is manual, and the master design fixed that in
docs/superpowers/specs/2026-08-07-minecraft-cloud-operator-design.md §6.5.
Neither Velocity nor Paper accepts two forwarding secrets at once, and neither
re-reads the file once its process has started. So there is necessarily a
window in which one layer holds the new value and the other still holds the
old, and a join or a transfer across that boundary fails with "Unable to
verify player details". §6.5 also gives the reason automatic orchestration is
deferred rather than merely unbuilt: in exactly that window an automatic drain
would want to move players onto fallback backends it can no longer reach, so
closing the window automatically needs registration to become
generation-aware. Until that exists, the operator detects the change and
reports it; the restarts follow this document.
The operator therefore never restarts anything on its own account here. A
rotation moves no pod hash: DesiredServerHash and DesiredProxyHash delete
spawnery.cloud/forwarding-hash before digesting
(internal/podspec/hash.go:76 and :137), so nothing about a rotation makes a
pod stale in the sense that 4c's rolling updates or 5b's takedown rule act on.
2. Why the server groups go first¶
Not an arbitrary order. A proxy holding the old secret and a backend holding the new one reject each other, so from the first restart until the last the two layers disagree and every join across the boundary fails. The order decides what that window costs.
Roll the proxies first and every connected player is thrown out in the same second — a proxy restart drops its connections — and lands in a network where no backend is reachable, because every backend still holds the old secret. The outage lasts until the last server group has been rolled.
Roll the server groups first and the proxies stay up with their players still connected. Unreachability grows group by group as each group crosses over, and only the groups already rolled are unreachable. The one hard cut then stays at the end, where it lasts as long as a proxy restart rather than as long as the whole rotation.
The condition's own message is written in this order — the stale server groups
before the stale proxy groups (staleSummary,
internal/controller/forwardingsecret.go:197) — so the message reads in the
order the work is done.
3. Prerequisite: the reader Role, once per namespace¶
The operator reads the Secret with a namespaced grant that is not part of
config/deploy/. Without it the operator cannot read the Secret at all and
detects nothing:
kubectl apply -n <ns> -f config/rbac/forwarding-secret-reader.yaml
That manifest holds a Role named spawnery-forwarding-secret-reader granting
secrets: get and nothing else, and a RoleBinding of the same name to the
spawnery-operator ServiceAccount in spawnery-system. Neither object carries
a metadata.namespace, so the -n <ns> above supplies it.
Check it before rotating:
kubectl get role,rolebinding spawnery-forwarding-secret-reader -n <ns>
A namespace where this was never applied reports itself rather than staying
silent: ForwardingSecretResolved reads Unknown with reason
SecretReadForbidden, and its message names this manifest and the kubectl
apply line to fix it. In that state the operator has no digest to compare
against, so ForwardingSecretRotationPending reads Unknown with reason
SecretUnresolved and this runbook cannot be followed to its end — nothing
below step 3 will ever confirm.
4. The progress command¶
A pod the operator creates while it has a digest recorded carries that digest
in the label spawnery.cloud/forwarding-hash
(internal/podspec/labels.go:74), and the label is written at creation and
never touched again — so it says which secret that process read at startup. It
is a label rather than an annotation so that a single kubectl get shows the
whole fleet's progress, and so that the old value can be selected on:
kubectl get pods -n <ns> -l spawnery.cloud/network=<net> \
-L spawnery.cloud/role -L spawnery.cloud/group -L spawnery.cloud/forwarding-hash
Sixteen hex characters per pod, or an empty column for a pod that carries no stamp at all. An empty column means unknown, not stale — see §8.
5. The steps¶
Step 1 — write down what is there now¶
Three readings, before anything changes. The first resolves <secret>. The
secret's value is what a rollback writes back (§7), and the current digest is
what the per-group checks in step 5 select on — after the rotation, the
operator records only the new digest and the old one is nowhere else.
kubectl get network <net> -n <ns> -o jsonpath='{.spec.forwardingSecretRef.name}{"\n"}'
kubectl get secret <secret> -n <ns> -o jsonpath='{.data.secret}' | base64 -d
kubectl get network <net> -n <ns> -o jsonpath='{.status.forwardingSecretHash}{"\n"}'
Call the third value <old-hash> below.
Also list what has to be rolled, in order — the server groups, then the proxy groups:
kubectl get servergroups,proxygroups -n <ns>
Step 2 — rotate the value¶
kubectl patch secret <secret> -n <ns> --type merge \
-p '{"stringData":{"secret":"<new value>"}}'
The bytes are digested exactly as they are stored, untrimmed
(podspec.ForwardingHash, internal/podspec/hash.go:168), so a trailing
newline is a different secret and is reported as a rotation of its own.
Step 3 — confirm the operator saw it¶
The Network controller re-reads the Secret on every reconcile and requeues
itself at resyncInterval, five seconds
(internal/controller/network_controller.go:153, the constant at
internal/controller/server_controller.go:75), so this answers within about
that.
The recorded digest, which should now differ from <old-hash>:
kubectl get network <net> -n <ns> -o jsonpath='{.status.forwardingSecretHash}{"\n"}'
The two conditions:
kubectl get network <net> -n <ns> -o jsonpath='{range .status.conditions[?(@.type=="ForwardingSecretResolved")]}{.status} {.reason} {.message}{"\n"}{end}'
kubectl get network <net> -n <ns> -o jsonpath='{range .status.conditions[?(@.type=="ForwardingSecretRotationPending")]}{.status} {.reason} {.message}{"\n"}{end}'
Expected: True SecretResolved ... and True RotationPending ..., the second
naming each stale group as role/group=count with the server groups first.
True RotationPending needs at least one stamped pod to compare. On a
network whose pods all predate this operator version, the second command reads
Unknown PodsPredateTracking instead while the rotation is genuinely pending —
that is not a sign the rotation did not land, and the first command plus the
digest above are what confirm it did. Roll everything anyway; the empty column
of the §4 command is what tells you which pods are still to go.
The event, emitted once on the transition rather than once per resync:
kubectl get events -n <ns> \
--field-selector involvedObject.kind=Network,involvedObject.name=<net>,reason=ForwardingSecretRotated
If ForwardingSecretResolved reads False SecretKeyMissing, the patch in step
2 wrote the wrong key or an empty value. Fix that before going further. On a
failed read the recorded digest keeps its previous value
(internal/controller/network_controller.go:125-134), so a pod created in this
state is stamped with the old digest while its projected volume has no secret
key to mount — it stays in ContainerCreating carrying a stamp that describes
nothing it ever loaded.
Step 4 — roll the server groups, one group at a time¶
Read §6 first: this disconnects the players on each pod it takes down, and for a persistent group it is paced by hand.
For each ServerGroup in the namespace, list its pods and delete them:
kubectl get pods -n <ns> \
-l spawnery.cloud/network=<net>,spawnery.cloud/role=server,spawnery.cloud/group=<group> \
-L spawnery.cloud/forwarding-hash
kubectl delete pod <pod> -n <ns>
Deleting the pod is what rolls the server: with the pod gone, the Server
controller's state machine reads PodLost and moves the Server to
Terminating (internal/phase/phase.go:316), and a Server that reaches
Terminating without a deletion having been requested is removed so the group
creates a replacement (internal/controller/server_controller.go:358-363). For
a persistent group the replacement carries the same ordinal, and therefore the
same claim name — PersistentServerName is <group>-<ordinal>
(internal/controller/persistent.go:36) and DataClaimName derives from the
Server's name (internal/podspec/server.go:157) — so the world comes back with
it. Nothing in this operator deletes a claim (growClaim's own comment,
internal/controller/server_controller.go:373).
Step 5 — verify the group before moving to the next¶
Select on the old digest. An empty answer means no pod of that group is left on it:
kubectl get pods -n <ns> \
-l spawnery.cloud/network=<net>,spawnery.cloud/role=server,spawnery.cloud/group=<group>,spawnery.cloud/forwarding-hash=<old-hash> \
-o name
And positively, that the replacements are up and carry the new one:
kubectl get pods -n <ns> \
-l spawnery.cloud/network=<net>,spawnery.cloud/role=server,spawnery.cloud/group=<group> \
-L spawnery.cloud/forwarding-hash
Both selectors pin role=server, and that is not redundant. A ServerGroup
and a ProxyGroup are different Kinds, so Kubernetes lets them share a name in
one namespace — a collision this repository designs around by name rather than
forbids (podspec.GroupConfigMapName and podspec.GroupPDBName,
internal/podspec/labels.go:117-162). Without the role term, a same-named
proxy group's pods answer this query too, and the step 4/5 loop never comes
back empty until the proxies are rolled — which this runbook says to do last.
Repeat steps 4 and 5 until no server group has a pod on <old-hash>, and until
no server pod carries an empty forwarding-hash column.
Step 6 — roll the proxy groups¶
Same shape, role=proxy. This is the hard cut, and it starts at the end of
step 5 rather than at the first delete here: once every server group holds the
new secret, a proxy that still holds the old one can reach no backend at all,
so no player can join through it and any left connected are already stranded.
Restarting a proxy then disconnects the players on it, and the group is
whole again only when the last proxy is back. Work through the proxy groups
without pausing between them.
kubectl get pods -n <ns> \
-l spawnery.cloud/network=<net>,spawnery.cloud/role=proxy,spawnery.cloud/group=<group> \
-L spawnery.cloud/forwarding-hash
kubectl delete pod <pod> -n <ns>
The ProxyGroup controller creates the replacement pod to bring the group back
to its replica count.
Step 7 — confirm the rotation is complete¶
kubectl get network <net> -n <ns> -o jsonpath='{range .status.conditions[?(@.type=="ForwardingSecretRotationPending")]}{.status} {.reason} {.message}{"\n"}{end}'
Expected: False ForwardingSecretInSync every pod of this network runs on the
current forwarding secret.
If it reads Unknown PodsPredateTracking instead, the rotation is not
finished — do not stop here. Outside a rotation that reading is benign and
clears on its own (§8). During one it means the opposite: a pod carrying no
stamp is a pod that was never rolled, it predates this operator's tracking, and
it is quite possibly still running the previous secret. Step 5 cannot see it
either — that check selects on forwarding-hash=<old-hash>, and a pod with no
such label matches no value of it.
Find them by the empty column, and roll them the same way, server pods before proxy pods:
kubectl get pods -n <ns> -l spawnery.cloud/network=<net> \
-L spawnery.cloud/role -L spawnery.cloud/group -L spawnery.cloud/forwarding-hash
With a digest recorded — which step 3 confirmed — every pod the operator
creates is stamped (internal/podspec/server.go:441-443,
internal/podspec/proxy.go:300-302), so the condition reaches False
ForwardingSecretInSync once the last unstamped pod has been replaced.
6. Two warnings¶
kubectl delete pod bypasses the PodDisruptionBudget, and the players on
that pod are disconnected. Each group's PDB is sized to its occupied pods and
selects on spawnery.cloud/occupied, which refuses the eviction API an
occupied pod (internal/podspec/labels.go:34-45). A direct kubectl delete pod
is not an eviction and the budget does not see it. Nor is there a drain on this
path: the state machine reaches Terminating through PodLost
(internal/phase/phase.go:316), not through the Draining branch that a
requested deletion takes. A rotation is a maintenance window. Announce it,
and do not reach for a drain to avoid the disconnection — §6.5 of the master
design is explicit that a drain mid-rotation would move players onto fallback
backends that the proxy, still holding the old secret, can no longer reach.
5b's one-ordinal budget does not bind a human. At most one ordinal of a persistent group is down at a time constrains the takedowns the operator nominates; it is a rule inside the nomination logic, not a lock on the objects. A human deleting every pod of a persistent group takes every world in it offline at once. Pace it by hand:
- delete one ordinal's pod;
- wait for its replacement to be
Readyand to carry the new digest — both columns of the step 5 command; - only then the next ordinal.
7. Rollback¶
Write the old value back, exactly as it was recorded in step 1, and then roll whatever has already been rolled — which is why the value is written down before anything changes.
kubectl patch secret <secret> -n <ns> --type merge \
-p '{"stringData":{"secret":"<the value from step 1>"}}'
The digest is a content hash over a stable salt, so restoring the same bytes
restores the same digest, and status.forwardingSecretHash returns to
<old-hash>. Pods that were never rolled are current again and need no action;
the pods rolled onto the new value are now the stale ones, and
ForwardingSecretRotationPending names them in the same role/group=count
form. Roll those back in the same order this runbook uses: server groups first,
then proxy groups.
8. What each condition state means¶
Both conditions live on the Network. They are deliberately separate from
Accepted, which keeps its own meaning — this Network owns its namespace — so
that an unreadable secret never stops the network's sizing
(api/v1alpha1/common_types.go:61-69).
ForwardingSecretRotationPending¶
Negative polarity: True is the problem. Precedence runs down this table — an
unreadable secret is decided before a stale pod, which is decided before an
unstamped one.
| Status | Reason | What it means | What to do |
|---|---|---|---|
True |
RotationPending |
at least one pod runs on a digest other than the current one; the message names each as role/group=count |
run this runbook: server groups first (§5 step 4), then proxy groups (§5 step 6) |
False |
ForwardingSecretInSync |
every pod carries the current digest and none is unstamped. A network with no pods at all reads this too, vacuously | nothing |
Unknown |
PodsPredateTracking |
no pod is stale, but at least one carries no stamp — see below | outside a rotation: nothing, it clears as pods turn over. During one (§5 step 7): the unstamped pods have not been rolled — find them by the empty column of the §4 command and roll them |
Unknown |
SecretUnresolved |
the secret could not be read, so no comparison is possible; the message carries the ForwardingSecretResolved message inside it |
fix the read first — the table below |
PodsPredateTracking needs its own paragraph, because it looks like a
failure and is not one. The operator cannot tell whether an unstamped pod
runs on the current secret: the stamp is written when the pod is created and
never afterwards, so a pod created before this operator version existed carries
none. That is the ordinary state of every running pod immediately after an
operator upgrade, and it clears as pods turn over for any reason at all. It is
Unknown rather than True because True would send every user through this
runbook after every upgrade, and rather than False because a genuine rotation
shortly after an upgrade would then go unreported.
ForwardingSecretResolved¶
Positive polarity: True is healthy.
| Status | Reason | What it means | What to do |
|---|---|---|---|
True |
SecretResolved |
the Secret exists and its secret key holds a non-empty value |
nothing |
False |
SecretNotFound |
the GET returned NotFound: spec.forwardingSecretRef names a Secret that does not exist in this namespace |
fix the name, or create the Secret. Pods of this network hang in ContainerCreating until it exists, because the projected volume cannot mount |
False |
SecretKeyMissing |
the Secret exists but has no secret key, or an empty one |
put the forwarding secret under the key secret |
Unknown |
SecretReadForbidden |
the GET was denied: the reader Role of §3 was never applied to this namespace. Forbidden arrives before the operator can learn whether the Secret exists, so it may be present and pods may still start, or it may be missing and they hang in ContainerCreating — see the note below before rotating in this state |
kubectl apply -n <ns> -f config/rbac/forwarding-secret-reader.yaml |
Unknown |
SecretReadFailed |
any other error — the API server was unreachable, for instance. Nobody's typo and nothing to edit. Same caveat as the row above, and the kubelet projects the Secret through that same API server, so an unreachable one stops pods starting for its own reason | look at the message and at the operator's logs; it clears when the read succeeds |
SecretNotFound is also the one reported as an event,
ForwardingSecretNotFound, on entry into that state:
kubectl get events -n <ns> \
--field-selector involvedObject.kind=Network,involvedObject.name=<net>,reason=ForwardingSecretNotFound
One thing the stamp does not say¶
The stamp is the last digest the operator read, not necessarily the bytes the
pod mounted. On any failed read the recorded digest keeps its previous value
(internal/controller/network_controller.go:125-134) and the pod builders
stamp it whenever it is non-empty (internal/podspec/server.go:441-443,
internal/podspec/proxy.go:300-302). The kubelet, meanwhile, projects whatever
the Secret holds, which has nothing to do with whether the operator may read
it. So a pod created while a read is failing carries a claim the operator could
not check, and the two failure families differ:
SecretNotFoundandSecretKeyMissing. There is nothing to project, so such a pod never starts — it sits inContainerCreating. Its label describes an intention rather than a fact, and the stamp misreports a pod that is not running.SecretReadForbiddenandSecretReadFailed. The Secret itself may be perfectly present, so such a pod starts normally. If the secret was rotated inside that window — the reader Role removed, or the API server briefly unreachable — the pod loads the new value and is stamped with the old digest. When the read recovers, the operator reports that pod stale although it is current, andRotationPendingstaysTrueuntil it is rolled.
Rolling the pod resolves it either way, but only once the read succeeds again:
a pod rolled while the read is still failing is stamped from the same retained
digest and is mis-stamped a second time. Fix the read first — the remedy
column above says how for each reason — and then roll, because a pod created
against a readable secret is stamped with the digest of the bytes it is about
to mount. Recorded in docs/reference/known-issues.md.
9. When the stamp lies, and for how long¶
Everything above reads spawnery.cloud/forwarding-hash off a pod and compares
it with the Network's status.forwardingSecretHash. Three situations make that
comparison wrong, and all three are properties of what the stamp is rather than
defects in it — which is why they are here, in the procedure that depends on
them, rather than in known-issues.md.
The stamp says what a pod loaded at start, not what it would load now. That
is the point — the kubelet refreshes the projected file underneath a running
pod and neither Velocity nor Paper reads it a second time, so the creation-time
value is the only one that describes the running process
(internal/podspec/labels.go:56-74). The cost is at the other end: the stamp
is the last digest the operator read, not necessarily the bytes the pod
mounted. status.forwardingSecretHash keeps its previous value on any
failed read (api/v1alpha1/network_types.go:75, guarded at
internal/controller/network_controller.go:125-134), and both builders stamp
it whenever it is non-empty (internal/podspec/server.go:441-443,
internal/podspec/proxy.go:300-302). The kubelet meanwhile projects whatever
the Secret currently holds, which is independent of whether the operator may
read it. The five resolved reasons therefore split two ways:
- Under
SecretNotFoundandSecretKeyMissingthere is nothing to project, so a pod created in that state never starts — it sits inContainerCreating, and its label describes an intention rather than a fact. Here the stamp misreports a pod that is not running. - Under
SecretReadForbiddenandSecretReadFailedthe Secret may be perfectly present, so such a pod starts normally. If the value is rotated inside that window — the reader Role removed, or a transient API failure — the pod loads the new bytes and carries the old digest, and once the read recovers the operator reports it stale while it is in fact current. The read recovering stops further pods being mis-stamped; it does not correct the ones already created, whose labels stay wrong until they are rolled. So "the stamp misreports only a pending pod" is not true of these two reasons, and a rotation performed while the operator cannot read the secret produces a network the condition describes incorrectly for as long as those pods live.
The mis-stamp window is not confined to failed reads. The two entries above
document what a failed read does to the stamp; the successful path has a
narrower version of the same gap, and nothing in the code or the runbook says
so. Between the Secret changing and the Network controller persisting the new
digest, a pod created by a group controller mounts the new value and is stamped
with the old one. That interval is up to one resyncInterval
(internal/controller/network_controller.go:153,
internal/controller/server_controller.go:75 — five seconds) for the Network
controller to notice, plus informer lag before the group controllers see the
new status.forwardingSecretHash: both builders copy it out of the Network
object their reconciler holds from the cache
(internal/podspec/server.go:441-443, internal/podspec/proxy.go:300-302).
The stamp is written at creation and never revised, so that pod reads as stale
for as long as it lives. The ordinary runbook roll sweeps it up, because it
happens after the digest is recorded — but a pod created that way after its
own group was already rolled, by a scale-up or a replacement, stays falsely
stale until something rolls it again. RotationPending then keeps naming a
group whose work is done. A third way needs no window and no failed read at
all: the premise the stamp rests on — the process reads the file once
(internal/podspec/labels.go:56-60) — holds for the pod and not for the
container, because RestartPolicy is Always
(internal/podspec/server.go:387, internal/podspec/proxy.go:277) and the
forwarding secret is projected without a subPath
(internal/podspec/server.go:166-191, :291), so a container that restarts
after a rotation starts on whatever the kubelet has since refreshed onto that
file, and a pod that crash-looped and then recovered — leaving podTerminal,
and counted again — is reported stale while its process runs the new secret.