Commit Graph

767 Commits

Author SHA1 Message Date
Josh Creek 6ef55affcb feat(multiplayer): add idempotent client mutation retries 2026-09-01 17:02:55 +01:00
Josh Creek 9ae01ecc5a fix(multiplayer): propagate allocated playlist 2026-09-01 17:00:37 +01:00
Josh Creek 8bd1455a2c feat(multiplayer): propagate allocated launch configuration 2026-09-01 16:58:09 +01:00
Josh Creek 315c524c42 feat(multiplayer): propagate allocated launch configuration 2026-09-01 16:57:27 +01:00
Josh Creek 75cb8faac4 feat(multiplayer): acknowledge supervisor shutdown 2026-09-01 16:54:40 +01:00
Josh Creek cc12260225 feat(multiplayer): expose server shutdown acknowledgement 2026-09-01 16:50:26 +01:00
Josh Creek 1d926e705b fix(multiplayer): preserve planned shutdown reason 2026-09-01 16:44:42 +01:00
Josh Creek 4e88ea80f3 feat(multiplayer): handle planned shutdowns on clients 2026-09-01 16:43:47 +01:00
Josh Creek b24f9fc448 feat(multiplayer): notify clients before server shutdown 2026-09-01 16:42:15 +01:00
Josh Creek 8c2dc66c7c fix(multiplayer): release innocent no-show participants 2026-09-01 16:39:46 +01:00
Josh Creek 334d8a26ac feat(multiplayer): enforce allocated initial connect policy 2026-09-01 16:36:26 +01:00
Josh Creek 74e50caf70 feat(multiplayer): publish allocation state events 2026-09-01 16:31:17 +01:00
Josh Creek d15d16d593 feat(multiplayer): publish allocation state events 2026-09-01 16:30:35 +01:00
Josh Creek ec82367c50 fix(multiplayer): close initial connect sweep rows 2026-09-01 16:28:08 +01:00
Josh Creek a6d2bdf8bd feat(multiplayer): sweep initial connect outcomes 2026-09-01 16:27:15 +01:00
Josh Creek bae7458668 feat(multiplayer): apply initial connect outcomes durably 2026-09-01 16:26:06 +01:00
Josh Creek b7642ad2be feat(multiplayer): model initial connect outcomes 2026-09-01 16:21:40 +01:00
Josh Creek 3dbecb0bd5 fix(multiplayer): fence roster topology at persistence 2026-09-01 16:20:31 +01:00
Josh Creek a439a1059b fix(multiplayer): fence roster topology at persistence 2026-09-01 16:20:01 +01:00
Josh Creek 80d6ee8cf5 fix(multiplayer): validate allocated roster shape 2026-09-01 16:18:31 +01:00
Josh Creek 69a8402f11 fix(multiplayer): honor assigned team and slot 2026-09-01 16:17:10 +01:00
Josh Creek 5812386676 feat(observability): log authenticated read routes 2026-09-01 16:14:20 +01:00
Josh Creek e62909d457 docs(multiplayer): mark identity and launch gates 2026-09-01 16:10:39 +01:00
Josh Creek 12b9712a8d test(multiplayer): lock allocated launch overrides 2026-09-01 16:09:57 +01:00
Josh Creek b4ea50d76a fix(multiplayer): reclaim slots by signed identity 2026-09-01 16:08:01 +01:00
Josh Creek bdb3c8f4a7 fix(multiplayer): gate allocated matches on full roster 2026-09-01 16:03:43 +01:00
Josh Creek 498f219ab9 fix(multiplayer): align NA fleet region runtime 2026-09-01 16:01:38 +01:00
Josh Creek c0861bfcad feat(multiplayer): wire allocated fleet runtime 2026-09-01 16:00:03 +01:00
Josh Creek 68e76b5feb feat(multiplayer): deliver allocated server rosters 2026-09-01 15:54:07 +01:00
Josh Creek 863cf61f1a test(multiplayer): verify result websocket fanout 2026-09-01 15:47:08 +01:00
Josh Creek 802e5fc96f test(multiplayer): fence conflicting result races 2026-09-01 15:44:47 +01:00
Josh Creek f3b56f70e3 feat(multiplayer): dispatch completed result events 2026-09-01 15:43:32 +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 dad26a164c fix(multiplayer): recover stale proposal mutations 2026-09-01 15:31:01 +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 79a6092b28 feat(multiplayer): wire production proposal outbox delivery 2026-09-01 15:21:49 +01:00
Josh Creek aa93aeec95 test(multiplayer): verify two-player proposal round trip 2026-09-01 15:17:53 +01:00
Josh Creek 1e1db3525e docs(multiplayer): record the workload-token delivery channel closing
Updates §8.10 and §8.28's cross-reference to reflect the previous two
commits: the token now binds allocation_id only (resolved durably at verify
time, not embedded match/server), and the delivery channel itself is wired
end to end (cmd/allocator mints -> Agones annotation -> supervisor reads ->
Authorization header), not just the verification core. Records what's left:
this has only run against HTTP-level Agones fakes, never a real cluster, so
the object_meta JSON casing remains unverified from this sandbox; fleet.yaml
still needs its own environment-specific manifest values.
2026-09-01 14:58:30 +01:00
Josh Creek 544f76c502 feat(multiplayer): deliver signed workload tokens via Agones allocation annotation
Closes the remaining gap the previous two commits left open: WorkloadVerify
itself worked, but nothing minted a real token at allocation time or handed
it to a running pod, so it had no real caller yet.

