Files
CosmicClash/scripts/verify_kind_agones.sh
T
Josh Creek 0de97381b7 fix(agones): stop a dead health loop from passing as a healthy server
Every allocated GameServer reached Ready and was recycled by Agones ~20s
later. Health pings are the game process's job by design -- the
supervisor has no health implementation at all -- so a server that stops
pinging is exactly what Agones is built to reclaim.

start_health() armed a Timer on a node that might not be inside the
SceneTree. A Timer only ticks inside the tree, so the node reported
itself configured, sent nothing, and said nothing about it. It now
returns a bool, refuses loudly when unconfigured, and defers to _ready()
when called before parenting, so the SDK arms its own timer and no
caller has to get the ordering right. server_boot.gd defers the add like
every sibling does (§9 gotcha 27) and logs when AGONES_SDK_HTTP_PORT is
missing, which previously read identically to a healthy start.

Also bounded the in-flight latch: it is set across an await, so a request
that never completes would silence health permanently. Defence in depth
rather than an observed fault.

Tests target the contract rather than the mechanism: a test that parents
the SDK correctly and asserts pings passes with the bug present, because
the defect was in the wiring. The unit tests assert start_health()
cannot claim success out of tree, and were confirmed to fail against the
previous code. The smoke gains a counting sidecar and asserts a
*repeating* ping -- it reports "health pings in 3.0s = 1, want at least
2" when the loop is broken, which is the production symptom exactly. It
is also now actually run: nothing referenced it before.

Two diagnostic fixes, both of which changed conclusions during this work:

The kind gate only built the game-server image when the tag was absent,
so a local rerun silently verified whatever was built last. That is why
local runs and CI disagreed about the same commit. It now builds by
default, with KIND_REUSE_GAME_SERVER_IMAGE=1 as the opt-in fast path.

The failure dump logged only not-ready pods, and used --all-containers
with a shared tail. A GameServer recycled after reaching Ready leaves no
unready pod behind, and the Agones sidecar out-logs the game server, so
the relevant output was never captured. It now dumps every pod, per
container, current and previous, plus the GameServer and Fleet resources
-- Agones' own state machine is what rejects these.
2026-09-05 21:35:50 +01:00

