Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1664,13 +1664,17 @@ jobs:
# (operator does zero logins and is wait-dominated, so it runs
# wide; the api buckets stay within the login limiter's budget).
# The arm64 job below uses the same steps and per-bucket settings.
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components]
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components, wake-hostport]
arch: [amd64]
include:
- bucket: operator
parallel: 8
test_timeout: 20m
job_timeout: 60 # Boots a kind cluster, installs the chart, runs the bucket's suite.
- bucket: wake-hostport
parallel: 1
test_timeout: 15m
job_timeout: 60
- bucket: api-auth
parallel: 4
test_timeout: 15m
Expand Down Expand Up @@ -1718,7 +1722,9 @@ jobs:
with:
arch: ${{ matrix.arch }}
- name: bring up cluster + helm install
run: ./deploy/kind/e2e.sh up "$CLUSTER" e2e
env:
GAMEPLANE_E2E_PROFILE: ${{ matrix.bucket == 'wake-hostport' && 'two-node' || 'default' }}
run: ./deploy/kind/e2e.sh up "$CLUSTER" e2e "$GAMEPLANE_E2E_PROFILE"
- name: run go e2e (${{ matrix.bucket }})
run: |
RUN_REGEX="$(./test/e2e/buckets.sh regex '${{ matrix.bucket }}')"
Expand Down Expand Up @@ -2510,7 +2516,7 @@ jobs:
'go-e2e-unit': (n) => n === 'go (test/e2e)',
'api-postgres': (n) => n === 'api (postgres)',
'e2e-buckets': (n) => n === 'e2e bucket coverage',
'e2e-go': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components) \//.test(n) && n.includes('/ amd64 '),
'e2e-go': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|wake-hostport) \//.test(n) && n.includes('/ amd64 '),
'e2e-go-arm64': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components) \//.test(n) && n.includes('/ arm64 '),
'e2e-multicluster': (n) => n.startsWith('e2e multicluster /') && n.includes('/ amd64 '),
'e2e-multicluster-arm64': (n) => n.startsWith('e2e multicluster /') && n.includes('/ arm64 '),
Expand Down Expand Up @@ -2663,7 +2669,7 @@ jobs:
const bucketSet = new Set();
for (const j of apiJobs) {
const n = j.name || '';
const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components) \//);
const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|wake-hostport) \//);
if (m) bucketSet.add(m[1]);
if (/^e2e multicluster \//.test(n)) bucketSet.add('multicluster');
if (/^e2e upgrade \//.test(n)) bucketSet.add('upgrade');
Expand Down
4 changes: 4 additions & 0 deletions charts/gameplane/templates/operator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ metadata:
name: {{ .Release.Name }}-operator-watch
labels: {{- include "gameplane.labels" . | nindent 4 }}
rules:
# Direct node identity/readiness checks for durable Hostport wake endpoints.
- apiGroups: [""]
resources: [nodes]
verbs: [get]
- apiGroups: ["gameplane.local"]
resources: [gameservers, gametemplates, backups, backupschedules, restores, modules, modulesources, networkcaptures]
verbs: [get, list, watch]
Expand Down
13 changes: 9 additions & 4 deletions deploy/kind/e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# deploy/kind/e2e.sh down [cluster-name]
#
# Diffs from dev-up (deploy/kind/up.sh):
# - Single-node cluster (faster boot, sufficient for E2E coverage).
# - Single-node cluster by default; two-node profile tests Hostport handoff.
# - Skips ingress-nginx (the dashboard isn't exercised here).
# - Loads pre-built gameplane-test/{operator,api,agent,sentinel,capture-sidecar,telemetry-receiver,fakeoidc}:<tag>
# images (and gameplane-test/gameprobe:<tag> when present).
Expand All @@ -28,9 +28,9 @@ CLUSTER="${2:-gameplane-e2e}"
TAG="${3:-${GAMEPLANE_E2E_TAG:-e2e}}"
PROFILE="${4:-default}"
case "${PROFILE}" in
default | postgres) ;;
default | postgres | two-node) ;;
*)
echo "unknown profile '${PROFILE}' (want default or postgres)" >&2
echo "unknown profile '${PROFILE}' (want default, postgres or two-node)" >&2
exit 2
;;
esac
Expand Down Expand Up @@ -188,12 +188,17 @@ up)
echo "removing pre-existing cluster ${CLUSTER} for a clean slate"
kind delete cluster --name "${CLUSTER}" || true
fi
echo "creating kind cluster ${CLUSTER} (single-node)"
echo "creating kind cluster ${CLUSTER} (${PROFILE})"
extra_nodes=""
if [[ "${PROFILE}" == "two-node" ]]; then
extra_nodes=" - role: worker"
fi
kind create cluster --name "${CLUSTER}" --wait 90s --config - <<EOF
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
${extra_nodes}
EOF

