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
+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