Rotating a Network's forwarding secret¶
You are replacing the value in one Network's forwarding Secret and restarting every pod that reads it, server groups first and proxy groups last. Neither Velocity nor Paper takes two secrets at once or re-reads the file, so until both layers have crossed over a join between them fails with "Unable to verify player details".
<ns> is the namespace, <net> the Network, <secret> the Secret named by
spec.forwardingSecretRef.name.
Before you start: the reader Role¶
The operator needs a namespaced grant to read the Secret: the chart renders it
for namespaces listed in networkNamespaces, anywhere else by hand
(Installing the operator).
kubectl get role,rolebinding spawnery-forwarding-secret-reader -n <ns>
kubectl apply -n <ns> -f config/rbac/forwarding-secret-reader.yaml
Without it the conditions read Unknown SecretReadForbidden and Unknown
SecretUnresolved, and nothing below step 3 can confirm.
The progress command¶
spawnery.cloud/forwarding-hash carries the digest recorded when the pod was
created; written once and never revised, it names the secret that process read
at startup:
kubectl get pods -n <ns> -l spawnery.cloud/network=<net> \
-L spawnery.cloud/role -L spawnery.cloud/group -L spawnery.cloud/forwarding-hash
An empty column means no stamp at all: unknown, not stale.
Step 1: write down what is there now¶
The value is what a rollback writes back; the digest is what step 5 selects on, and afterwards only the new one is kept.
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 <old-hash>, and list what must be rolled, in order:
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 untrimmed: a trailing newline is a different secret and a rotation of its own.
Step 3: confirm the operator saw it¶
The Network controller re-reads the Secret every five seconds. Expect a new
digest, True SecretResolved, and True RotationPending naming each stale
group as role/group=count.
kubectl get network <net> -n <ns> -o jsonpath='{.status.forwardingSecretHash}{"\n"}'
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}'
Where no pod is stamped yet it reads Unknown PodsPredateTracking instead,
and the digest is the confirmation. The event fires once on the transition:
kubectl get events -n <ns> \
--field-selector involvedObject.kind=Network,involvedObject.name=<net>,reason=ForwardingSecretRotated
False SecretKeyMissing means step 2 wrote the wrong key or an empty value.
Fix it before going on: a pod created meanwhile wears the previous digest and
hangs in ContainerCreating with no secret key to mount.
Step 4: roll the server groups, one group at a time¶
Read the two warnings below first. Proxies first would throw every player out at once into a network where no backend is reachable; servers first keeps the proxies up and leaves one hard cut at the end.
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 rolls the server: it reaches Terminating through PodLost
and is replaced. A persistent group's replacement keeps the ordinal and
therefore the claim, so the world comes back.
Step 5: verify the group before moving to the next¶
Select on the old digest, where an empty answer means the group is done, then check that the replacements carry the new one:
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
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 because the two Kinds may share a name.
Without it a same-named proxy group's pods answer too, and the loop comes back
empty only once the proxies are rolled, which is last.
Repeat until no server pod is on <old-hash> or unstamped.
Step 6: roll the proxy groups¶
Same shape, role=proxy. The hard cut starts at the end of step 5, not at the
first delete here: once the server groups have crossed over, a proxy still on
the old secret reaches no backend. Work through them without pausing.
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>
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.
Unknown PodsPredateTracking here means the rotation is not finished. An
unstamped pod was never rolled, and step 5 cannot see it: that check selects
on forwarding-hash=<old-hash>, which an unlabelled pod never matches. Find
them by their empty column, and roll them server pods first:
kubectl get pods -n <ns> -l spawnery.cloud/network=<net> \
-L spawnery.cloud/role -L spawnery.cloud/group -L spawnery.cloud/forwarding-hash
Two warnings¶
kubectl delete pod bypasses the PodDisruptionBudget, and the players on
that pod are disconnected. The PDB refuses the eviction API for a pod
labelled spawnery.cloud/occupied, but a direct delete is not an eviction,
and there is no drain on this path. A rotation is a maintenance window:
announce it, and do not reach for a drain to soften it. Mid-rotation, a drain
moves players onto fallback backends the proxy, still on the old secret,
cannot reach.
The one-ordinal budget does not bind a human. At most one ordinal of a
persistent group is down at a time constrains the operator's takedowns, not
what a person may delete: deleting every pod of such a group takes every
world offline at once. Delete one ordinal's pod, wait for its replacement to
be Ready and carrying the new digest, then the next.
Rollback¶
Write the old value back, exactly as recorded in step 1, then roll back what has already been rolled.
kubectl patch secret <secret> -n <ns> --type merge \
-p '{"stringData":{"secret":"<the value from step 1>"}}'
The same bytes restore the same digest, so the pods already rolled become the stale ones, named the same way and rolled back in the same order; the rest need no action.
ForwardingSecretRotationPending¶
Negative polarity: True is the problem; precedence runs down the table,
unreadable before stale before unstamped.
| 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 guide: server groups first (step 4), then proxy groups (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 (step 7): the unstamped pods have not been rolled; find them by the empty column of the progress 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, using the table below |
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 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, such as an unreachable API server. 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 |
Where the stamp misleads, and why to fix the read first. The stamp is the
operator's last successful read, never revised, while the kubelet projects
whatever the Secret holds. A pod created between the Secret changing and the
digest being recorded (or under the last two reasons, where it starts
normally) runs the new value under the old stamp and reads stale although it
is current; rolling it before the read recovers only mis-stamps it again. A
container restarting after a rotation likewise comes up on the refreshed file
(RestartPolicy is Always, the secret projected without a subPath) under
a pod keeping its first stamp.
SecretNotFound is also an event:
kubectl get events -n <ns> \
--field-selector involvedObject.kind=Network,involvedObject.name=<net>,reason=ForwardingSecretNotFound