Commit Graph

51 Commits

Author SHA1 Message Date
Josh Creek 52ee181042 fix(kind): validate the allocation response Agones actually returns
With the Fleet readiness wait corrected, the gate reached the allocation
check for the first time and failed with "allocation did not return a
GameServer" -- while the cluster dump shows the allocation plainly
succeeded: one GameServer Allocated, Fleet reporting ALLOCATED 1.

GameServerAllocationStatus is flat: state, gameServerName, address,
ports, nodeName. It does not embed the allocated GameServer. The
validator read status.gameServer.metadata.name and
status.gameServer.status.{address,ports}, a shape Agones never sends,
and its unit tests asserted that same invented shape -- so validator and
tests agreed with each other while both disagreed with Agones. Nothing
caught it because the gate had never once allocated anything.

Read the real fields, keeping every existing check: non-empty name,
address neither blank nor unspecified, exactly one named "game" port in
range.

Also print the response body when validation fails. work_dir is removed
by the EXIT trap, so a shape mismatch was otherwise invisible from CI --
which is how this survived. If the shape is still not what I expect, the
next run says so instead of costing another round trip.
2026-09-05 22:03:41 +01:00
Josh Creek 4f48f0a6a8 fix(kind): wait on the Fleet field that exists
The gate asserted `--for=jsonpath='{.status.ready}'=2`. An Agones Fleet's
status carries replicas, readyReplicas, reservedReplicas and
allocatedReplicas -- there is no `ready` -- so the wait could never match
however healthy the Fleet was.

It failed in the most misleading way available: as "the Fleet never
became ready", which sent three separate investigations after the game
server. Two of those found genuine bugs, but the gate would have stayed
red with both fixed.

The evidence is in the previous CI run's own dump, which the new
per-container diagnostics produced: both GameServers Ready and stable for
5m6s, and the Fleet reporting DESIRED 2 / CURRENT 2 / READY 2, while
kubectl wait timed out beside it. That same dump also confirms the health
fix in 0de97381 worked -- those GameServers had been churning every ~20s
before it.

Assert the corrected jsonpath in test_fleet_manifests.py and reject the
old one, alongside the build-by-default behaviour, so neither silently
regresses.
2026-09-05 21:55:20 +01:00
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
Josh Creek ca70568fad fix(agones): make the kind gate's Agones lifecycle actually work
Several independent causes, all of which had to be right before the
Fleet could reach Ready.

The supervisor pointed --sdk-base-url at 127.0.0.1:9357, which is the
Agones sidecar's gRPC port; its HTTP surface is 9358, and that is what
AGONES_SDK_HTTP_PORT carries and what agones_sdk.gd reads. An HTTP
client against the gRPC port could never have worked, in kind or in
production.

The supervisor also treated the sidecar's first incomplete /gameserver
response as fatal. The sidecar accepts requests before the controller
populates status.address and status.ports, so this produced a restart
loop precisely during normal Agones startup. It now polls until the
endpoint is assigned or ReadyTimeout elapses.

server_boot.gd started ServerControl and the Agones SDK only under
--allocated-mode, but the kind smoke deliberately strips that flag, so
nothing served the readiness probe and the GameServer could never become
Ready. Lifecycle now keys on AGONES_SDK_HTTP_PORT, which Agones injects
into every managed container, while allocation and roster semantics stay
tied to --allocated-mode. The SDK node is added to the tree
non-deferred, since start_health() creates a Timer immediately.

Fleet: Agones assigns its own SDK service account and masks that token
from the game container while keeping it for the injected sidecar, so
the manifest must not pin serviceAccountName or
automountServiceAccountToken. Godot stores user:// under HOME, so HOME
points at the writable runtime volume to keep the root filesystem
read-only, and fsGroup makes that volume writable for the non-root user.

Namespace: Agones' Dynamic port policy injects a hostPort, which both
the baseline and restricted Pod Security Standards forbid, so the
workload namespace enforces privileged while continuing to audit and
warn against restricted.

NetworkPolicy: the injected sidecar reaches the Kubernetes API over
HTTPS, and NetworkPolicy applies to the whole Pod rather than to the
container whose token was masked.

