mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
0bad9e07db
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.
340 lines
12 KiB
Go
340 lines
12 KiB
Go
package supervisor
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestAllocatedStartInjectsDynamicEndpointAndCallsReadyAfterProbe(t *testing.T) {
|
|
ready := false
|
|
readyCalled := false
|
|
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":
|
|
if ready {
|
|
w.WriteHeader(http.StatusOK)
|
|
} else {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}
|
|
case "/ready":
|
|
readyCalled = true
|
|
w.WriteHeader(http.StatusOK)
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
ready = true
|
|
path := filepath.Join(t.TempDir(), "env.txt")
|
|
argsPath := filepath.Join(t.TempDir(), "args.txt")
|
|
command := []string{"/bin/sh", "-c", "env > " + path + "; printf '%s' \"$@\" > " + argsPath, "shell"}
|
|
s, err := New(Config{Command: command, SDKBaseURL: server.URL, ReadyURL: server.URL + "/ready-probe", Transport: "steam_sdr", ReadyTimeout: time.Second, PollInterval: time.Millisecond})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Wait(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
contents, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(contents), "SDR_LISTEN_PORT=31001") || !strings.Contains(string(contents), "SDR_IP=203.0.113.9:31001") {
|
|
t.Fatalf("dynamic endpoint not injected: %s", contents)
|
|
}
|
|
args, err := os.ReadFile(argsPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(args), "--port=31001") {
|
|
t.Fatalf("dynamic port argument not injected: %s", args)
|
|
}
|
|
if !readyCalled {
|
|
t.Fatal("Agones Ready was called before process-ready probe")
|
|
}
|
|
}
|
|
|
|
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" {
|
|
_, _ = w.Write([]byte(`{"status":{"address":"127.0.0.1","ports":[{"name":"game","port":31002}]}}`))
|
|
return
|
|
}
|
|
if r.URL.Path == "/ready-probe" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
path := filepath.Join(t.TempDir(), "env.txt")
|
|
s, err := New(Config{Command: []string{"/bin/sh", "-c", "env > " + path}, SDKBaseURL: server.URL, ReadyURL: server.URL + "/ready-probe", Transport: "enet", ReadyTimeout: time.Second})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Wait(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
contents, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(contents), "SDR_LISTEN_PORT=") || strings.Contains(string(contents), "SDR_IP=") {
|
|
t.Fatalf("ENet received SDR variables: %s", contents)
|
|
}
|
|
}
|
|
|
|
func TestDirectModeDoesNotRequireAgonesReadiness(t *testing.T) {
|
|
s, err := New(Config{Command: []string{"/bin/sh", "-c", "exit 0"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Wait(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestDrainRequiresAndUsesAuthenticatedLocalEndpoint(t *testing.T) {
|
|
seenToken := ""
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/drain" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
seenToken = r.Header.Get("Authorization")
|
|
if seenToken != "Bearer secret-token" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}))
|
|
defer server.Close()
|
|
s, err := New(Config{Command: []string{"/bin/sh", "-c", "exit 0"}, DrainURL: server.URL + "/drain", DrainToken: "secret-token"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Drain(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if seenToken != "Bearer secret-token" {
|
|
t.Fatalf("unexpected drain token: %q", seenToken)
|
|
}
|
|
missing, _ := New(Config{Command: []string{"/bin/sh", "-c", "exit 0"}})
|
|
if err := missing.Drain(context.Background()); err == nil {
|
|
t.Fatal("unauthenticated drain was allowed")
|
|
}
|
|
}
|
|
|
|
func TestAssignedEndpointRejectsMalformedAddressAndPort(t *testing.T) {
|
|
for _, response := range []string{
|
|
`{"status":{"address":"203.0.113.9","ports":[{"name":"game","port":65536}]}}`,
|
|
`{"status":{"address":" ","ports":[{"name":"game","port":31001}]}}`,
|
|
`{"status":{"address":"203.0.113.9 bad","ports":[{"name":"game","port":31001}]}}`,
|
|
} {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/gameserver" {
|
|
_, _ = w.Write([]byte(response))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
s, err := New(Config{Command: []string{"/bin/sh", "-c", "exit 0"}, SDKBaseURL: server.URL, ReadyURL: server.URL + "/probe", ReadyTimeout: time.Second})
|
|
if err != nil {
|
|
server.Close()
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Start(context.Background()); err == nil {
|
|
t.Errorf("malformed endpoint was accepted: %s", response)
|
|
}
|
|
server.Close()
|
|
}
|
|
}
|
|
|
|
func TestSupervisorRejectsRemoteOrPartialDrainConfiguration(t *testing.T) {
|
|
for _, config := range []Config{
|
|
{Command: []string{"/bin/sh", "-c", "exit 0"}, DrainURL: "https://example.com/drain", DrainToken: "token-1234567890123456"},
|
|
{Command: []string{"/bin/sh", "-c", "exit 0"}, DrainURL: "http://127.0.0.1/drain"},
|
|
{Command: []string{"/bin/sh", "-c", "exit 0"}, DrainToken: "token-1234567890123456"},
|
|
{Command: []string{"/bin/sh", "-c", "exit 0"}, DrainURL: "http://127.0.0.1/drain?token=leaked", DrainToken: "token-1234567890123456"},
|
|
} {
|
|
if _, err := New(config); err == nil {
|
|
t.Fatalf("unsafe drain configuration accepted: %+v", config)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunDrainsBeforeChildExit(t *testing.T) {
|
|
marker := filepath.Join(t.TempDir(), "drained")
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/drain" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
if r.Header.Get("Authorization") != "Bearer run-secret" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if err := os.WriteFile(marker, []byte("drained"), 0600); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}))
|
|
defer server.Close()
|
|
command := []string{"/bin/sh", "-c", "while [ ! -f '" + marker + "' ]; do sleep 0.01; done"}
|
|
s, err := New(Config{Command: command, DrainURL: server.URL + "/drain", DrainToken: "run-secret"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
time.AfterFunc(30*time.Millisecond, cancel)
|
|
if err := s.Run(ctx, time.Second); err != nil {
|
|
t.Fatalf("graceful run: %v", err)
|
|
}
|
|
if _, err := os.Stat(marker); err != nil {
|
|
t.Fatalf("drain endpoint was not called: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunForceKillsUnresponsiveChildAtDeadline(t *testing.T) {
|
|
s, err := New(Config{Command: []string{"/bin/sh", "-c", "trap '' TERM; sleep 5"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
time.AfterFunc(30*time.Millisecond, cancel)
|
|
started := time.Now()
|
|
err = s.Run(ctx, 50*time.Millisecond)
|
|
if err == nil || !strings.Contains(err.Error(), "force-killed") {
|
|
t.Fatalf("unresponsive child result = %v", err)
|
|
}
|
|
if elapsed := time.Since(started); elapsed > time.Second {
|
|
t.Fatalf("force-kill exceeded bounded deadline: %s", elapsed)
|
|
}
|
|
}
|