204 lines
9.7 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Disposable integration gate for multiplayer-next.md §8.49. This deliberately
# does not touch an existing cluster: kind creates an isolated cluster and the
# EXIT trap removes only that named cluster.
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root_dir"
cluster_name="${KIND_CLUSTER_NAME:-cosmic-clash-agones-smoke}"
agones_version="${AGONES_VERSION:-1.49.0}"
game_server_image="${GAME_SERVER_IMAGE:-cosmic-clash-game-server:kind}"
kind_node_image="${KIND_NODE_IMAGE:-kindest/node:v1.33.1}"
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/cosmic-clash-agones.XXXXXX")"
# This gate fails in CI with nothing but Helm's "context deadline exceeded",
# and the EXIT trap then deletes the cluster, so there is no way to learn why
# the pods never became Available. Dump enough cluster state on failure that a
# CI run explains itself without needing a local reproduction -- which is not
# equivalent anyway, since a developer machine has different resources and a
# different container runtime.
#
# Set KIND_KEEP_ON_FAILURE=1 to retain the cluster for interactive inspection.
on_error() {
echo "kind/Agones gate failed at ${BASH_SOURCE[0]}:$1" >&2
echo "--- failing command: ${BASH_COMMAND}" >&2
}
trap 'on_error "$LINENO"' ERR
dump_cluster_state() {
echo "=== node capacity and conditions ===" >&2
kubectl get nodes -o wide >&2 2>&1 || true
kubectl describe nodes 2>&1 | grep -A 12 -E "Allocated resources|Conditions:" >&2 || true
for ns in agones-system cosmic-clash; do
echo "=== namespace ${ns}: pods ===" >&2
kubectl -n "$ns" get pods -o wide >&2 2>&1 || true
echo "=== namespace ${ns}: services ===" >&2
kubectl -n "$ns" get services -o wide >&2 2>&1 || true
# Events explain scheduling/image/probe failures that pod status alone
# does not: FailedScheduling, ImagePullBackOff, readiness probe errors.
echo "=== namespace ${ns}: recent events ===" >&2
kubectl -n "$ns" get events --sort-by=.lastTimestamp 2>&1 | tail -40 >&2 || true
# Log EVERY pod, not only the not-ready ones. A GameServer that reaches
# Ready and is then recycled on a health check leaves no unready pod
# behind: the failures are already deleted and the survivors read 2/2
# Running, so filtering on readiness dumped nothing useful and the game
# server's own output went unseen for several CI runs.
for pod in $(kubectl -n "$ns" get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null); do
ready="$(kubectl -n "$ns" get pod "$pod" -o jsonpath='{.status.containerStatuses[*].ready}' 2>/dev/null || true)"
echo "=== ${ns}/${pod} (ready=${ready:-unknown}) ===" >&2
kubectl -n "$ns" describe pod "$pod" 2>&1 | tail -35 >&2 || true
# Per container, not --all-containers: the Agones sidecar is far chattier
# than the game server, so a shared tail hides exactly the output needed,
# and --previous without -c resolves to a container that never restarted.
for container in $(kubectl -n "$ns" get pod "$pod" -o jsonpath='{range .spec.containers[*]}{.name}{"\n"}{end}' 2>/dev/null); do
echo "--- ${ns}/${pod}[${container}] logs (current) ---" >&2
kubectl -n "$ns" logs "$pod" -c "$container" --tail=60 >&2 2>&1 || true
echo "--- ${ns}/${pod}[${container}] logs (previous, if it restarted) ---" >&2
kubectl -n "$ns" logs "$pod" -c "$container" --previous --tail=60 >&2 2>&1 || true
done
done
done
# Agones' own view: a GameServer can be Unhealthy while its Pod looks fine,
# which is precisely the shape of a failed health check.
echo "=== Agones GameServers and Fleets ===" >&2
kubectl get gameservers --all-namespaces -o wide >&2 2>&1 || true
kubectl get fleets --all-namespaces -o wide >&2 2>&1 || true
echo "=== helm releases ===" >&2
helm list --all-namespaces >&2 2>&1 || true
}
cleanup() {
local status=$?
# No reachability guard here: every command inside dump_cluster_state is
# already `|| true`, so a gone cluster costs a few harmless errors, whereas
# a guard that misjudges reachability silently suppresses the whole dump --
# which is exactly what happened on its first run.
if [[ "$status" != 0 ]]; then
dump_cluster_state
fi
if [[ "$status" != 0 && "${KIND_KEEP_ON_FAILURE:-}" == 1 ]]; then
echo "kind cluster retained for inspection: kind-${cluster_name} (delete with: kind delete cluster --name ${cluster_name})" >&2
rm -rf "$work_dir"
exit "$status"
fi
kind delete cluster --name "$cluster_name" >/dev/null 2>&1 || true
rm -rf "$work_dir"
exit "$status"
}
trap cleanup EXIT
for tool in docker kind kubectl helm; do
command -v "$tool" >/dev/null 2>&1 || {
echo "8.49 requires '$tool'; install Docker, kind, kubectl, and Helm to run the disposable gate" >&2
exit 2
}
done
if ! docker info >/dev/null 2>&1; then
echo "8.49 requires a running Docker daemon" >&2
exit 2
fi
kind delete cluster --name "$cluster_name" >/dev/null 2>&1 || true
# Build by default. Reusing whatever happens to be tagged locally silently
# verifies stale code: a developer fixes the game server, reruns this gate, and
# it exercises the previous build because the tag already exists. CI never hits
# that because a fresh runner has no image, which is precisely how a local pass
# and a CI failure can disagree about the same commit.
if [[ "${KIND_REUSE_GAME_SERVER_IMAGE:-}" == 1 ]] && docker image inspect "$game_server_image" >/dev/null 2>&1; then
echo "Reusing existing $game_server_image (KIND_REUSE_GAME_SERVER_IMAGE=1); it may not contain local changes"
else
echo "Building $game_server_image from the pinned game-server target"
docker build --target game-server -t "$game_server_image" .
fi
kind create cluster --name "$cluster_name" --image "$kind_node_image" --wait 120s
kind load docker-image "$game_server_image" --name "$cluster_name"
# Agones creates its SDK service account and namespaced RBAC in each configured
# GameServer namespace. The namespace must therefore exist before Helm runs.
kubectl apply -f deploy/k8s/base/namespace.yaml
helm repo add agones https://agones.dev/chart/stable >/dev/null
helm repo update >/dev/null
# Agones 1.49 otherwise requests 10,100 MiB of ephemeral storage for both its
# controller and extensions pods, which exceeds a default single-node kind
# cluster before the Fleet can be exercised. Its allocator and ping Services
# also default to LoadBalancer, whose ingress never becomes ready in plain kind.
# These are smoke-only bounds; production sizing and exposure remain
# deployment-owned.
helm upgrade --install agones agones/agones \
--namespace agones-system --create-namespace \
--version "$agones_version" \
--set 'gameservers.namespaces[0]=cosmic-clash' \
--set agones.crds.cleanup.enabled=true \
--set agones.controller.replicas=1 \
--set agones.controller.resources.requests.ephemeral-storage=128Mi \
--set agones.controller.resources.limits.ephemeral-storage=512Mi \
--set agones.extensions.replicas=1 \
--set agones.extensions.resources.requests.ephemeral-storage=128Mi \
--set agones.extensions.resources.limits.ephemeral-storage=512Mi \
--set agones.allocator.replicas=1 \
--set agones.allocator.service.serviceType=ClusterIP \
--set agones.ping.http.serviceType=ClusterIP \
--set agones.ping.udp.serviceType=ClusterIP \
--wait --timeout 5m
kubectl wait --for=condition=available deployment/agones-controller \
-n agones-system --timeout=180s
kubectl wait --for=condition=available deployment/agones-allocator \
-n agones-system --timeout=180s
# The production Fleet only schedules on explicitly on-demand, zoned nodes.
# Give the disposable node equivalent labels so this gate exercises those
# constraints instead of rewriting them out of the rendered Fleet.
kubectl label nodes --all \
cosmic-clash.io/capacity-type=on-demand \
topology.kubernetes.io/zone=kind-smoke \
--overwrite
# The base Fleet intentionally carries a release-time digest placeholder. For
# this isolated run only, replace that exact placeholder with the image loaded
# into kind. No repository manifest is modified and no mutable image is used
# outside the disposable cluster.
#
# This runner is intentionally an Agones lifecycle smoke, not a substitute for
# the production control-plane gate: there is no PostgreSQL/API/roster backend
# in this disposable cluster. Disable only those production-only child paths so
# the real supervisor can validate the assigned endpoint, launch the exported
# server, and call the Agones SDK Ready endpoint.
zero_digest="$(printf '0%.0s' {1..64})"
sed -e "s|ghcr.io/cosmic-clash/game-server@sha256:${zero_digest}|$game_server_image|" \
-e 's|--control-plane-url=http://control-plane.cosmic-clash.svc.cluster.local:8080|--control-plane-url=|' \
-e '/- --roster-path=\/run\/cosmic-clash\/join-roster.json/d' \
-e '/- --allocated-mode$/d' \
deploy/k8s/base/fleet.yaml > "$work_dir/fleet.yaml"
kubectl -n cosmic-clash create secret generic cosmic-clash-game-server \
--from-literal=drain-token=kind-smoke-drain-token \
--from-literal=join-signing-keys.json='{"kind-smoke-key":"a2luZC1zbW9rZS1zaWduaW5nLWtleQ=="}' \
--from-literal=join-signing-key-id=kind-smoke-key \
--dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f deploy/k8s/base/service-accounts.yaml
kubectl apply -f "$work_dir/fleet.yaml"
kubectl wait --for=jsonpath='{.status.ready}'=2 \
fleet/cosmic-clash-game -n cosmic-clash --timeout=5m
cat > "$work_dir/allocation.yaml" <<'EOF'
apiVersion: allocation.agones.dev/v1
kind: GameServerAllocation
metadata:
generateName: cosmic-clash-smoke-
namespace: cosmic-clash
spec:
fleet:
name: cosmic-clash-game
EOF
kubectl create -f "$work_dir/allocation.yaml" -o json > "$work_dir/allocation.json"
python3 scripts/verify_agones_allocation_response.py "$work_dir/allocation.json"