diff --git a/deploy/k8s/base/control-plane-service.yaml b/deploy/k8s/base/control-plane-service.yaml new file mode 100644 index 00000000..35cef6cc --- /dev/null +++ b/deploy/k8s/base/control-plane-service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: control-plane + namespace: cosmic-clash + labels: + app.kubernetes.io/name: control-plane +spec: + selector: + app.kubernetes.io/name: control-plane + ports: + - name: http + port: 8080 + targetPort: http diff --git a/deploy/k8s/base/fleet.yaml b/deploy/k8s/base/fleet.yaml index 1204fef4..763647b3 100644 --- a/deploy/k8s/base/fleet.yaml +++ b/deploy/k8s/base/fleet.yaml @@ -17,6 +17,11 @@ spec: cosmic-clash.io/build: build-1 cosmic-clash.io/protocol: "1" cosmic-clash.io/transport: enet + annotations: + # The release process replaces this with the immutable image digest; + # the Downward API passes the same value to the supervisor so the + # allocated child can validate its assignment manifest. + cosmic-clash.io/image-digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 spec: ports: - name: game @@ -49,7 +54,49 @@ spec: containers: - name: game-server image: ghcr.io/cosmic-clash/game-server@sha256:0000000000000000000000000000000000000000000000000000000000000000 - args: ["--port=7777"] + args: + - --sdk-base-url=http://127.0.0.1:9357 + - --ready-url=http://127.0.0.1:7780/ready + - --drain-url=http://127.0.0.1:7780/drain + - --drain-token-env=COSMIC_CLASH_DRAIN_TOKEN + - --control-plane-url=http://control-plane.cosmic-clash.svc.cluster.local:8080 + - --server-id-env=COSMIC_CLASH_SERVER_ID + - --image-digest-env=COSMIC_CLASH_IMAGE_DIGEST + - --roster-path=/run/cosmic-clash/join-roster.json + - --transport=enet + - -- + - --allocated-mode + - --match-id=allocation-placeholder + - --server-id=allocation-placeholder + - --playlist-version=casual + - --client-build=build-1 + - --assignment-expiry-unix=1 + - --server-image-digest=sha256:0000000000000000000000000000000000000000000000000000000000000000 + - --transport=enet + - --region=EU + - --join-authorisations-file=/run/cosmic-clash/join-roster.json + - --join-authorisations-key-file=/run/secrets/cosmic-clash/join-signing-key + - --readiness-port=7780 + env: + - name: COSMIC_CLASH_SERVER_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: COSMIC_CLASH_IMAGE_DIGEST + valueFrom: + fieldRef: + fieldPath: metadata.annotations['cosmic-clash.io/image-digest'] + - name: COSMIC_CLASH_DRAIN_TOKEN + valueFrom: + secretKeyRef: + name: cosmic-clash-game-server + key: drain-token + volumeMounts: + - name: allocated-roster + mountPath: /run/cosmic-clash + - name: join-signing-key + mountPath: /run/secrets/cosmic-clash + readOnly: true securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true @@ -62,3 +109,12 @@ spec: limits: cpu: 1 memory: 512Mi + volumes: + - name: allocated-roster + emptyDir: {} + - name: join-signing-key + secret: + secretName: cosmic-clash-game-server + items: + - key: join-signing-key + path: join-signing-key diff --git a/deploy/k8s/base/kustomization.yaml b/deploy/k8s/base/kustomization.yaml index 040645df..6069643f 100644 --- a/deploy/k8s/base/kustomization.yaml +++ b/deploy/k8s/base/kustomization.yaml @@ -6,6 +6,7 @@ resources: - rbac.yaml - network-policies.yaml - control-plane-deployment.yaml + - control-plane-service.yaml - fleet.yaml - fleet-autoscaler.yaml - game-server-pdb.yaml diff --git a/deploy/k8s/base/network-policies.yaml b/deploy/k8s/base/network-policies.yaml index f69148cd..1e9e39e2 100644 --- a/deploy/k8s/base/network-policies.yaml +++ b/deploy/k8s/base/network-policies.yaml @@ -66,3 +66,34 @@ spec: podSelector: matchLabels: k8s-app: kube-dns +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: game-server-allowed-egress + namespace: cosmic-clash +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: game-server + policyTypes: [Egress] + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: control-plane + ports: + - protocol: TCP + port: 8080 + - ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns diff --git a/multiplayer-next.md b/multiplayer-next.md index 9542ffb1..8a7bdfca 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1215,7 +1215,7 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| -| 8.26 `[D:8.1,8.6,8.12]` | **IN PROGRESS.** Provider-neutral Kustomize base now defines a restricted Agones Fleet with region/build/protocol/transport labels and UDP game port, plus distinct EU/NA overlays; the base avoids rewriting cross-namespace Agones RBAC | `deploy/k8s/base/fleet.yaml`, `overlays/eu`, `overlays/na` and `server/security/test_fleet_manifests.py` cover labels, replica floor, UDP declaration, pod hardening, overlay distinction and RBAC namespace safety; live Kustomize/Agones rendering, second-provider fixtures, edge/network/DNS/secret and SDR POP/cert/public-UDP overlays remain | +| 8.26 `[D:8.1,8.6,8.12]` | **IN PROGRESS.** Provider-neutral Kustomize base now defines a restricted Agones Fleet with region/build/protocol/transport labels and UDP game port, plus distinct EU/NA overlays; the base avoids rewriting cross-namespace Agones RBAC and now wires the digest-pinned supervisor image, control-plane Service, dynamic roster volume, signing/drain secret references and required network flow | `deploy/k8s/base/fleet.yaml`, `control-plane-service.yaml`, `network-policies.yaml`, `overlays/eu`, `overlays/na` and `server/security/test_fleet_manifests.py` cover labels, replica floor, UDP declaration, pod hardening, supervisor/runtime arguments, Service selection, egress policy, overlay distinction, Kustomize rendering and RBAC namespace safety; operator secret/image replacement, second-provider fixtures, edge/DNS and SDR POP/cert/public-UDP overlays remain | | 8.27 `[D:8.26]` | **IN PROGRESS.** Go supervisor package provides local-safe Agones REST discovery, validates assigned address/port data, injects dynamic `SDR_LISTEN_PORT`/`SDR_IP`, performs explicit process-ready probing and Ready transition; direct mode bypasses Agones | `server/supervisor/` covers allocated/direct startup, invalid endpoint rejection, dynamic endpoint/Ready ordering and authenticated drain; allocated Godot now supplies a loopback readiness/drain control surface and `agones_sdk.gd` supplies sidecar Health/Ready/Shutdown/annotation REST operations; metadata watch, real Agones annotation/shutdown confirmation and emulator integration remain | | 8.28 `[D:8.6,8.27]` | **IN PROGRESS.** Supervisor separates explicit process-ready from Agones Ready and never scrapes stdout; allocated mode refuses to mark Ready without a configured readiness probe. The durable control-plane counterpart now exists: a workload-authenticated `POST /v1/servers/{id}/register` (and its `/api/v1` contract alias) advances a match's `ALLOCATING -> PROCESS_READY -> ASSIGNMENT_READY` state, and every participant's queue ticket with it, as one idempotent SERIALIZABLE transaction, gated on every participant already holding a live, unexpired assignment. The supervisor now calls it: once Agones Ready succeeds, it POSTs process-ready (`assignment_ready=false`) using a workload token read fresh from disk each call (matching kubelet's in-place rotation of a projected service-account token), and a registration failure kills the child rather than leaving an Agones-Ready-but-control-plane-unregistered process running; `ControlPlaneURL` unset (the default) is a total no-op. It then reports assignment-ready too: `server_boot.gd` already verifies its mounted roster synchronously before `/ready` is ever exposed (so process-ready implies the roster was valid), and the API's `ASSIGNMENT_READY` gate checks only durable `assignments` rows server-side — so no new Godot-side state was needed, correcting an earlier overcautious note here. The supervisor retries assignment-ready (default 5 attempts, 2s apart, configurable) since those durable rows may lag process-ready slightly; a persistent failure there does not kill the child, unlike process-ready. Per-allocation data (currently `match-id`) now has a real channel to an already-Ready pod: `server/agones.Client.Allocate` requests `cosmic-clash.io/match-id`/`cosmic-clash.io/allocation-id` as `GameServerAllocation.spec.metadata.annotations` (Agones applies these to the allocated GameServer's own `object_meta` — the only channel that exists post-allocation, since env vars are fixed at pod creation), and the supervisor reads them back from its existing `/gameserver` SDK call, falling back to them only when `MatchID` isn't explicitly configured. The image now exists: a new `Dockerfile` `game-server` target packages the supervisor as ENTRYPOINT alongside the same dedicated-server export `server` produces | `server/supervisor/` tests prove Ready follows the probe and direct mode remains functional; `server_control.gd`, `agones_sdk.gd` and process-level smokes prove loopback `/ready`, `/health`, bearer-protected `/drain`, sidecar-shaped Health/Ready calls and drain admission fencing; `server/api/service.go`, `server/store/allocation_match_sql.go` and adversarial tests cover the registration route, digest/protocol validation, idempotent replay/conflict and the assignment-count gate; `server/supervisor/supervisor_test.go` covers opt-in registration, the workload-token/body/idempotency-key shape, the kill-on-failure path, the match-ID annotation fallback (both that it's used and that its absence fails closed before any HTTP call), the full process-ready-then-assignment-ready sequence and body shapes, and a retry test that fails assignment-ready twice with 409 before succeeding, asserting `Start()` still succeeds and the child is never killed; `server/agones/allocation_test.go` covers the requested annotations. `docker build --target game-server` verified for real: both binaries present, correct permissions, supervisor prints its usage; `server/store/stalled_allocation_sql.go`/`_test.go` and a live `TestPostgreSQLStalledAllocationsAreReclaimedWithoutPenalisingPlayers` cover the deadline boundary (a recent match must survive untouched), the no-penalty requeue and refreshed expiry, participant release, and idempotence against a second pass. **Fixed in passing**: `Dockerfile`'s `server` stage's `ubuntu` base digest had gone dead on Docker Hub (`docker pull` returned "not found", verified independently) — `make verify-phase6` was silently broken for a clean build before the re-pin; confirmed fixed with a full `make verify-phase6` run (arenas rotated, both goals observed, clean teardown). **Still not wired into `deploy/k8s/base/fleet.yaml`**: the manifest doesn't reference the `game-server` image or invoke any supervisor flags yet — the concrete remaining step is deciding and adding the per-deployment values (`--control-plane-url`, `--workload-token-path` plus the projected token volume, `--server-id-env`/`--image-digest-env` Downward API wiring), deliberately not guessed at here since they're environment-specific. `deploy/cosmic-clash-server` now wraps its exec in `stdbuf -oL -eL` (falling back to unwrapped if unavailable), fixing a real, live-confirmed bug: a genuinely detached (`docker run -d`) container showed zero `docker logs` output — not even the startup line — for 20+ seconds while the process ran normally, and `docker stop`'s SIGTERM lost that buffered output permanently rather than delaying it; re-verified fixed against the real launcher script, then a full `make verify-phase6` re-run confirmed no regression. Health-reclaim now exists: `store.ExpireStalledAllocations` reclaims a match stuck in `ALLOCATING`/`PROCESS_READY`/`ASSIGNMENT_READY` past a deadline (server crashed, or was reclaimed by Agones as unhealthy, before ever registering) by failing the match and requeuing every participant to `QUEUED` with a fresh expiry rather than penalising them — task 8.50's own "infrastructure-caused cases cannot penalise affected players" criterion directly settles the requeue-vs-fail design question this had been blocked on. Wired into `cmd/maintenance` alongside the season-rollover sweep (`--stalled-allocation-deadline` default 2m, `--stalled-allocation-batch`). **Superseding the `fleet.yaml` framing above**: §8.10's `WorkloadVerify` blocker, and its delivery channel, are both now closed — a control-plane-self-issued signed token (not a Kubernetes JWT), minted by `cmd/allocator` into a `cosmic-clash.io/workload-token` annotation and read back by the supervisor, exactly the way `match-id` already worked — see §8.10. `/register` and `/result` no longer 503 unconditionally once every `--workload-secret` (control plane, allocator) is set consistently. What remains for `fleet.yaml` is now purely the manifest itself: it doesn't yet reference the `game-server` image or invoke any supervisor flags (`--control-plane-url`, `--server-id-env`/`--image-digest-env` Downward API wiring — `--workload-token-path` is no longer required, since the annotation fallback covers it) — deliberately not guessed at here since these are environment-specific values, and this whole path has only run against HTTP-level Agones fakes, never a real cluster (see §8.10's "what's still missing") | | 8.29 `[D:8.26,8.27]` | **IN PROGRESS.** Supervisor discovers and validates the Agones endpoint, propagates the actual dynamic `--port`, and exports `SDR_LISTEN_PORT`/`SDR_IP` only for Hosted-SDR while preserving an isolated ENet path | `server/supervisor/` tests cover invalid address/port rejection, dynamic port argument/env propagation and SDR-vs-ENet separation; real Agones dynamic/passthrough mapping, POP/cert/firewall/NAT and multi-match fixture remain | @@ -1391,3 +1391,6 @@ Bounded, but not by much: the attacker must race a genuine disconnect, and they **Split-screen.** Tracked separately in `TODO.md`; unrelated to this effort, though the camera-outside-the-ship structure that enables it is the same structure this plan relies on. **A second, distinct source of the same "Unable to send packet on channel N, max channels: 0" stderr noise — item E of §0, in `networked_match.gd`'s `_broadcast_snapshot` rather than `match_net.gd`'s `_remove_player`.** Only reproduced via the deliberately-adversarial `client-abuse-malformed` smoke role: `_broadcast_snapshot`'s per-peer send races `match_sim.gd`'s host-forced `disconnect_peer()` (the abuse-disconnect path) against the same tick's `connected_peers.has(slot.peer_id)` snapshot, the same general shape of race as the fixed site but on a different call path (a server-initiated forced disconnect, not a normal client-initiated one) and not currently known to be reachable from ordinary play. Left for a dedicated pass — not fixed under this round's time pressure, since the fixed site (gotcha 46's neighbor, the round-2 addendum above) was the one an adversarial review actually flagged as a "clean stderr" violation in the tests this project's own conventions rely on. +#### Deployment wiring update (2026-09-01) + +The current working implementation now wires `deploy/k8s/base/fleet.yaml` to the digest-pinned `game-server` supervisor target, the in-cluster control-plane Service, workload roster materialization, signing/drain secret references, downward-API server/image identity, and the required game-server egress policy. `kubectl kustomize deploy/k8s/base` and `server/security/test_fleet_manifests.py` pass. The older 8.28 narrative above still records the pre-wiring state; live Agones, operator secret/image replacement, and real cluster readiness remain explicit gates. diff --git a/server/api/service_test.go b/server/api/service_test.go index 51595223..c5e6a045 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -343,7 +343,7 @@ func TestServerRosterRequiresWorkloadBindingAndReturnsRawSignedEnvelopes(t *test if binding.ServerID != "server-1" || binding.MatchID != "match-1" || !at.Equal(now) { t.Fatal("unexpected roster binding") } - return [][]byte{[]byte(`{"authorisation":{"player_id":"player-1"},"signature":"sig"}`)}, nil + return [][]byte{[]byte(`{"authorisation":{"player_id":"player-1","expires_at":"1970-01-01T00:33:20Z"},"signature":"sig"}`)}, nil }, } server := httptest.NewServer(service.Handler()) diff --git a/server/security/test_fleet_manifests.py b/server/security/test_fleet_manifests.py index fd240c19..c2630e84 100644 --- a/server/security/test_fleet_manifests.py +++ b/server/security/test_fleet_manifests.py @@ -19,6 +19,18 @@ class FleetManifestTest(unittest.TestCase): self.assertIn(label, fleet) for hardening in ("runAsNonRoot: true", "automountServiceAccountToken: false", "readOnlyRootFilesystem: true", "allowPrivilegeEscalation: false"): self.assertIn(hardening, fleet) + for runtime in ( + "ghcr.io/cosmic-clash/game-server@sha256:", + "--sdk-base-url=http://127.0.0.1:9357", + "--control-plane-url=http://control-plane.cosmic-clash.svc.cluster.local:8080", + "--roster-path=/run/cosmic-clash/join-roster.json", + "--allocated-mode", + "--join-authorisations-key-file=/run/secrets/cosmic-clash/join-signing-key", + "fieldPath: metadata.annotations['cosmic-clash.io/image-digest']", + "secretName: cosmic-clash-game-server", + "emptyDir: {}", + ): + self.assertIn(runtime, fleet) for scheduling in ( "cosmic-clash.io/capacity-type: on-demand", "topologyKey: topology.kubernetes.io/zone", @@ -60,6 +72,16 @@ class FleetManifestTest(unittest.TestCase): self.assertNotIn("namespace: cosmic-clash", base) self.assertIn("namespace: agones-system", rbac) + def test_control_plane_service_and_game_server_egress_are_declared(self): + service = self.read("base/control-plane-service.yaml") + network = self.read("base/network-policies.yaml") + base = self.read("base/kustomization.yaml") + for field in ("kind: Service", "name: control-plane", "port: 8080", "targetPort: http"): + self.assertIn(field, service) + for field in ("name: game-server-allowed-egress", "app.kubernetes.io/name: game-server", "port: 8080"): + self.assertIn(field, network) + self.assertIn("control-plane-service.yaml", base) + if __name__ == "__main__": unittest.main() diff --git a/server/supervisor/supervisor.go b/server/supervisor/supervisor.go index 7ffb7d49..49b72934 100644 --- a/server/supervisor/supervisor.go +++ b/server/supervisor/supervisor.go @@ -182,10 +182,12 @@ func (s *Supervisor) Start(ctx context.Context) error { if s.config.Transport == "steam_sdr" { env = append(env, "SDR_LISTEN_PORT="+strconv.Itoa(port), "SDR_IP="+address+":"+strconv.Itoa(port)) } - if err := s.fetchRoster(ctx); err != nil { + rosterExpiry, err := s.fetchRoster(ctx) + if err != nil { return err } - command := withPort(s.config.Command, port) + command := withAllocatedConfig(s.config.Command, s.matchID(), s.config.ServerID, s.config.ImageDigest, rosterExpiry) + command = withPort(command, port) s.cmd = exec.CommandContext(ctx, command[0], command[1:]...) } else { s.cmd = exec.CommandContext(ctx, s.config.Command[0], s.config.Command[1:]...) @@ -218,68 +220,112 @@ func (s *Supervisor) Start(ctx context.Context) error { return nil } -func (s *Supervisor) fetchRoster(ctx context.Context) error { +func (s *Supervisor) fetchRoster(ctx context.Context) (time.Time, error) { if s.config.RosterPath == "" { - return nil + return time.Time{}, nil } matchID := s.matchID() if matchID == "" { - return fmt.Errorf("roster fetch has no match ID") + return time.Time{}, fmt.Errorf("roster fetch has no match ID") } token, err := s.workloadToken() if err != nil { - return err + return time.Time{}, err } rosterURL := strings.TrimRight(s.config.ControlPlaneURL, "/") + "/v1/servers/" + url.PathEscape(s.config.ServerID) + "/roster" request, err := http.NewRequestWithContext(ctx, http.MethodGet, rosterURL, nil) if err != nil { - return err + return time.Time{}, err } request.Header.Set("Authorization", "Bearer "+token) response, err := s.client.Do(request) if err != nil { - return err + return time.Time{}, err } defer response.Body.Close() if response.StatusCode/100 != 2 { - return fmt.Errorf("control-plane roster returned %s", response.Status) + return time.Time{}, fmt.Errorf("control-plane roster returned %s", response.Status) } var roster []json.RawMessage if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&roster); err != nil || len(roster) == 0 { if err == nil { err = fmt.Errorf("empty roster") } - return fmt.Errorf("decode control-plane roster: %w", err) + return time.Time{}, fmt.Errorf("decode control-plane roster: %w", err) } + var expiry time.Time for _, envelope := range roster { if len(envelope) == 0 || string(envelope) == "null" { - return fmt.Errorf("control-plane roster contains an invalid envelope") + return time.Time{}, fmt.Errorf("control-plane roster contains an invalid envelope") + } + var decoded struct { + Authorisation struct { + ExpiresAt time.Time `json:"expires_at"` + } `json:"authorisation"` + } + if err := json.Unmarshal(envelope, &decoded); err != nil || decoded.Authorisation.ExpiresAt.IsZero() { + return time.Time{}, fmt.Errorf("control-plane roster contains an envelope without expiry") + } + if expiry.IsZero() || decoded.Authorisation.ExpiresAt.Before(expiry) { + expiry = decoded.Authorisation.ExpiresAt } } contents, err := json.Marshal(roster) if err != nil { - return fmt.Errorf("encode roster: %w", err) + return time.Time{}, fmt.Errorf("encode roster: %w", err) } directory := filepath.Dir(s.config.RosterPath) temporary, err := os.CreateTemp(directory, ".cosmic-clash-roster-*") if err != nil { - return fmt.Errorf("create roster file: %w", err) + return time.Time{}, fmt.Errorf("create roster file: %w", err) } temporaryName := temporary.Name() defer os.Remove(temporaryName) - if err := temporary.Chmod(0600); err == nil { - _, err = temporary.Write(contents) + if err := temporary.Chmod(0600); err != nil { + _ = temporary.Close() + return time.Time{}, fmt.Errorf("secure roster file: %w", err) } + _, err = temporary.Write(contents) if closeErr := temporary.Close(); err == nil { err = closeErr } if err != nil { - return fmt.Errorf("write roster file: %w", err) + return time.Time{}, fmt.Errorf("write roster file: %w", err) } if err := os.Rename(temporaryName, s.config.RosterPath); err != nil { - return fmt.Errorf("install roster file: %w", err) + return time.Time{}, fmt.Errorf("install roster file: %w", err) } - return nil + return expiry, nil +} + +func withAllocatedConfig(command []string, matchID, serverID, imageDigest string, rosterExpiry time.Time) []string { + result := append([]string(nil), command...) + values := map[string]string{ + "match-id": matchID, + "server-id": serverID, + "server-image-digest": imageDigest, + } + if !rosterExpiry.IsZero() { + values["assignment-expiry-unix"] = strconv.FormatInt(rosterExpiry.Unix(), 10) + } + 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, diff --git a/server/supervisor/supervisor_integration_test.go b/server/supervisor/supervisor_integration_test.go index 55f72211..69bd4ebb 100644 --- a/server/supervisor/supervisor_integration_test.go +++ b/server/supervisor/supervisor_integration_test.go @@ -80,7 +80,7 @@ func TestRealSupervisorRegistersAllocatedServerThroughControlPlane(t *testing.T) if err := store.SaveAssignment(ctx, db, store.DurableAssignment{ MatchID: "supervisor-live-match", PlayerID: player, AllocationID: request.AllocationID, ServerID: "supervisor-live-server", Slot: index, Region: "EU", ClientBuild: "build-1", ProtocolVersion: 1, Transport: "enet", Endpoint: "127.0.0.1:7777", - JoinAuthorisation: base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"authorisation":{"match_id":"supervisor-live-match","server_id":"supervisor-live-server","player_id":%q},"signature":"sig"}`, player))), ManifestDigest: []byte{0, 1, 2, 3}, ExpiresAt: now.Add(time.Hour), Revision: 1, + JoinAuthorisation: base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"authorisation":{"match_id":"supervisor-live-match","server_id":"supervisor-live-server","player_id":%q,"expires_at":%q},"signature":"sig"}`, player, now.Add(time.Hour).Format(time.RFC3339)))), ManifestDigest: []byte{0, 1, 2, 3}, ExpiresAt: now.Add(time.Hour), Revision: 1, }); err != nil { t.Fatal(err) } diff --git a/server/supervisor/supervisor_test.go b/server/supervisor/supervisor_test.go index acb23cd6..a7c553df 100644 --- a/server/supervisor/supervisor_test.go +++ b/server/supervisor/supervisor_test.go @@ -87,7 +87,7 @@ func TestAllocatedStartMaterializesWorkloadAuthenticatedRosterBeforeChild(t *tes w.WriteHeader(http.StatusUnauthorized) return } - _, _ = w.Write([]byte(`[{"authorisation":{"player_id":"player-1"},"signature":"sig"}]`)) + _, _ = w.Write([]byte(`[{"authorisation":{"player_id":"player-1","expires_at":"2030-01-01T00:00:00Z"},"signature":"sig"}]`)) return } if r.URL.Path == "/v1/servers/server-1/register" {