agones.Client gains WorkloadSecret/WorkloadTokenTTL. When set, Allocate
mints a signed workload token for the allocation (allocation_id is known at
request-construction time, before Agones has picked a server -- see the
previous commit for why that's the only identifier the token can bind) and
requests it as a third cosmic-clash.io/workload-token annotation, alongside
the existing match-id/allocation-id ones. Left unset (the default), Allocate
requests no such annotation, so a deployment not yet using this path is
unaffected. cmd/allocator wires it from a new --workload-secret /
COSMIC_CLASH_WORKLOAD_SECRET flag (must match cmd/control-plane's own), with
a startup warning if left unset.

supervisor.Supervisor.workloadToken() resolves the bearer credential for
control-plane registration: an explicitly configured --workload-token-path
always wins (kept for a future Kubernetes-projected-JWT WorkloadVerify path,
not yet wired server-side), otherwise it falls back to the
cosmic-clash.io/workload-token annotation on the allocated GameServer --
the same annotation-fallback pattern matchID already used for
cosmic-clash.io/match-id. WorkloadTokenPath is accordingly no longer
required at construction time when ControlPlaneURL is set.

Verified: new agones test proves the annotation is requested (and parses/
verifies against the same secret, naming the right allocation) when
WorkloadSecret is configured, and that it's absent when it isn't; new
supervisor tests prove the annotation-sourced token is what's actually sent
as the Authorization bearer, and that Start fails closed with neither a
configured path nor an annotation present. Full
`go build ./... && go vet ./... && gofmt -l . && go test ./... -race` and
`go test -tags integration ./... -race` both clean.
2026-09-01 14:57:45 +01:00
Josh Creek d588898f5d fix(multiplayer): bind signed workload tokens to allocation_id only
The just-landed signed workload token embedded (allocation_id, match_id,
server_id) as claims. That doesn't actually work for its intended delivery
channel: the token is meant to be requested as a GameServerAllocation
annotation in the SAME request that asks Agones to pick a server, so at mint
time the allocator knows allocation_id (it generates it) but not yet which
server_id Agones will return -- server_id only exists in Agones's response,
after the annotation request has already been sent. Embedding it was simply
not possible for the real caller this was built for; only the (allocator ->
signed_token) unit tests and hand-constructed integration tests happened to
supply it directly, masking the gap.

Fixes it by having the token bind only allocation_id (the one identifier
actually known at mint time) plus expiry. match_id/server_id are resolved at
verify time from the durable allocations table via the new
store.AllocationBindingByAllocationID, keyed by allocation_id -- which the
allocator already records immediately after Agones responds. This is
strictly stronger, not just a workaround: a caller can no longer claim any
match/server pairing at all, even one that happens to be internally
consistent -- the binding returned is entirely durable-record-derived.

Verified: server/workload's unit tests updated for the new two-field claim
shape; server/api's Postgres integration suite gains
TestWorkloadVerifierFromSignedTokenNeverTrustsCallerSuppliedBinding (two
distinct real allocations each resolve to their own, and only their own,
match/server pairing) replacing the now-inapplicable mismatched-triple test.
Full `go build ./... && go vet ./... && gofmt -l . && go test ./... -race`
and `go test -tags integration ./... -race` both clean; the api integration
suite re-run 3x clean against a live postgres:17-alpine container.
2026-09-01 14:54:38 +01:00
Josh Creek 939b7a9584 docs(multiplayer): record WorkloadVerify closed via self-issued token
Updates §8.10 and §8.28's cross-reference in multiplayer-next.md to reflect
the previous commit: the WorkloadVerify blocker both rows named as the actual
next thing standing in the way of a working server registration/result route
is closed, via a control-plane-self-issued signed token rather than the
Kubernetes-JWT approach originally assumed necessary. Records precisely what
remains: the real delivery channel (an Agones annotation carrying a minted
token, and the supervisor reading it) and fleet.yaml's still-unaddressed
manifest wiring.
2026-09-01 14:52:07 +01:00
Josh Creek 520613aab0 feat(multiplayer): implement WorkloadVerify without a Kubernetes trust boundary
WorkloadVerify (api.Service.WorkloadVerify) was permanently unwired: both
/v1/servers/{id}/register and /v1/servers/{id}/result always 503, because
the only design considered so far was verifying a Kubernetes-projected
service-account JWT (server/workload/jwt.go), which needs a live cluster's
TokenReview/JWKS endpoint to validate against safely -- something this
sandbox cannot do without guessing at a trust boundary.

The API layer doesn't actually require that specific mechanism. serverMutation
only compares WorkloadBinding.ServerID and .MatchID (server/api/service.go);
AdvanceServerRegistration only uses .MatchID/.ServerID/.AllocationID. Nothing
downstream needs Namespace/ServiceAcct/PodUID/GameServerUID populated.

This adds a self-contained alternative: a short-lived, HMAC-signed token the
control plane mints and verifies with a secret only it holds (server/workload/
signed_token.go), the same trust model domain.SessionStore already uses for
player sessions elsewhere in this codebase. It needs no cluster to verify --
signature + expiry is fully self-contained and unit-testable.

The design's soundness rests on the delivery channel, not the crypto: the
token is meant to reach the allocated GameServer via the same Agones
GameServerAllocation annotation channel allocation.go already uses for
match-id/allocation-id, readable only by that pod's own local SDK sidecar. A
caller presenting this token has already proven, via that channel, that it is
the pod Agones allocated. (Wiring the actual annotation delivery -- extending
agones.Client.Allocate and the supervisor's token source -- is a separate,
follow-up change; this commit lands the verification core it depends on.)

store.AllocationBindingStillValid adds defense-in-depth on top of signature
and expiry: it cross-checks the token's claims against the durable
allocations table (append-only, never leaves 'ALLOCATED'), so a validly-signed
token naming an allocation that was never recorded -- or a real allocation id
paired with a mismatched match/server -- is still rejected.

api.WorkloadVerifierFromSignedToken wires the two together and is now plugged
into cmd/control-plane (new --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET
flag; a startup warning is logged if it's left unset, since the route then
stays 503 exactly as before) and cmd/testkit-api (fixed test secret, since
that binary is test-only already).

Verified: new unit tests in server/workload (signature tamper, wrong secret,
expiry boundary, malformed input) and a new Postgres integration suite in
server/api (real allocation row, real signed token, acceptance / unknown-
allocation rejection / mismatched-triple rejection / the previously-503
Service.WorkloadVerify field itself) -- both run clean with -race across
multiple passes against a live postgres:17-alpine container. Full
`go build ./... && go vet ./... && gofmt -l . && go test ./... -race` and
`go test -tags integration ./... -race` both clean.
2026-09-01 14:51:21 +01:00
Josh Creek 330f99bb0e docs(multiplayer): record the cancel-cascade fix in task 8.17 2026-09-01 14:40:48 +01:00
Josh Creek 79318b56bd feat(multiplayer): cascade a queue-ticket cancel into an open proposal
The last two commits fixed the severe stranding bug in decline and
timeout, but left a real responsiveness gap: cancelling a ticket
directly while it's part of an OPEN proposal used to leave the OTHER
participant waiting out the full response window for something the
system already knew couldn't happen -- their proposal partner just
abandoned the queue. ProposalExpireRequeueSQL eventually rescues them,
but only after the full window elapses, not immediately.

CascadeCancelToOpenProposal runs inside the same transaction as the
cancel itself: if the cancelled ticket belonged to a currently-OPEN
proposal, decline that proposal right now and requeue every other
participant immediately via the same ProposalDeclineRequeueSQL the
decline path already uses. The cancelling player's own ticket
correctly stays CANCELLED, not swept back into the requeue meant for
everyone else (ProposalDeclineRequeueSQL only touches tickets still at
PROPOSED).

Covered by a real PostgreSQL integration test: cancelling one
participant's ticket mid-proposal immediately declines the proposal
and requeues the other participant with a refreshed expiry, while the
cancelling player's own ticket stays CANCELLED. First draft used a
stale expected revision (0) for the cancel call -- CreateProposal's
own QueueTicketProposeSQL already bumps a ticket's revision to 1 when
forming the proposal, caught immediately by actually running the test
against real Postgres rather than assuming. Clean across 5 runs after
the fix, plus the full integration and unit suites.
2026-09-01 14:40:30 +01:00
Josh Creek fe0a0b72a8 docs(multiplayer): record the proposal-timeout requeue fix in task 8.17 2026-09-01 14:36:50 +01:00
Josh Creek 4627dd58fb fix(multiplayer): requeue every participant after a proposal times out
The timeout sibling of the previous commit's decline fix: a proposal
that simply times out (the 10s window elapses with no unanimous
response) hits ProposalExpireSQL/ProposalParticipantExpireSQL, and
neither of those -- same as the decline path -- ever touched
queue_tickets. Same severe consequence: every participant still
holding a PROPOSED ticket, response pending or already accepted, is
left stranded (invisible to the matcher, blocking a fresh
queue_create, renewable forever by heartbeat) with no automatic way
back into matchmaking. This path is reached from both GetProposal
(the recovery/read boundary -- a client that missed the expiry event
entirely) and RespondToProposal (a response arriving after the
window), so both needed the fix.

ProposalExpireRequeueSQL mirrors ProposalDeclineRequeueSQL, guarded on
state = 'EXPIRED' so it's safe to call unconditionally right after
ProposalExpireSQL: a no-op on a proposal that's still OPEN, and a
no-op on a proposal that was already EXPIRED on a prior pass (nothing
left at PROPOSED to requeue a second time).

Covered by a real PostgreSQL integration test via GetProposal (nobody
ever responds; recovering the proposal well after its window expires
it and must requeue both participants), confirming both tickets land
back at QUEUED with a refreshed expiry and are visible again to
ListQueuedCandidates. Clean across 5 runs, plus the full integration
and unit suites.
2026-09-01 14:36:34 +01:00
Josh Creek c8c363e667 docs(multiplayer): record the proposal-decline requeue fix in task 8.17 2026-09-01 14:33:46 +01:00