domain.validCandidate hard-requires a non-empty PredictedRTT map, but
CreateQueueTicket persisted an empty one and the only endpoint that
could fill it returned 503 in every real binary, because Service.Probe
was assigned nowhere outside api tests. No client-created ticket could
ever be selected by the matcher. The Godot client had no probe method at
all, so even a wired backend was unreachable from the game.
Four distinct defects had to be fixed for this path to work:
Nothing issued the nonce ProbeProvider was meant to compare against, so
the contract could not be satisfied even in principle. Add
POST /v1/probes/{region}/challenge, backed by a durable single-use
challenge -- durable because any replica may serve the answer for a
challenge another replica issued. RTT is the interval between issuing
and receiving, so no client-reported latency reaches placement.
CreateQueueTicket marshalled a nil map to JSON `null`, a JSONB scalar
rather than an object, and jsonb_set rejects that with "cannot set path
in scalar". RecordProbe would have failed at runtime even once wired.
Persist an object, and normalise non-object values in the update for
rows already written.
A nil ProbeRecorder made the handler report success while persisting
nothing, which silently leaves the ticket unmatchable. That is a
misconfiguration, not a successful probe; it now returns 503.
A successful probe updated PostgreSQL only. The candidate inserted at
enqueue time carries an empty RTT map, and the Redis keyspace has its
TTL continually refreshed, so the stale entry need never repair itself.
Refresh that player's projection after the probe commits.
Client side: add the challenge/answer round trip and have the
matchmaking screen collect evidence before creating a ticket, since
queueing first produces a search that can never match. Probing every
region fully is not required -- placement uses whichever regions
answered -- but queueing with none is refused rather than silently
stalling.
New integration test drives the real enqueue and probe paths and then
asks the actual matcher predicate, rather than hand-building a candidate
the way the unit tests do -- which is exactly why they missed this.
Also make the integration schema reset drop the whole public schema: the
enumerated table list silently broke with each new migration.
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.
Closes the second blocker named last commit. Re-traced the actual code
path rather than trusting the earlier assumption: server_boot.gd
verifies its mounted roster file synchronously in _ready(), before
NetworkManager.host() runs and before ServerControl.set_process_ready
is ever called -- so by the time the loopback /ready probe (and thus
Agones Ready, and thus process-ready registration) succeeds, Godot has
already verified its own roster. And the API's ASSIGNMENT_READY gate
(AdvanceServerRegistrationSQL) checks only durable `assignments` rows
server-side, nothing Godot reports. No new Godot-side state was needed
-- the earlier 'needs Godot's own roster-verification state exposed'
claim was overcautious and is corrected here.
The supervisor now calls registerControlPlane(ctx, true) right after
process-ready succeeds, with a bounded retry (default 5 attempts, 2s
apart, both configurable) rather than a single attempt: the durable
`assignments` rows the server-side gate checks may not have propagated
by the first attempt, and that is expected, not fatal. Unlike a
process-ready registration failure, a persistent assignment-ready
failure does NOT kill the child -- the process is already legitimately
listening and usable, and killing a healthy process over a lagging
control-plane read would be actively harmful; it's logged to stderr
instead.
Covered by two tests: the full process-ready-then-assignment-ready
sequence and body shapes, and a retry test that fails the assignment-
ready call twice with 409 (simulating the real gate not yet
satisfied) before succeeding on the third attempt, asserting Start()
still succeeds and the child is never killed.
Investigated the Fleet-manifest wiring task flagged last commit and
found a deeper, previously-undesigned gap: Kubernetes env vars are
fixed at pod creation, but Agones allocates a match to an already-
running Ready pod well after it starts -- so there was no channel at
all for match-specific data (match ID) to reach that pod's processes.
Close it using the Agones GameServerAllocation API's documented
spec.metadata.annotations field, which Agones applies to the allocated
GameServer's own object_meta on success: server/agones.Client.Allocate
now requests cosmic-clash.io/match-id and cosmic-clash.io/allocation-id
annotations, and the supervisor reads them back from the same
/gameserver SDK call it already makes for the assigned port/address
(GameServer.ObjectMeta.Annotations), falling back to them for its own
control-plane registration only when MatchID isn't explicitly
configured -- an explicit value always wins, and a match ID resolvable
from neither source fails Start() closed before any HTTP call.
The exact object_meta vs objectMeta JSON key from a live Agones SDK
sidecar is not independently verified from this sandbox; documented
inline, and the fallback degrades safely (empty annotations map, same
as before this change) if it turns out to be wrong.
Covered by two new tests: the annotation actually flowing through to
the registration body, and fail-closed with neither config nor
annotation supplying a match ID (registerCalled stays false, not just
that Start() errors).
Add opt-in control-plane registration to server/supervisor: once Agones
Ready succeeds, POST /v1/servers/{id}/register (assignment_ready=false)
using a workload token read fresh from disk each call -- matching how a
Kubernetes projected service account token is rotated in place by
kubelet, unlike a cached/env-var secret. ControlPlaneURL empty (the
default) is a total no-op, so direct/Compose mode and allocated-without-
control-plane mode are both byte-for-byte unaffected; New() rejects a
half-configured registration (URL set without token path/server/match/
digest) rather than silently skipping it.
ServerID/MatchID/ImageDigest are read from env vars named by CLI flags
(--server-id-env, --match-id-env, --image-digest-env), matching the
existing --drain-token-env convention in this same binary, rather than
parsed out of the Agones SDK's own GameServer JSON -- that shape isn't
independently verifiable from here, whereas the Kubernetes Downward API
(fieldRef: metadata.name) populating an env var is a standard, safe
pattern already used elsewhere in this codebase for exactly this class
of secret.
A registration failure now kills the child (matching the existing
waitReady failure path) rather than leaving Agones-Ready-but-
control-plane-unregistered process running -- a real gap the second new
test (TestControlPlaneRegistrationFailureKillsChildRatherThanRunningUnregistered)
had to be corrected to actually exercise: its first draft omitted
ReadyURL and was failing at waitReady, before ever reaching the code
path it claimed to test.