The kind runner creates the namespace before Helm so Agones can install
its per-namespace SDK RBAC, scopes gameservers.namespaces to it, forces
the allocator and ping Services to ClusterIP because LoadBalancer
ingress never becomes ready in plain kind, and labels the node so the
production Fleet's on-demand/zone constraints are exercised rather than
edited out of the rendered manifest.
2026-09-05 20:50:01 +01:00
Josh Creek 8aa4af3a3a test(kind): always dump on failure, and record the second Agones failure
The dump added in 9ab1bec8 never ran. A `kubectl cluster-info`
reachability guard suppressed it, so its first exercise produced exactly
the silence it was written to prevent. Every command inside is already
`|| true`, so the guard bought nothing and cost the whole dump; removed.

That run did establish something the CI logs cannot: after clearing
local Docker pressure, the Agones install completes cleanly (controller
and allocator both reach "condition met") and the gate instead fails
later, waiting for the Fleet's game-server pods to become Ready. CI
never reaches that point because the Agones install times out first.

So there are likely two failures stacked, and fixing the CI timeout will
probably expose the Fleet one. Recorded in AGONES-CI-INVESTIGATION.md
along with the reasons the Fleet failure warrants suspicion -- fleet.yaml
changed its join-signing key mount from raw bytes to a JSON map this
branch -- and the reasons it may be unrelated.
2026-09-05 20:03:16 +01:00
Josh Creek 9ab1bec89a test(kind): dump cluster state before the Agones gate deletes its cluster
This gate fails with nothing but Helm's "context deadline exceeded" and
three Deployments reporting Available: 0/1, then the EXIT trap deletes
the cluster -- so there is no way to learn why the pods never became
ready. Both CI runs and a local run are equally uninformative.

Dump node capacity and conditions, pods and recent events for
agones-system and cosmic-clash, and describe plus current/previous logs
for every not-ready pod, on any failure and before deletion. Events
matter as much as pod status here: FailedScheduling, ImagePullBackOff
and probe failures are all invisible in a status column.

KIND_KEEP_ON_FAILURE=1 retains the cluster for interactive inspection.

Same approach that just found the allocated-Compose cause, where a
silent assertion had hidden a real 422-instead-of-409 API bug across
several CI runs.
2026-09-05 19:57:05 +01:00
Josh Creek fc2f5c8669 test(compose): report the actual status when the idempotency conflict check fails
The ERR trap added in 432e5a11 located the CI failure at the
`[[ "$conflict_status" == 409 ]]` assertion, but the request discarded
its body and the assertion printed nothing, so three failing runs never
revealed what the API actually returned.

Print the status and body on mismatch.
2026-09-05 19:27:35 +01:00
Josh Creek 432e5a11e8 test(compose): make the allocated smoke explain its own CI failures
This suite fails on GitHub Actions while passing locally, and it failed
the same way at 089c127c -- the branch head before any of this branch's
recent work -- so it is pre-existing rather than newly broken.

Diagnosing it is currently impossible from CI alone. The script is
mostly `curl -fsS` and bare [[ ]] assertions under `set -e`, all of
which abort with no output, so the run log contains nothing but
"make: *** Error 1". Both failing runs are equally silent.

Add an ERR trap that reports the script line and the failing command,
and dump `compose ps` plus the service logs on any non-zero exit rather
than only when COMPOSE_KEEP_ON_FAILURE is set. The next CI run should
therefore say what actually broke instead of needing another round trip
to find out.

No behaviour change on success; the target still passes locally.
2026-09-05 19:19:45 +01:00
Josh Creek 61a073099d fix(store): widen the serializable retry budget, stop leaking test volumes
Two things that made the integration gate untrustworthy.

The retry budget was too small for expected contention.
TestPostgreSQLConcurrentIdenticalResultSubmission fires five identical
concurrent submissions and requires all five to succeed; it failed 4 runs
in 20. The error was retryable and retries did fire -- three attempts
simply was not enough. Contention here is normal rather than
exceptional: several game servers can submit results, and several
matchers can claim candidates, against the same rows at once. Raised to
five attempts, which is 0 failures in 40 runs.

