From 315c524c42ed8d529f2707f96b06f11e180b2705 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:57:27 +0100 Subject: [PATCH] feat(multiplayer): propagate allocated launch configuration --- multiplayer-next.md | 2 + server/agones/allocation.go | 4 ++ server/agones/allocation_test.go | 6 +++ server/supervisor/supervisor.go | 56 +++++++++++++++++++++++++++- server/supervisor/supervisor_test.go | 30 +++++++++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/multiplayer-next.md b/multiplayer-next.md index d5be4e44..3ab79fac 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1434,3 +1434,5 @@ An adversarial UI review found the lobby’s generic disconnect handler still re The workload-authenticated `POST /servers/{serverId}/shutdown` contract is now exposed for allocated servers. It validates the bound credential and reason, records an idempotent `SERVER_SHUTDOWN` audit event under a serializable transaction, and returns a stable acknowledgment on retry; match-state transitions remain owned by the no-show/result transactions. API/store tests cover authorization, validation, idempotency SQL, and audit wiring; live PostgreSQL delivery remains an integration gate. The allocated supervisor now calls that shutdown acknowledgment during signal-bound controlled drain, using the same workload credential and a deterministic idempotency key after the local drain request succeeds. The lifecycle test verifies the drain-before-ack ordering, credential separation, and bounded graceful child exit; live pod termination and control-plane outage behavior remain deployment gates. + +Allocator-selected region, build, protocol, and transport now travel with the allocation as Agones annotations and override stale child launch flags immediately before an allocated process starts. The overlay rejects control characters and preserves direct-server command behavior; focused supervisor/allocator tests cover precedence and annotation payloads, while live Agones passthrough remains an infrastructure gate. diff --git a/server/agones/allocation.go b/server/agones/allocation.go index 1cd47c6b..8f567771 100644 --- a/server/agones/allocation.go +++ b/server/agones/allocation.go @@ -172,6 +172,10 @@ func (c Client) Allocate(ctx context.Context, request domain.AllocationRequest, body.Spec.Metadata.Annotations = map[string]string{ "cosmic-clash.io/match-id": request.MatchID, "cosmic-clash.io/allocation-id": request.AllocationID, + "cosmic-clash.io/region": request.Region, + "cosmic-clash.io/build": request.Build, + "cosmic-clash.io/protocol": strconv.Itoa(request.Protocol), + "cosmic-clash.io/transport": request.Transport, } if len(c.WorkloadSecret) > 0 { ttl := c.WorkloadTokenTTL diff --git a/server/agones/allocation_test.go b/server/agones/allocation_test.go index c8d83dc7..f5584d28 100644 --- a/server/agones/allocation_test.go +++ b/server/agones/allocation_test.go @@ -32,6 +32,12 @@ func TestAllocateBuildsStrictGameServerAllocationAndEndpoint(t *testing.T) { if body.Spec.Metadata.Annotations["cosmic-clash.io/match-id"] != "match-1" || body.Spec.Metadata.Annotations["cosmic-clash.io/allocation-id"] != "allocation-1" { t.Fatalf("allocation did not request match/allocation ID annotations on the GameServer: %+v", body.Spec.Metadata.Annotations) } + want := map[string]string{"cosmic-clash.io/region": "EU", "cosmic-clash.io/build": "build-1", "cosmic-clash.io/protocol": "1", "cosmic-clash.io/transport": "enet"} + for key, value := range want { + if body.Spec.Metadata.Annotations[key] != value { + t.Fatalf("annotation %s = %q, want %q", key, body.Spec.Metadata.Annotations[key], value) + } + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":{"state":"Allocated","gameServerName":"gs-a","address":"2001:db8::1","ports":[{"name":"default","port":7777}]}}`)) })) diff --git a/server/supervisor/supervisor.go b/server/supervisor/supervisor.go index d41026ba..7e7657fb 100644 --- a/server/supervisor/supervisor.go +++ b/server/supervisor/supervisor.go @@ -23,7 +23,8 @@ import ( type GameServer struct { // ObjectMeta.Annotations carries per-allocation data the agones package // requests on the GameServerAllocation (server/agones/allocation.go) -- - // currently cosmic-clash.io/match-id and cosmic-clash.io/allocation-id. + // currently cosmic-clash.io/match-id, cosmic-clash.io/allocation-id, and + // allocator-selected compatibility fields. // This is the only channel for match-specific config to reach an // already-Ready pod: env vars are fixed at pod creation, before Agones // assigns a match to it. NOTE: the exact JSON key for this field @@ -187,6 +188,10 @@ func (s *Supervisor) Start(ctx context.Context) error { return err } command := withAllocatedConfig(s.config.Command, s.matchID(), s.config.ServerID, s.config.ImageDigest, rosterExpiry) + command, err = withAllocatedCompatibility(command, s.lastGameServer) + if err != nil { + return err + } command = withPort(command, port) s.cmd = exec.CommandContext(ctx, command[0], command[1:]...) } else { @@ -328,6 +333,55 @@ func withAllocatedConfig(command []string, matchID, serverID, imageDigest string return result } +// withAllocatedCompatibility overlays fields selected by the allocator onto +// child flags. These values arrive through Agones allocation annotations after +// the pod was created, so static Fleet defaults must never win over them. +func withAllocatedCompatibility(command []string, gameServer GameServer) ([]string, error) { + values := map[string]string{} + annotations := gameServer.ObjectMeta.Annotations + if annotations == nil { + return command, nil + } + for annotation, flag := range map[string]string{ + "cosmic-clash.io/region": "region", + "cosmic-clash.io/build": "client-build", + "cosmic-clash.io/protocol": "protocol-version", + "cosmic-clash.io/transport": "transport", + } { + value := annotations[annotation] + if value == "" { + continue + } + if strings.ContainsAny(value, "\r\n\t") { + return nil, fmt.Errorf("allocated annotation %q contains control characters", annotation) + } + values[flag] = value + } + return withAllocatedValues(command, values), nil +} + +func withAllocatedValues(command []string, values map[string]string) []string { + result := append([]string(nil), command...) + for key, value := range values { + if value == "" { + continue + } + prefix := "--" + key + "=" + replaced := false + for i, arg := range result { + if strings.HasPrefix(arg, prefix) { + result[i] = prefix + value + replaced = true + break + } + } + if !replaced { + result = append(result, prefix+value) + } + } + return result +} + // reportAssignmentReady is best-effort: process-ready has already succeeded, // so the process is legitimately usable either way. A persistent failure is // written to stderr rather than returned, since treating it as fatal would diff --git a/server/supervisor/supervisor_test.go b/server/supervisor/supervisor_test.go index 43ef921d..fa849ad2 100644 --- a/server/supervisor/supervisor_test.go +++ b/server/supervisor/supervisor_test.go @@ -34,6 +34,36 @@ func TestWithAllocatedConfigOverridesAuthoritativeChildFlags(t *testing.T) { } } +func TestWithAllocatedCompatibilityOverridesStaleFlagsAndRejectsUnsafeValues(t *testing.T) { + command := []string{"game-server", "--region=EU", "--client-build=stale", "--protocol-version=1", "--transport=enet", "--custom=keep"} + gameServer := GameServer{} + gameServer.ObjectMeta.Annotations = map[string]string{ + "cosmic-clash.io/region": "NA", "cosmic-clash.io/build": "build-live", + "cosmic-clash.io/protocol": "12", "cosmic-clash.io/transport": "steam_sdr", + } + got, err := withAllocatedCompatibility(command, gameServer) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"--region=NA", "--client-build=build-live", "--protocol-version=12", "--transport=steam_sdr", "--custom=keep"} { + found := false + for _, arg := range got { + if arg == want { + found = true + break + } + } + if !found { + t.Fatalf("dynamic flag %q missing from %#v", want, got) + } + } + unsafe := gameServer + unsafe.ObjectMeta.Annotations = map[string]string{"cosmic-clash.io/region": "NA\nforged"} + if _, err := withAllocatedCompatibility(command, unsafe); err == nil { + t.Fatal("unsafe annotation did not fail closed") + } +} + func TestAllocatedStartInjectsDynamicEndpointAndCallsReadyAfterProbe(t *testing.T) { ready := false readyCalled := false