Skip to content

Operator flags

cmd/spawnery-operator/main.go reads these from the command line. This page is hand-written, because a flag's meaning is not in its usage string (--help already prints that). A Go test, cmd/spawnery-operator/flags_docs_test.go, fails if a flag is added here and not there, or there and not here.

The chart sets most of these through values.yaml's operator.* keys; see chart values for the ones it exposes and their own defaults, which are not always the flag's own.

The manager also accepts controller-runtime's own logging flags (--zap-log-level, --zap-devel, --zap-encoder, --zap-stacktrace-level, --zap-time-encoding), registered by zap.Options.BindFlags rather than by this file. They belong to controller-runtime, not to spawnery, and are out of scope here.

Manager

--metrics-bind-address

Default: :8080

Where the Prometheus metrics endpoint listens. See metrics and alerts for what it serves.

--health-probe-bind-address

Default: :8081

Where /healthz and /readyz listen. /readyz reports ready only once this replica holds the leader-election lock. A standby answers not the leader yet rather than ready, because the agent gRPC endpoint is leader-bound and a standby serving readiness would attract agents into a registry no controller reads.

--leader-elect

Default: true

Runs leader election among replicas. On by default so that running more than one replica is never a decision made twice, once when the chart was written and once when the flag was flipped. Set to false for a local go run outside a cluster: left on, controller-runtime looks for a ServiceAccount token mount that only exists inside a pod, and a local run dies at startup.

--namespace

Default: empty, meaning every namespace

Restricts the manager's cache, and therefore every controller, to one namespace. A multi-tenant installation running one operator per tenant sets this; the default is a cluster-wide operator watching every Network there is.

--operator-namespace

Default: $POD_NAMESPACE

The namespace the operator itself runs in: where the TLS secret for the agent channel lives and where the agent-facing Service is expected. The chart sets POD_NAMESPACE from metadata.namespace, so this is normally never passed explicitly. Empty and unset together refuse to start; see --agent-session-deadline below for why a wrong value here is worse than no value.

The agent channel

Every game server and proxy pod holds exactly one gRPC stream to the leader. These three flags bound that stream's lifetime and how the operator judges it.

--report-interval

Default: 5s

How often an agent reports its player count. A count older than twice this is stale. It also sets the ceiling on how quickly the operator can notice a server on a node that has died and start moving players off it. The operator logs a warning at startup if this value leaves too little room before Velocity's own read timeout disconnects them first.

--startup-deadline

Default: 5m

How long a server may take to reach Ready before phase.Decide counts it Failed.

--player-status-interval

Default: 30s

How often an unchanged player count is still written into the Server status. Status is for observers, not for the control loop (nothing here reads it back), so this trades API write volume against how stale the number looks to kubectl get.

--permission-check-interval

Default: 10m

How often the operator asks the API server, via SelfSubjectAccessReview, whether it still holds the permissions internal/rbacaudit expects. A negative value checks once at startup and never again. The repeat cost was measured at 73 reviews per check, 54ms.

--agent-bind-address

Default: :9443

Where the agent gRPC endpoint listens. This is the port behind the Service a game server pod actually dials: the leader, not the pod, since the endpoint is a leader-bound runnable.

--agent-session-renew-after

Default: 8m

When an agent should open a fresh stream rather than keep using the one it has. Must be below --agent-session-deadline, or the operator would cut every stream mid-renewal; the operator refuses to start otherwise.

--agent-session-deadline

Default: 10m

When the operator closes an agent's stream regardless of activity. Bounded above by the agent token's own lifetime: a stream is authenticated once, when it opens, so it may not outlive the token that opened it. Otherwise a stolen token would stay useful for longer than its own expiry says. The operator refuses to start if this is set above that lifetime. A CA rotation also waits out this same duration before switching the serving certificate, so that every stream opened under the old CA has closed and reopened under the new one by the time the switch happens.

Orphan sweep

--orphan-interval

Default: 1m

How often the orphan reconciler runs: the pass that catches the two directions no watch covers by itself (a managed pod whose Server is gone, and a Server whose group is gone) and drops registry entries for pods that no longer exist, so that map stays bounded.

Node draining

--drain-taint

Default: none; repeatable

A taint key, beside spec.unschedulable, that marks a node as departing. Pass it once per key: --drain-taint karpenter.sh/disrupted --drain-taint node.kubernetes.io/unreachable. Only the NoSchedule and NoExecute effects on a matching key count. PreferNoSchedule does not stop the scheduler putting the replacement pod straight back on the same node, so honouring it would condemn a pod, rebuild it there, and condemn that one next pass.

