mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): propagate allocated launch configuration
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}]}}`))
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user