Also jittered the backoff, but measured rather than assumed: my first
theory was a thundering herd, since the delay was exactly
RetryBackoff*(attempt+1) and every loser of a race woke at the same
instant. Isolating the two changes showed jitter alone moved 4/20 to
3/20, while the budget alone reached 0/20. The budget was the real
constraint. Jitter is kept because it costs nothing and its benefit
grows with the number of contending writers -- production is not capped
at five -- but the comment now says plainly that it is the smaller half,
so nobody inherits my wrong explanation.

Second, the integration scripts leaked one throwaway database volume per
run. --rm does reclaim anonymous volumes on a normal exit, but these
scripts force-remove the container from a trap, and `docker rm -f`
without -v keeps the volume. Sixty-four accumulated during this branch
until PostgreSQL stopped starting, surfacing only as the scripts' own
readiness timeout rather than as a disk error -- which is what the
"Docker storage exhausted locally" notes were really describing.
Measured at one volume per run before, zero after, across all five
scripts.
2026-09-05 17:14:43 +01:00
Josh Creek 5765532409 fix(allocator): publish signed assignment rosters before servers start
The root blocker (issue #14). The worker bound the provider allocation
and stopped. Service.PublishRoster and store.SaveVerifiedAssignmentRoster
both existed, fully tested, with zero non-test callers, and the
production allocator configured neither a roster store nor a signing
key. Nothing ever wrote the assignments table.

The allocated supervisor fetches a non-empty roster before it launches
the game child, so every real allocation failed at that fetch: no match
could reach ASSIGNMENT_READY or accept a player. Existing tests seeded
assignments directly, which is exactly why the missing hand-off went
unnoticed.

The worker now builds one join authorisation per durable participant,
signs each with the active key, and publishes them. Participants are
read through the same query SaveVerifiedAssignmentRoster re-validates
against, so the allocator cannot construct a roster the persistence
boundary would reject. The manifest commits to a digest over the whole
roster, so a server cannot be handed a truncated roster whose surviving
entries are each individually valid.

Persist the provider endpoint on the allocation: it arrived on the
provider response and was never stored, so a worker crashing between
allocating and publishing had no endpoint to recover and would have
stranded the match permanently. Republishing is idempotent, so that
crash now simply retries.

cmd/allocator refuses to start without key material rather than running
an allocator that binds allocations and silently strands every match.
The k8s allocator Deployment mounts the same key set the Fleet does, and
both now take the JSON key map so a rotation can publish several.

New integration test drives the real worker through to the supervisor's
own roster read path without seeding the assignments table. Verified it
fails with "assignments = 0, want 2" when the publish step is removed.
2026-09-05 10:42:31 +01:00
Josh Creek b8bcc1f3c1 feat(join-auth): add key-ID rotation to signed join authorisations
Prerequisite for wiring the allocator to publish rosters. The signing
key is a shared HMAC secret mounted into both the allocator and the
allocated game server; without a key ID, rotating it would invalidate
every authorisation already issued for an in-flight match, because a
server holding only the new key cannot verify a token signed with the
old one.

Add KeyID to JoinAuthorisation and append it to the canonical claim
bytes, so it is covered by the signature and cannot be repointed at a
different key than the one that actually signed. Allocated servers now
hold a set of currently-valid keys and select by ID: a rotation
publishes the new key alongside the old, and the old is dropped once no
live match can still reference it.

The key file becomes a JSON map of key ID to base64 key. A file of raw
key bytes is still accepted as a single key under the empty ID, which is
what an unrotated deployment and the kind fixture use.

Game/scripts/match_net.gd builds the canonical bytes independently, so
it changes in lockstep; the cross-language golden token in
test_match_net.gd is regenerated from the Go implementation and now
carries a key ID. Added tests cover accepting either key mid-rotation,
rejecting a retired key ID, and rejecting a token whose key ID was
swapped to name a key the server does hold.

Go suite and 223 Godot tests pass.
2026-09-05 10:36:06 +01:00
Josh Creek 2c648514ba test(server): fix and wire up the two unrun Python suites
test_contracts.py required operation ID `recordPlayerConnected`, but
openapi.json names that endpoint `claimPlayerConnection` — the accurate
name, since POST /servers/{id}/connect claims a connection lease and
returns a generation. Align the test on the document and assert the set
difference, so a future mismatch names the missing operation instead of
reporting "False is not true".

test_observability_manifests.py copied only two of the four files the
checker reads, so it died on a missing kustomization.yaml before ever
reaching the mutated namespace. Copy the full fixture, split the
namespace and scrape-path mutations into separate cases so either
defect produces its own diagnostic, and add an unmutated-copy case so a
broken fixture can't make the mutation cases pass vacuously.

Neither suite was invoked by any Make target or workflow, which is why
both could sit red. Add them, plus test_threat_model.py, to
verify_multiplayer_local.sh.
2026-09-05 10:10:11 +01:00
Josh Creek 817572a6ce fix(multiplayer): size kind agones smoke resources 2026-09-04 17:07:13 +01:00
Josh Creek a5cbba8ac9 fix(multiplayer): harden live registration verification 2026-09-04 16:48:25 +01:00
Josh Creek d64920b0f9 fix(multiplayer): repair allocated compose verification 2026-09-04 16:38:10 +01:00
Josh Creek 9a724bf562 test(multiplayer): recover from native Godot crashes 2026-09-03 21:28:21 +01:00
Josh Creek 2050acd63d test(multiplayer): make local gate portable 2026-09-03 21:12:40 +01:00
Josh Creek bcc2639a33 fix(multiplayer): deploy reconnect abandonment maintenance 2026-09-03 21:05:58 +01:00
Josh Creek 9b14109727 test(multiplayer): retain failed database container 2026-09-01 23:08:01 +01:00
Josh Creek 870b89d279 test(multiplayer): expose database gate failures 2026-09-01 23:07:08 +01:00
Josh Creek d04523accd feat(multiplayer): package observability resources 2026-09-01 19:10:10 +01:00
Josh Creek 95e82cc719 feat(multiplayer): wire allocator metrics discovery 2026-09-01 18:46:31 +01:00
Josh Creek 9a72dd5eab feat(multiplayer): expose allocator quota metrics 2026-09-01 18:43:55 +01:00
Josh Creek 71935e61b5 test(multiplayer): add chaos recovery smoke 2026-09-01 18:30:09 +01:00
Josh Creek 4b243f1a47 test(multiplayer): add release promotion gate 2026-09-01 18:26:56 +01:00
Josh Creek 7c044b7094 test(multiplayer): harden agones allocation gate 2026-09-01 18:16:54 +01:00
Josh Creek b72a7cf843 test(multiplayer): cover compose allocator binding 2026-09-01 18:02:05 +01:00
Josh Creek 063dff463d test(multiplayer): drive compose proposal orchestration 2026-09-01 17:58:37 +01:00
Josh Creek 00d6b1edd3 test(multiplayer): cover compose queue lifecycle 2026-09-01 17:56:47 +01:00
Josh Creek b33f681ffa test(multiplayer): exercise compose supervisor drain 2026-09-01 17:54:16 +01:00
Josh Creek b1783abdce test(multiplayer): isolate agones lifecycle smoke 2026-09-01 17:52:02 +01:00
Josh Creek 88eb510dc3 test(multiplayer): add allocated compose smoke flow 2026-09-01 17:50:52 +01:00
Josh Creek 9b76d47c52 test(multiplayer): add disposable kind agones gate 2026-09-01 17:43:35 +01:00
Josh Creek 7170400f49 test(multiplayer): verify observability manifests locally 2026-09-01 17:34:15 +01:00
Josh Creek 65f0ad5dfd test(multiplayer): strengthen local verification gate 2026-09-01 17:14:18 +01:00
Josh Creek 98ff2aed81 test(multiplayer): detect macOS Godot bundle 2026-09-01 17:13:18 +01:00
Josh Creek d1bcb51225 test(multiplayer): add consolidated local gate 2026-09-01 17:07:44 +01:00
Josh Creek 863cf61f1a test(multiplayer): verify result websocket fanout 2026-09-01 15:47:08 +01:00
Josh Creek 9ec707674a test(multiplayer): verify allocated supervisor registration 2026-09-01 15:39:28 +01:00
Josh Creek 207ab47866 test(multiplayer): verify allocator worker lifecycle 2026-09-01 15:34:51 +01:00
Josh Creek 04b5d3b29d feat(multiplayer): verify ranked profile delivery 2026-09-01 15:29:49 +01:00
Josh Creek d081a72b9a feat(multiplayer): wire ranked profile runtime policy 2026-09-01 15:28:37 +01:00
Josh Creek f096e8ff0b test(multiplayer): verify live assignment delivery 2026-09-01 15:25:36 +01:00
Josh Creek aa93aeec95 test(multiplayer): verify two-player proposal round trip 2026-09-01 15:17:53 +01:00
Josh Creek 521b8122ac test(multiplayer): add a real Go+Postgres+Godot end-to-end integration test
Every existing test of the client/control-plane boundary is either a
Go unit test with a mocked HTTP layer or a GDScript unit test with no
network at all (multiplayer-next.md 8.40's own evidence names "live
multi-process control-plane/game verification" as remaining). Nothing
before this actually ran the real compiled Go binary, a real
PostgreSQL instance, and a real headless Godot process talking real
HTTP to each other -- and it immediately found a real bug (previous
commit).

server/cmd/testkit-api is a new, deliberately separate, clearly-marked
test-only binary wired identically to cmd/control-plane except for
SteamLogin: cmd/control-plane has no way to authenticate against a
real Steam Web API from this sandbox (task 8.7's own documented
blocker), so testkit-api accepts any non-empty ticket string and
derives a deterministic identity instead. This bypass is confined to
its own binary -- never a flag on cmd/control-plane, never referenced
by any Dockerfile stage or Kubernetes manifest -- specifically so it
can't become a footgun on the real one.

Game/tests/control_plane_smoke.gd drives the real ControlPlaneClient
autoload through login -> queue_create -> heartbeat against a real
server and prints SMOKE PASS/FAIL, matching the existing net_smoke.gd
convention. scripts/verify_control_plane_integration.sh orchestrates
both sides (real postgres:17-alpine, the built testkit-api binary, the
Godot client) end to end.

Two real bugs surfaced building this, both fixed and re-verified, not
just the target bug: the smoke script's own use of `go run` left a
zombie process that survived cleanup and squatting on its port
corrupted the NEXT run with a misleading "http=401 unauthorized" (now
builds and runs a real binary directly, plus a belt-and-suspenders
port-kill in cleanup); and calling heartbeat() synchronously from
within a request_succeeded handler produced a spurious "Busy" because
ControlPlaneClient's own internal resync (see previous commit) was
still in flight -- the test now waits for ControlPlaneClient to go
idle via a real Timer (call_deferred alone floods the message queue
without ever yielding a frame for the in-flight request to complete).

Verified stable across 3 consecutive full runs: real PostgreSQL
container up, migrations applied, testkit-api built and started, real
headless Godot client round-tripping login/queue/heartbeat, clean
teardown with no leftover processes, containers, or bound ports each
time.
2026-09-01 14:13:52 +01:00
Josh Creek 17bd7768eb test(multiplayer): add real-Redis integration coverage for the candidate index
Every existing RedisCandidateIndex test runs against miniredis -- a
from-scratch Go reimplementation of the Redis command set, not real
Redis's own float64 score encoding, TTL/expiry, or RESP behavior.
Add an opt-in integration suite (mirroring postgres_integration_test.go's
pattern: //go:build integration, COSMIC_CLASH_REDIS_ADDR-gated) plus
scripts/run_redis_integration.sh against a disposable redis:7-alpine
container, covering:

  - upsert/snapshot/remove against a real server
  - a real TTL actually waited out (not miniredis's manual
    FastForward), proving expiry really happens on the wire
  - the documented 'Redis restart or lost keyspace' repair path
    exercised against an actual FLUSHALL, not a simulated empty map,
    including that the repair actually persists back to Redis (a
    second snapshot reads it without a second durable-source call)

Verified stable across 3 runs with -race against a real container.
2026-09-01 13:20:09 +01:00
Josh Creek 5234390931 test: add PostgreSQL integration harness 2026-09-01 08:58:28 +01:00
Josh Creek 91e536425d feat: enforce supply chain policy 2026-08-31 21:32:03 +01:00
Josh Creek 23c1c3d231 ci: add ENet integration coverage 2026-08-21 19:57:59 +01:00
Josh Creek 6bafdc7794 feat(multiplayer): add Steam transport foundation 2026-08-21 18:52:07 +01:00