feat(multiplayer): propagate allocated launch configuration

This commit is contained in:
Josh Creek
2026-09-01 16:57:27 +01:00
parent 75cb8faac4
commit 315c524c42
5 changed files with 97 additions and 1 deletions
+2
View File
@@ -1434,3 +1434,5 @@ An adversarial UI review found the lobbys 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.
+4
View File
@@ -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
+6
View File
@@ -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}]}}`))
}))
+55 -1
View File
@@ -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
+30
View File
@@ -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