kubectl cluster-info --context "kind-${CLUSTER}" >/dev/null
Expand Down
42 changes: 40 additions & 2 deletions docs/superpowers/specs/2026-07-24-wake-on-connect-design.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Wake-on-connect for idle auto-sleep

**Date:** 2026-07-24
**Status:** approved, not yet implemented
**Status:** implemented; Hostport handoff revised October 2026

## Problem

Expand Down Expand Up @@ -77,7 +77,45 @@ A new Service `<gs>-game-direct` — an always-ClusterIP Service selecting the g
4. Game Service selector flips back to `gameserver: <name>`
5. Sentinel Deployment deleted last

Connections already held in the proxy survive the flip-back because they connect through `-game-direct`, not the re-pointed game Service. In Hostport mode the sentinel relinquishes the host port on deletion; the game pod briefly Pending self-heals via scheduler retry.
Connections already held in the proxy survive the flip-back because they connect through `-game-direct`, not the re-pointed game Service.

**Hostport correction (#730, October 2026):** Hostport uses a separate handoff.
Before its first sleep, the operator saves the live game's node name and Node UID
in `gameplane.local/hostport-node` and `gameplane.local/hostport-node-uid`. These
annotations survive Pod deletion and operator restart; the dashboard's current
`gameplane.local/node` annotation remains a separate, transient observation.
The game completes its normal graceful stop and scales to zero. Only after the
StatefulSet acknowledges that generation and a direct API list confirms all game
Pods have disappeared does the operator create the sentinel. The sentinel has
required node affinity for that identity, the game's tolerations and scheduling
constraints, and a Recreate strategy. It never bypasses the scheduler with
`spec.nodeName`.

On wake, foreground deletion removes the sentinel Deployment and its children.
The game remains at zero until a direct API read confirms the Deployment is gone
and no sentinel Pods remain, including terminating Pods. The game then starts
with the same required endpoint-node affinity. Hostport clients still reconnect;
the old "briefly Pending, self-heals" sequencing is superseded. Service-backed
exposure retains the hold-and-proxy drain described above.

`HostportWakeReady=False` explains `GameDraining`, `SentinelDraining`,
`EndpointNodeUnknown`, `EndpointNodeUnavailable`, `EndpointUnschedulable`, or
`ListenerPending`. A full node stays pinned and reports the scheduler's reason;
a missing, replaced or unready node makes wake unavailable. The operator never
silently changes the advertised node. Unrelated child reconciliation continues
during these waits. After restoring node capacity/readiness, reconciliation
resumes automatically. For intentional migration, suspend the server, wait for
both game and sentinel Pods to disappear, clear both durable endpoint annotations,
update scheduling/storage placement and the client-facing node address, then
resume the game before sleeping it again. Do not clear the annotations while a
listener remains active. An installation upgraded while already asleep without
a recorded game node must first start its game to establish placement.

**Wake budget correction (#729):** The join deadline starts before the Kubernetes
PATCH, which has a separate five-second maximum within that budget. Concurrent
callers share the actual result of the in-flight PATCH. UDP packet reads enqueue
coalesced requests to one bounded worker and never wait on the PATCH. Successful
TCP proxy sessions use the longer session/drain context after upstream connects.

### Connection handling state machine

Expand Down
56 changes: 56 additions & 0 deletions i18n/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,34 @@
"defaultMessage": "The state of the server is not known yet.",
"description": "Healthy condition before the operator has observed the server."
},
"condition.hostportwakeready.endpointnodeunavailable": {
"defaultMessage": "The Hostport endpoint node is missing, replaced or not Ready; restore that node or explicitly migrate the endpoint.",
"description": "Hostport endpoint handoff status: EndpointNodeUnavailable."
},
"condition.hostportwakeready.endpointnodeunknown": {
"defaultMessage": "No game endpoint node has been recorded; start the game before enabling sleep.",
"description": "Hostport endpoint handoff status: EndpointNodeUnknown."
},
"condition.hostportwakeready.endpointunschedulable": {
"defaultMessage": "The Hostport listener cannot schedule on its endpoint node. Inspect the Pod scheduling events for details.",
"description": "Hostport endpoint handoff status: EndpointUnschedulable."
},
"condition.hostportwakeready.gamedraining": {
"defaultMessage": "Waiting for the game Pod to disappear before acquiring its Hostport.",
"description": "Hostport endpoint handoff status: GameDraining."
},
"condition.hostportwakeready.listenerpending": {
"defaultMessage": "Waiting for a Ready listener on the Hostport endpoint node.",
"description": "Hostport endpoint handoff status: ListenerPending."
},
"condition.hostportwakeready.listenerready": {
"defaultMessage": "The listener is Ready on the Hostport endpoint node.",
"description": "Hostport endpoint handoff status: ListenerReady."
},
"condition.hostportwakeready.sentineldraining": {
"defaultMessage": "Waiting for the sentinel Deployment and Pods to disappear before scheduling the game on its endpoint node.",
"description": "Hostport endpoint handoff status: SentinelDraining."
},
"condition.idlescheduleinvalid.wakewindowunparseable": {
"defaultMessage": "A wake window in the idle schedule cannot be parsed: {details}",
"description": "Condition when spec.idle has an invalid wake window. details is the English parse error."
Expand Down Expand Up @@ -423,10 +451,26 @@
"defaultMessage": "The module content does not match its pinned digest.",
"description": "Module condition when the digest differs from the pin."
},
"condition.ready.endpointnodeunavailable": {
"defaultMessage": "The Hostport endpoint node is missing, replaced or not Ready; restore that node or explicitly migrate the endpoint.",
"description": "Hostport endpoint handoff status: EndpointNodeUnavailable."
},
"condition.ready.endpointnodeunknown": {
"defaultMessage": "No game endpoint node has been recorded; start the game before enabling sleep.",
"description": "Hostport endpoint handoff status: EndpointNodeUnknown."
},
"condition.ready.endpointunschedulable": {
"defaultMessage": "The Hostport listener cannot schedule on its endpoint node. Inspect the Pod scheduling events for details.",
"description": "Hostport endpoint handoff status: EndpointUnschedulable."
},
"condition.ready.failed": {
"defaultMessage": "{detail}",
"description": "Ready condition when the server cannot start. detail is the English reason from the operator, for example a missing template or an invalid configuration."
},
"condition.ready.gamedraining": {
"defaultMessage": "Waiting for the game Pod to disappear before acquiring its Hostport.",
"description": "Hostport endpoint handoff status: GameDraining."
},
"condition.ready.idleasleep": {
"defaultMessage": "The server is asleep because no players are online.",
"description": "Ready condition for a server put to sleep by idle auto-sleep."
Expand All @@ -443,6 +487,14 @@
"defaultMessage": "The module cannot be removed while game servers use its template.",
"description": "Ready condition when deletion is blocked."
},
"condition.ready.listenerpending": {
"defaultMessage": "Waiting for a Ready listener on the Hostport endpoint node.",
"description": "Hostport endpoint handoff status: ListenerPending."
},
"condition.ready.listenerready": {
"defaultMessage": "The listener is Ready on the Hostport endpoint node.",
"description": "Hostport endpoint handoff status: ListenerReady."
},
"condition.ready.noversionavailable": {
"defaultMessage": "The module source has no available versions.",
"description": "Module condition when no version can be chosen."
Expand All @@ -463,6 +515,10 @@
"defaultMessage": "The server is running and the agent is reporting heartbeats.",
"description": "Ready condition while the game server is up."
},
"condition.ready.sentineldraining": {
"defaultMessage": "Waiting for the sentinel Deployment and Pods to disappear before scheduling the game on its endpoint node.",
"description": "Hostport endpoint handoff status: SentinelDraining."
},
"condition.ready.servingstalecatalog": {
"defaultMessage": "Serving the cached catalog. The last index attempt failed.",
"description": "Ready condition while an older catalog stays available."
Expand Down
6 changes: 6 additions & 0 deletions operator/config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ rules:
- list
- patch
- watch
- apiGroups:
- ""
resources:
- nodes
verbs:
- get
- apiGroups:
- ""
resources:
Expand Down
11 changes: 11 additions & 0 deletions operator/hack/condition-ids/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ type row struct {
// guardCalls maps a helper that takes a condition reason as a string literal
// to the index of that argument.
var guardCalls = map[string]int{
"hostportCondition": 1,
"hostportPending": 0,
"markUnhealthy": 2,
"markFailed": 2,
"markPending": 2,
Expand Down Expand Up @@ -120,6 +122,15 @@ var rows = []row{
{types: []string{"AddressAssignment"}, reason: "AssignmentPending", english: "Requested $REQ: the address manager has not assigned an address yet.", description: "AddressAssignment condition while the address manager works."},
{types: []string{"AddressAssignment"}, reason: "Assigned", english: "{assignedFrom, select, pool {Address {address} assigned from pool ''{pool}''.} other {Address {address} assigned.}}", description: "AddressAssignment condition once an address is assigned. assignedFrom is pool or default."},

// Hostport endpoint handoff; Ready can mirror the same reason.
{types: []string{"HostportWakeReady", "Ready"}, reason: "EndpointNodeUnknown", english: "No game endpoint node has been recorded; start the game before enabling sleep.", description: "Hostport endpoint handoff status: EndpointNodeUnknown."},
{types: []string{"HostportWakeReady", "Ready"}, reason: "EndpointNodeUnavailable", english: "The Hostport endpoint node is missing, replaced or not Ready; restore that node or explicitly migrate the endpoint.", description: "Hostport endpoint handoff status: EndpointNodeUnavailable."},
{types: []string{"HostportWakeReady", "Ready"}, reason: "GameDraining", english: "Waiting for the game Pod to disappear before acquiring its Hostport.", description: "Hostport endpoint handoff status: GameDraining."},
{types: []string{"HostportWakeReady", "Ready"}, reason: "SentinelDraining", english: "Waiting for the sentinel Deployment and Pods to disappear before scheduling the game on its endpoint node.", description: "Hostport endpoint handoff status: SentinelDraining."},
{types: []string{"HostportWakeReady", "Ready"}, reason: "EndpointUnschedulable", english: "The Hostport listener cannot schedule on its endpoint node. Inspect the Pod scheduling events for details.", description: "Hostport endpoint handoff status: EndpointUnschedulable."},
{types: []string{"HostportWakeReady", "Ready"}, reason: "ListenerReady", english: "The listener is Ready on the Hostport endpoint node.", description: "Hostport endpoint handoff status: ListenerReady."},
{types: []string{"HostportWakeReady", "Ready"}, reason: "ListenerPending", english: "Waiting for a Ready listener on the Hostport endpoint node.", description: "Hostport endpoint handoff status: ListenerPending."},

// GameServer: tunnel and other
{types: []string{"TunnelReady"}, reason: "InvalidConfig", english: "{provider, select, none {The tunnel is enabled but the server has no tunnel configuration.} other {The ''{provider}'' tunnel provider is selected but its configuration is missing.}}", description: "TunnelReady condition for an incomplete tunnel setup. provider is the selected provider, or none."},
{types: []string{"TunnelReady"}, reason: "DeploymentNotReady", english: "Waiting for the tunnel deployment to become ready.", description: "TunnelReady condition while the tunnel pod is not ready."},
Expand Down
24 changes: 21 additions & 3 deletions operator/internal/controller/gameserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ func (r *GameServerReconciler) Reconcile(ctx context.Context, req ctrl.Request)
logger.Error(err, "compute desired replicas")
return ctrl.Result{}, err
}
if plan.blockGame {
replicas = 0
}
if err := r.reconcileStatefulSet(ctx, &gs, &tmpl, ver, mc, replicas, &wipeState); err != nil {
if apierrors.IsConflict(err) {
return ctrl.Result{Requeue: true}, nil
Expand Down Expand Up @@ -442,7 +445,7 @@ func (r *GameServerReconciler) Reconcile(ctx context.Context, req ctrl.Request)
}
}

requeue, err := r.reconcileStatus(ctx, &gs, idle, idleStatus, idleScheduleErr, tunnelPlan, &tmpl, svcEvents, conflictingServer)
requeue, err := r.reconcileStatus(ctx, &gs, idle, idleStatus, idleScheduleErr, tunnelPlan, &tmpl, svcEvents, conflictingServer, plan.hostportCondition)
if err != nil {
return ctrl.Result{}, err
}
Expand All @@ -452,7 +455,7 @@ func (r *GameServerReconciler) Reconcile(ctx context.Context, req ctrl.Request)
// or nothing fires the transition until an unrelated event happens to
// wake the reconciler.
hints := []time.Duration{stopRequeue, idleRequeue}
if plan.wantSentinel {
if plan.wantSentinel || plan.hostportCondition != nil {
// Bounded backstop for planSentinel's wait on the sentinel or the
// game pod. Both are owned (Owns(&appsv1.Deployment{}),
// Owns(&appsv1.StatefulSet{})) so a readiness transition normally
Expand Down Expand Up @@ -1592,7 +1595,22 @@ func (r *GameServerReconciler) reconcileStatefulSet(
// a removed scheduling pin active on the StatefulSet.
ss.Spec.Template.Spec.NodeSelector = gs.Spec.NodeSelector
ss.Spec.Template.Spec.Tolerations = gs.Spec.Tolerations
ss.Spec.Template.Spec.Affinity = gs.Spec.Affinity
// Do not change a live Pod's template merely because its node was
// recorded while a graceful stop is still saving the game. Apply the
// endpoint pin at zero replicas, then retain it on every future wake.
if actualReplicas == 0 || ss.ResourceVersion == "" || ss.Spec.Template.Annotations[hostportNodeAnnotation] != "" {
ss.Spec.Template.Spec.Affinity = hostportAffinity(gs)
if gs.Spec.Networking.Expose == "Hostport" && gs.Annotations[hostportNodeAnnotation] != "" {
if ss.Spec.Template.Annotations == nil {
ss.Spec.Template.Annotations = map[string]string{}
}
ss.Spec.Template.Annotations[hostportNodeAnnotation] = gs.Annotations[hostportNodeAnnotation]
} else {
delete(ss.Spec.Template.Annotations, hostportNodeAnnotation)
}
} else {
ss.Spec.Template.Spec.Affinity = gs.Spec.Affinity.DeepCopy()
}
// Same unconditional-assign reasoning: removing template.spec.security
// must clear a previously-set pod-level FSGroup, not leave it pinned.
ss.Spec.Template.Spec.SecurityContext = gamePodSecurityContext(tmpl)
Expand Down
Loading
Loading