mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat(multiplayer): report process-ready to the control plane from the supervisor
Add opt-in control-plane registration to server/supervisor: once Agones
Ready succeeds, POST /v1/servers/{id}/register (assignment_ready=false)
using a workload token read fresh from disk each call -- matching how a
Kubernetes projected service account token is rotated in place by
kubelet, unlike a cached/env-var secret. ControlPlaneURL empty (the
default) is a total no-op, so direct/Compose mode and allocated-without-
control-plane mode are both byte-for-byte unaffected; New() rejects a
half-configured registration (URL set without token path/server/match/
digest) rather than silently skipping it.
ServerID/MatchID/ImageDigest are read from env vars named by CLI flags
(--server-id-env, --match-id-env, --image-digest-env), matching the
existing --drain-token-env convention in this same binary, rather than
parsed out of the Agones SDK's own GameServer JSON -- that shape isn't
independently verifiable from here, whereas the Kubernetes Downward API
(fieldRef: metadata.name) populating an env var is a standard, safe
pattern already used elsewhere in this codebase for exactly this class
of secret.
A registration failure now kills the child (matching the existing
waitReady failure path) rather than leaving Agones-Ready-but-
control-plane-unregistered process running -- a real gap the second new
test (TestControlPlaneRegistrationFailureKillsChildRatherThanRunningUnregistered)
had to be corrected to actually exercise: its first draft omitted
ReadyURL and was failing at waitReady, before ever reaching the code
path it claimed to test.
This commit is contained in:
@@ -2,6 +2,7 @@ package supervisor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -66,6 +67,112 @@ func TestAllocatedStartInjectsDynamicEndpointAndCallsReadyAfterProbe(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlPlaneRegistrationRejectsIncompleteConfig(t *testing.T) {
|
||||
base := Config{Command: []string{"/bin/true"}, ControlPlaneURL: "https://control-plane.invalid"}
|
||||
if _, err := New(base); err == nil {
|
||||
t.Fatal("registration enabled with no token path/server/match/digest was accepted")
|
||||
}
|
||||
complete := base
|
||||
complete.WorkloadTokenPath, complete.ServerID, complete.MatchID, complete.ProtocolVersion, complete.ImageDigest = "/tmp/token", "server-1", "match-1", 1, "sha256:aa"
|
||||
if _, err := New(complete); err != nil {
|
||||
t.Fatalf("fully configured registration rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlPlaneRegistrationReportsProcessReadyWithWorkloadToken(t *testing.T) {
|
||||
var gotAuth, gotIdempotency, gotBody string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/gameserver":
|
||||
_, _ = w.Write([]byte(`{"status":{"address":"203.0.113.9","ports":[{"name":"game","port":31001}]}}`))
|
||||
case r.URL.Path == "/ready-probe":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.URL.Path == "/ready":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.URL.Path == "/v1/servers/server-1/register":
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotIdempotency = r.Header.Get("Idempotency-Key")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(body)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tokenPath := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(tokenPath, []byte(" workload-jwt-abc123 \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := New(Config{
|
||||
Command: []string{"/bin/sh", "-c", "true"}, SDKBaseURL: server.URL, ReadyURL: server.URL + "/ready-probe", ReadyTimeout: time.Second, PollInterval: time.Millisecond,
|
||||
ControlPlaneURL: server.URL, WorkloadTokenPath: tokenPath, ServerID: "server-1", MatchID: "match-1", ProtocolVersion: 1, ImageDigest: "sha256:aa",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = s.Wait()
|
||||
if gotAuth != "Bearer workload-jwt-abc123" {
|
||||
t.Fatalf("Authorization header = %q, want the trimmed token file contents", gotAuth)
|
||||
}
|
||||
if len(gotIdempotency) < 16 {
|
||||
t.Fatalf("Idempotency-Key = %q, too short", gotIdempotency)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"match_id":"match-1"`) || !strings.Contains(gotBody, `"assignment_ready":false`) || !strings.Contains(gotBody, `"image_digest":"sha256:aa"`) {
|
||||
t.Fatalf("register body = %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlPlaneRegistrationFailureKillsChildRatherThanRunningUnregistered(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/gameserver":
|
||||
_, _ = w.Write([]byte(`{"status":{"address":"203.0.113.9","ports":[{"name":"game","port":31001}]}}`))
|
||||
case "/ready-probe":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/ready":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/v1/servers/server-1/register":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tokenPath := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(tokenPath, []byte("workload-jwt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A long-running child: if Start's failure path did not actually kill it,
|
||||
// Wait would block for the full sleep instead of returning promptly with
|
||||
// a "signal: killed" style exit.
|
||||
s, err := New(Config{
|
||||
Command: []string{"/bin/sh", "-c", "sleep 30"}, SDKBaseURL: server.URL, ReadyURL: server.URL + "/ready-probe", ReadyTimeout: time.Second, PollInterval: time.Millisecond,
|
||||
ControlPlaneURL: server.URL, WorkloadTokenPath: tokenPath, ServerID: "server-1", MatchID: "match-1", ProtocolVersion: 1, ImageDigest: "sha256:aa",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Start(context.Background()); err == nil {
|
||||
t.Fatal("Start succeeded despite the control-plane rejecting registration")
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("child was not actually killed after a failed registration")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("child was still running 5s after a failed registration should have killed it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocatedENetDoesNotReceiveSDRVariables(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/gameserver" {
|
||||
|
||||
Reference in New Issue
Block a user