This is a bare key, not key=value:Effect. The value is never compared, only the key and the effect read off the node's own taint. Passing a whole taint, the shape kubectl taint and most tutorials use, matches nothing: the flag is accepted, nothing ever drains, and nothing says why. The operator warns only for a small set of taints other autoscalers are known to use (ToBeDeletedByClusterAutoscaler, karpenter.sh/disrupted, karpenter.sh/disruption); a key of an operator's own choosing that is simply absent from the cluster cannot be told from a typo by anything here.

What condemning a server does depends on whether its group is ephemeral, persistent or on-demand. An ephemeral group treats a condemned server exactly like a stale one: a replacement is created before the condemned server is removed, so the group never drops below its target count. A persistent group cannot do that. Its ordinal is tied to a ReadWriteOnce claim, and a second pod mounting that claim while the first is still draining does not fail cleanly, it hangs on the volume, so the replacement for a persistent ordinal waits for the condemned server's drain to finish, bounded by spec.drain.timeoutSeconds. Draining a node that holds a persistent world is a real, bounded gap for that world, not a hot changeover: the group cannot replace what it condemns until it has finished condemning it. An on-demand group's member is simply removed, players moved first: nothing replaces it and nothing waits on it, because its world is on its claim and its key is free for its owner to start again.

Claim-backed sources

Three switches, one per field that can point a group at a PersistentVolumeClaim an administrator already filled. All three are off by default, and turning one on is a values edit and a Deployment restart, not a CRD change.

None of the three is a security control: a PersistentVolumeClaim is a namespaced object in the same trust domain as the group that names it, so anybody who could write the group could have written the claim. What a switch actually buys is an installation being able to say this cluster runs no third-party plugins (or files, or arbitrary mounts) and have that be a fact the operator enforces rather than a convention nobody checks. The chart's own values.schema.json says the same thing about the matching values.

Until 0.2.x, a single flag (what is now --allow-plugin-volumes) governed both spec.extraPlugins and spec.mounts together. An installation upgrading from before that split needs both flags now if it relied on the old one for mount-backed claims; leaving only the old flag on will start refusing groups that name a claim-backed mount.

--allow-plugin-volumes

Default: false

Lets a group name a spec.extraPlugins claim, whose contents the entrypoint copies into every server's plugins/ directory on start. See plugins from a volume.

--allow-file-volumes

Default: false

Lets a group name a spec.extraFiles claim, whose tree the entrypoint copies into every server's whole working directory on start, for files that do not belong under plugins/, such as a Sponge configuration. See files from a volume.

--allow-mount-volumes

Default: false

Lets a group's spec.mounts name a PersistentVolumeClaim rather than only a ConfigMap or a Secret. Split out of --allow-plugin-volumes in 0.2.x, so that turning on plugin claims does not also turn on arbitrary claim mounts. See mounts and files.

--aot-cache

Default: false

Mounts the startup cache that each release publishes beside its Purpur images (ghcr.io/spawnery/purpur-aot, from image version 0.23.0) into the servers running them, at /var/run/spawnery/aot. The JVM then maps the classes it would otherwise load and link on every start. Other images get nothing. Like an image source for plugins, it needs the ImageVolume feature; see plugins from a volume, a container runtime that supports them, and nodes that can pull ghcr.io/spawnery/purpur-aot. Turning it on restarts nothing: servers pick it up at their next start.

--world-sync

Default: false

Serves groups whose spec.storage.backend is ObjectStore. Their members get no data claim; spawnery-worldsync on the nodes keeps their worlds in a bucket (see Worlds in an object store). The operator reads the bucket from WORLDSYNC_ENDPOINT, WORLDSYNC_REGION, WORLDSYNC_BUCKET, the optional WORLDSYNC_PREFIX, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in its environment. It writes a deletion marker for each world deleteServer removes, and a sweeper deletes marked worlds once no node holds them. With the flag off, a member of such a group gets no pod, its Accepted condition is False with reason WorldSyncOff, and deleteServer refuses it.

--world-sync-snapshot-interval

Default: 5m

How often a member of an ObjectStore group asks for a snapshot of its world, and so the most play a node loss costs. A member takes a new value at its next start. Only read with --world-sync.