mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
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.
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/agones"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/migrations"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/store"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
@@ -34,7 +35,7 @@ func TestRealAllocatorWorkerReconcilesAgonesAllocationAndBindsMatch(t *testing.T
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS schema_migrations, assignments, audit_events, outbox, result_receipts, ranked_season_rollovers, penalties, seasons, ratings, match_participants, matches, proposal_participants, proposals, allocations, game_servers, queue_tickets, idempotency_keys, sessions, identities CASCADE`); err != nil {
|
||||
if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS schema_migrations, allocation_quotas, assignments, audit_events, outbox, result_receipts, ranked_season_rollovers, penalties, seasons, ratings, match_participants, matches, proposal_participants, proposals, allocations, game_servers, queue_tickets, idempotency_keys, sessions, identities CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Apply(ctx, db, filepath.Join("..", "migrations")); err != nil {
|
||||
@@ -112,3 +113,149 @@ func TestRealAllocatorWorkerReconcilesAgonesAllocationAndBindsMatch(t *testing.T
|
||||
t.Fatalf("recorded allocations=%d err=%v", recorded, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The root blocker: the worker bound the provider allocation and stopped.
|
||||
// Service.PublishRoster and store.SaveVerifiedAssignmentRoster both existed but
|
||||
// had no non-test callers, so nothing in production ever wrote the assignments
|
||||
// table. The allocated supervisor fetches a non-empty roster before launching
|
||||
// the game child, so every real allocation died at that fetch and no match
|
||||
// could reach ASSIGNMENT_READY or accept a player.
|
||||
//
|
||||
// This drives the real worker and asserts against the durable tables. It never
|
||||
// seeds the assignments table, which is exactly how the existing tests missed
|
||||
// the missing hand-off.
|
||||
func TestRealAllocatorWorkerPublishesSignedAssignmentRoster(t *testing.T) {
|
||||
dsn := os.Getenv("COSMIC_CLASH_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("COSMIC_CLASH_POSTGRES_DSN is not set")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS schema_migrations, allocation_quotas, assignments, audit_events, outbox, result_receipts, ranked_season_rollovers, penalties, seasons, ratings, match_participants, matches, proposal_participants, proposals, allocations, game_servers, queue_tickets, idempotency_keys, sessions, identities CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Apply(ctx, db, filepath.Join("..", "migrations")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
players := []string{"roster-worker-a", "roster-worker-b"}
|
||||
for index, player := range players {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ($1, $2)`, player, "steam-"+player); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO queue_tickets (ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at) VALUES ($1, $2, 'casual', 'ACCEPTED', 'build-1', 1, $3, $4)`, fmt.Sprintf("roster-worker-ticket-%d", index), player, now, now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO matches (match_id, playlist, state, region, protocol_version) VALUES ('roster-worker-match', 'casual', 'ALLOCATING', 'EU', 1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index, player := range players {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO match_participants (match_id, player_id, ticket_id, slot, team) VALUES ('roster-worker-match', $1, $2, $3, $4)`, player, fmt.Sprintf("roster-worker-ticket-%d", index), index*3, index); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write([]byte(`{"items":[{"metadata":{"name":"roster-ready-1","labels":{"cosmic-clash.io/region":"EU","cosmic-clash.io/build":"build-1","cosmic-clash.io/protocol":"1","cosmic-clash.io/transport":"enet"}},"status":{"state":"Ready"}}]}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":{"state":"Allocated","gameServerName":"roster-ready-1","address":"127.0.0.1","ports":[{"name":"default","port":7777}]}}`))
|
||||
}))
|
||||
defer provider.Close()
|
||||
|
||||
agonesClient := agones.Client{BaseURL: provider.URL, Namespace: "games", HTTP: provider.Client()}
|
||||
ready, err := agonesClient.ListReadyServers(ctx)
|
||||
if err != nil || len(ready) != 1 {
|
||||
t.Fatalf("ready projection = %+v err=%v", ready, err)
|
||||
}
|
||||
if err := store.RegisterReadyServer(ctx, db, ready[0], now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Two keys, signing with the newer: proves the rotation set is threaded
|
||||
// through signing and the persistence boundary's re-verification.
|
||||
keys := JoinSigningKeys{
|
||||
ActiveKeyID: "key-new",
|
||||
Keys: map[string][]byte{"key-old": []byte("retired-key"), "key-new": []byte("active-key")},
|
||||
}
|
||||
worker := Worker{
|
||||
Claims: store.AllocatingMatchClaims{DB: db, Transport: "enet"},
|
||||
Service: Service{Provider: agonesClient, Durable: store.AllocationRegistry{DB: db}, Roster: store.PostgresRosterStore{DB: db}, Now: func() time.Time { return now }},
|
||||
Now: func() time.Time { return now },
|
||||
Roster: store.AssignmentRosters{DB: db},
|
||||
Keys: keys,
|
||||
}
|
||||
processed, err := worker.RunOnce(ctx)
|
||||
if err != nil || !processed {
|
||||
t.Fatalf("worker processed=%t err=%v", processed, err)
|
||||
}
|
||||
|
||||
// One assignment row per participant, which is precisely what the
|
||||
// ASSIGNMENT_READY transition and the supervisor's roster fetch require.
|
||||
var assignments int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM assignments WHERE match_id = 'roster-worker-match'`).Scan(&assignments); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assignments != len(players) {
|
||||
t.Fatalf("assignments = %d, want %d; the allocator did not publish the roster", assignments, len(players))
|
||||
}
|
||||
|
||||
// The supervisor's own read path must return a usable roster.
|
||||
roster, err := store.GetAssignmentRoster(ctx, db, "roster-worker-match", "roster-ready-1", now)
|
||||
if err != nil {
|
||||
t.Fatalf("supervisor roster fetch: %v", err)
|
||||
}
|
||||
if len(roster) != len(players) {
|
||||
t.Fatalf("supervisor roster has %d entries, want %d", len(roster), len(players))
|
||||
}
|
||||
verify := domain.VerifyJoinAuthorisationHMAC(keys.Keys)
|
||||
seenSlots := map[int]bool{}
|
||||
for _, encoded := range roster {
|
||||
var signed domain.SignedJoinAuthorisation
|
||||
if err := json.Unmarshal(encoded, &signed); err != nil {
|
||||
t.Fatalf("decode roster entry: %v", err)
|
||||
}
|
||||
if signed.Authorisation.KeyID != "key-new" {
|
||||
t.Fatalf("entry signed with %q, want the active key", signed.Authorisation.KeyID)
|
||||
}
|
||||
if !verify(domain.JoinAuthorisationBytes(signed.Authorisation), signed.Signature) {
|
||||
t.Fatalf("roster entry for %s does not verify", signed.Authorisation.PlayerID)
|
||||
}
|
||||
if signed.Authorisation.MatchID != "roster-worker-match" || signed.Authorisation.ServerID != "roster-ready-1" {
|
||||
t.Fatalf("roster entry bound to the wrong match/server: %+v", signed.Authorisation)
|
||||
}
|
||||
seenSlots[signed.Authorisation.Slot] = true
|
||||
}
|
||||
if len(seenSlots) != len(players) {
|
||||
t.Fatalf("roster slots collided: %v", seenSlots)
|
||||
}
|
||||
|
||||
// Republishing must be idempotent: a worker that crashed after binding but
|
||||
// before publishing retries this same path.
|
||||
allocation, recorded, err := store.AllocatingMatchClaims{DB: db, Transport: "enet"}.FindProviderAllocation(ctx, domain.AllocationRequest{
|
||||
AllocationID: "allocation-roster-worker-match", MatchID: "roster-worker-match",
|
||||
Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet",
|
||||
})
|
||||
if err != nil || !recorded {
|
||||
t.Fatalf("recover allocation: recorded=%t err=%v", recorded, err)
|
||||
}
|
||||
if allocation.Endpoint == "" {
|
||||
t.Fatal("the recovered allocation lost its endpoint, so a crashed worker could never republish")
|
||||
}
|
||||
if err := worker.publishAssignmentRoster(ctx, allocation); err != nil {
|
||||
t.Fatalf("republish: %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM assignments WHERE match_id = 'roster-worker-match'`).Scan(&assignments); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assignments != len(players) {
|
||||
t.Fatalf("republish duplicated assignments: %d", assignments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package allocator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
)
|
||||
|
||||
// JoinAuthorisationLifetime bounds how long an issued authorisation may be
|
||||
// replayed. It must outlive the initial-connect window (a player still loading
|
||||
// must be able to join) without leaving a usable credential lying around after
|
||||
// the match it belongs to is over.
|
||||
const JoinAuthorisationLifetime = 30 * time.Minute
|
||||
|
||||
// AssignmentRosterSource reads the authoritative participants of an allocated
|
||||
// match. It is deliberately the same query the persistence boundary
|
||||
// re-validates against, so the allocator cannot construct a roster that
|
||||
// disagrees with the durable match_participants rows.
|
||||
type AssignmentRosterSource interface {
|
||||
LoadAssignmentParticipants(context.Context, domain.Allocation) ([]domain.AssignmentParticipant, error)
|
||||
}
|
||||
|
||||
// JoinSigningKeys is the allocator's key material. ActiveKeyID names the key
|
||||
// new authorisations are signed with; Keys holds every currently-valid key so
|
||||
// verification (including the re-check at the persistence boundary) still
|
||||
// accepts authorisations issued before a rotation.
|
||||
type JoinSigningKeys struct {
|
||||
ActiveKeyID string
|
||||
Keys map[string][]byte
|
||||
}
|
||||
|
||||
func (k JoinSigningKeys) validate() error {
|
||||
if k.ActiveKeyID == "" || len(k.Keys) == 0 {
|
||||
return fmt.Errorf("join signing keys are not configured")
|
||||
}
|
||||
if len(k.Keys[k.ActiveKeyID]) == 0 {
|
||||
return fmt.Errorf("active join signing key %q is not present in the key set", k.ActiveKeyID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildSignedRoster turns the durable participants into one signed join
|
||||
// authorisation each, plus the manifest that commits to the whole set.
|
||||
//
|
||||
// Signing each entry proves each individual claim; the manifest's roster
|
||||
// digest additionally commits to the set, so a server cannot be handed a
|
||||
// truncated roster whose surviving entries are each individually valid.
|
||||
func BuildSignedRoster(allocation domain.Allocation, participants []domain.AssignmentParticipant, keys JoinSigningKeys, now time.Time) (domain.Assignment, []domain.SignedJoinAuthorisation, error) {
|
||||
if err := keys.validate(); err != nil {
|
||||
return domain.Assignment{}, nil, err
|
||||
}
|
||||
if allocation.State != domain.ServerAllocated || allocation.Endpoint == "" || len(participants) == 0 || now.IsZero() {
|
||||
return domain.Assignment{}, nil, domain.ErrManifestRejected
|
||||
}
|
||||
active := keys.Keys[keys.ActiveKeyID]
|
||||
roster := make([]domain.SignedJoinAuthorisation, 0, len(participants))
|
||||
for _, participant := range participants {
|
||||
signed, err := domain.SignJoinAuthorisationHMAC(domain.JoinAuthorisation{
|
||||
MatchID: allocation.MatchID,
|
||||
ServerID: allocation.ServerID,
|
||||
PlayerID: participant.PlayerID,
|
||||
SteamID: participant.SteamID,
|
||||
Slot: participant.Slot,
|
||||
Team: participant.Team,
|
||||
Protocol: strconv.Itoa(allocation.Protocol),
|
||||
// Generation 1 is the first connection lease. Reconnects fence by
|
||||
// advancing the durable generation, not by reissuing this token.
|
||||
Generation: 1,
|
||||
ExpiresAt: now.Add(JoinAuthorisationLifetime).UTC(),
|
||||
KeyID: keys.ActiveKeyID,
|
||||
}, active)
|
||||
if err != nil {
|
||||
return domain.Assignment{}, nil, fmt.Errorf("sign join authorisation for %s: %w", participant.PlayerID, err)
|
||||
}
|
||||
roster = append(roster, signed)
|
||||
}
|
||||
rosterDigest, err := domain.AssignmentRosterDigest(roster)
|
||||
if err != nil {
|
||||
return domain.Assignment{}, nil, err
|
||||
}
|
||||
assignment := domain.Assignment{
|
||||
Allocation: allocation,
|
||||
Endpoint: allocation.Endpoint,
|
||||
Manifest: domain.AllocationManifest{
|
||||
AllocationID: allocation.AllocationID,
|
||||
MatchID: allocation.MatchID,
|
||||
ServerID: allocation.ServerID,
|
||||
Region: allocation.Region,
|
||||
Build: allocation.Build,
|
||||
Protocol: allocation.Protocol,
|
||||
Transport: allocation.Transport,
|
||||
RosterDigest: rosterDigest,
|
||||
},
|
||||
}
|
||||
return assignment, roster, nil
|
||||
}
|
||||
@@ -128,6 +128,11 @@ func (s Service) Allocate(ctx context.Context, request domain.AllocationRequest,
|
||||
}
|
||||
return agones.AllocatedServer{}, err
|
||||
}
|
||||
// The client-facing endpoint arrives on the provider result, not on the
|
||||
// allocation. Carry it onto the record so publishing the assignment roster
|
||||
// -- and recovering after a crash between allocating and publishing -- has
|
||||
// an endpoint to work from.
|
||||
result.Allocation.Endpoint = result.Endpoint
|
||||
recorded, err := s.Durable.RecordProviderAllocation(ctx, result.Allocation, now)
|
||||
if err != nil {
|
||||
if s.Metrics != nil {
|
||||
@@ -149,6 +154,7 @@ func (s Service) RecordProviderAllocation(ctx context.Context, result agones.All
|
||||
// Quota is consumed by Allocate before a fresh provider request. This
|
||||
// method only reconciles an already-issued provider result after an
|
||||
// ambiguous write, so consuming here would charge one allocation twice.
|
||||
result.Allocation.Endpoint = result.Endpoint
|
||||
allocation, err := s.Durable.RecordProviderAllocation(ctx, result.Allocation, now)
|
||||
if s.Metrics != nil {
|
||||
if err != nil {
|
||||
|
||||
@@ -25,6 +25,13 @@ type Worker struct {
|
||||
Claims MatchClaimSource
|
||||
Service Service
|
||||
Now func() time.Time
|
||||
// Roster and Keys wire the assignment hand-off. Without them the worker
|
||||
// binds an allocation and stops, nothing ever writes the assignments
|
||||
// table, and the allocated supervisor's roster fetch fails -- so every
|
||||
// real allocation dies before the game process launches. They are optional
|
||||
// only so existing allocation-only tests need no key material.
|
||||
Roster AssignmentRosterSource
|
||||
Keys JoinSigningKeys
|
||||
}
|
||||
|
||||
// RunOnce returns whether it found a claimed match. It never exposes an
|
||||
@@ -76,9 +83,41 @@ func (w Worker) RunOnce(ctx context.Context) (bool, error) {
|
||||
if err := w.Claims.BindAllocatedMatch(ctx, allocation); err != nil {
|
||||
return true, fmt.Errorf("bind allocated match %s: %w", request.MatchID, err)
|
||||
}
|
||||
if err := w.publishAssignmentRoster(ctx, allocation); err != nil {
|
||||
return true, fmt.Errorf("publish assignment roster for match %s: %w", request.MatchID, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// publishAssignmentRoster completes the hand-off from allocation to a joinable
|
||||
// match. The supervisor fetches a non-empty roster before it launches the game
|
||||
// child, so skipping this leaves the match stuck short of ASSIGNMENT_READY
|
||||
// forever.
|
||||
//
|
||||
// It is safe to retry: SaveVerifiedAssignmentRoster upserts by (match, player)
|
||||
// and re-validates every claim against the durable participants, so a worker
|
||||
// that crashed after binding but before publishing simply republishes on the
|
||||
// next pass.
|
||||
func (w Worker) publishAssignmentRoster(ctx context.Context, allocation domain.Allocation) error {
|
||||
if w.Roster == nil {
|
||||
// Allocation-only deployments (and the allocation-focused tests) leave
|
||||
// this unset deliberately.
|
||||
return nil
|
||||
}
|
||||
if err := w.Keys.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
participants, err := w.Roster.LoadAssignmentParticipants(ctx, allocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assignment, roster, err := BuildSignedRoster(allocation, participants, w.Keys, w.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Service.PublishRoster(ctx, assignment, roster, domain.VerifyJoinAuthorisationHMAC(w.Keys.Keys))
|
||||
}
|
||||
|
||||
func validateProviderAllocation(request domain.AllocationRequest, result agones.AllocatedServer) error {
|
||||
allocation := result.Allocation
|
||||
if result.Endpoint == "" || allocation.State != domain.ServerAllocated || allocation.AllocationID != request.AllocationID || allocation.MatchID != request.MatchID || allocation.ServerID == "" || allocation.Region != request.Region || allocation.Build != request.Build || allocation.Protocol != request.Protocol || allocation.Transport != request.Transport || allocation.ArenaPath != request.ArenaPath {
|
||||
|
||||
Reference in New Issue
Block a user