From a0195987bdf8f6d7208f0a878f76d822ea527122 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:40:51 +0100 Subject: [PATCH] feat: add Agones readiness supervisor core --- multiplayer-todo.md | 4 +- server/supervisor/supervisor.go | 168 +++++++++++++++++++++++++++ server/supervisor/supervisor_test.go | 72 ++++++++++++ 3 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 server/supervisor/supervisor.go create mode 100644 server/supervisor/supervisor_test.go diff --git a/multiplayer-todo.md b/multiplayer-todo.md index f3f81fc9..5fd76a05 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1209,8 +1209,8 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| | 8.26 `[D:8.1,8.6,8.12]` | Portable Helm/Kustomize Fleets per build/EU/NA region; isolate provider edge/network/DNS/secret and SDR POP/cert/public-UDP overlays | Two provider fixtures render; labels select region/build/protocol/transport; each fixture documents Valve approval and externally reachable UDP mapping | -| 8.27 `[D:8.26]` | Godot Agones REST adapter plus allocation-metadata watch and Go PID-1 supervisor scaffold; both bypass cloud behavior without SDK env; local SDK support | Native/existing Compose/CI remain functional; emulator exercises supervisor port discovery plus Get/Watch, Ready, Health, annotation and Shutdown | -| 8.28 `[D:8.6,8.27]` | **Process-ready stage:** supervisor obtains dynamic port, launches Godot; static config/listen/Health succeed, then explicit Agones Ready—no roster/backend-registration prerequisite and no stdout probe | A detached unallocated process reaches Ready; a broken listener/config never does; Health reclaims a hung process | +| 8.27 `[D:8.26]` | **IN PROGRESS.** Go supervisor package provides local-safe Agones REST discovery, dynamic `SDR_LISTEN_PORT`/`SDR_IP` injection, explicit process-ready probing and Ready transition; direct mode bypasses Agones | `server/supervisor/` covers allocated/direct startup and dynamic endpoint/Ready ordering; Godot Agones adapter, metadata watch, Health/annotation/Shutdown 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 | `server/supervisor/` tests prove Ready follows the probe and direct mode remains functional; Godot readiness endpoint, detached-container and Health-reclaim integration remain | | 8.29 `[D:8.26,8.27]` | Separate ENet and Hosted-SDR dynamic/passthrough mappings; supervisor exports local `SDR_LISTEN_PORT` and external `SDR_IP`; validate POP/cert/firewall/NAT | Two isolated matches share a node; Agones-reported public endpoint receives relay traffic on the bound socket; ENet fixture remains independent | | 8.30 `[D:8.18,8.26,8.28,8.29]` | Atomic `GameServerAllocation` from Ready filtered by region/build/protocol/transport, attaching signed roster/non-secret config with bounded race retry | Duplicate commands yield one Allocated server; exhaustion or retry leaves no orphan; no client assignment is exposed merely because process is Ready | | 8.31 `[D:8.9,8.30]` | **Assignment-ready stage:** watch Allocated metadata, verify manifest/bindings, register hosted address, acknowledge backend; only then mint/expose client tickets | Modified/wrong manifest never reaches assignment-ready; clients cannot connect early; secrets never appear in metadata/args/logs | diff --git a/server/supervisor/supervisor.go b/server/supervisor/supervisor.go new file mode 100644 index 00000000..08f1325a --- /dev/null +++ b/server/supervisor/supervisor.go @@ -0,0 +1,168 @@ +// Package supervisor contains the small PID-1 lifecycle boundary around an +// allocated Godot process. The Agones client is HTTP-only so local/Compose +// execution remains independent of the cloud SDK. +package supervisor + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +type GameServer struct { + Status struct { + Address string `json:"address"` + Ports []struct { + Name string `json:"name"` + Port int `json:"port"` + } `json:"ports"` + } `json:"status"` +} + +type Config struct { + Command []string + Environment []string + SDKBaseURL string + ReadyURL string + ReadyTimeout time.Duration + PollInterval time.Duration + HTTPClient *http.Client +} + +type Supervisor struct { + config Config + client *http.Client + cmd *exec.Cmd +} + +func New(config Config) (*Supervisor, error) { + if len(config.Command) == 0 || config.Command[0] == "" { + return nil, fmt.Errorf("supervisor command is required") + } + if config.ReadyTimeout <= 0 { + config.ReadyTimeout = 30 * time.Second + } + if config.PollInterval <= 0 { + config.PollInterval = 100 * time.Millisecond + } + if config.HTTPClient == nil { + config.HTTPClient = http.DefaultClient + } + return &Supervisor{config: config, client: config.HTTPClient}, nil +} + +// Start launches the process and marks Agones Ready only after the explicit +// readiness probe succeeds. No stdout/log scraping is used. With no SDK URL, +// this is direct/Compose mode and the command is simply started. +func (s *Supervisor) Start(ctx context.Context) error { + env := append([]string(nil), os.Environ()...) + env = append(env, s.config.Environment...) + if s.config.SDKBaseURL != "" { + port, address, err := s.assignedEndpoint(ctx) + if err != nil { + return err + } + env = append(env, "SDR_LISTEN_PORT="+strconv.Itoa(port), "SDR_IP="+address+":"+strconv.Itoa(port)) + } + s.cmd = exec.CommandContext(ctx, s.config.Command[0], s.config.Command[1:]...) + s.cmd.Env = env + if err := s.cmd.Start(); err != nil { + return err + } + if s.config.SDKBaseURL == "" { + return nil + } + if err := s.waitReady(ctx); err != nil { + _ = s.cmd.Process.Kill() + return err + } + return s.sdkPost(ctx, "/ready") +} + +func (s *Supervisor) Wait() error { + if s.cmd == nil { + return fmt.Errorf("supervisor has not started") + } + return s.cmd.Wait() +} + +func (s *Supervisor) assignedEndpoint(ctx context.Context) (int, string, error) { + var server GameServer + if err := s.sdkGet(ctx, "/gameserver", &server); err != nil { + return 0, "", err + } + if len(server.Status.Ports) == 0 || server.Status.Address == "" { + return 0, "", fmt.Errorf("Agones returned no assigned endpoint") + } + for _, port := range server.Status.Ports { + if port.Port > 0 && (port.Name == "game" || len(server.Status.Ports) == 1) { + return port.Port, server.Status.Address, nil + } + } + return 0, "", fmt.Errorf("Agones returned no usable game port") +} + +func (s *Supervisor) waitReady(ctx context.Context) error { + if s.config.ReadyURL == "" { + return fmt.Errorf("allocated mode requires an explicit readiness URL") + } + deadline := time.NewTimer(s.config.ReadyTimeout) + defer deadline.Stop() + for { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.config.ReadyURL, nil) + if err == nil { + response, requestErr := s.client.Do(request) + if requestErr == nil { + _ = response.Body.Close() + if response.StatusCode >= 200 && response.StatusCode < 300 { + return nil + } + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return fmt.Errorf("process-ready probe timed out") + case <-time.After(s.config.PollInterval): + } + } +} + +func (s *Supervisor) sdkGet(ctx context.Context, path string, target any) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(s.config.SDKBaseURL, "/")+path, nil) + if err != nil { + return err + } + response, err := s.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode/100 != 2 { + return fmt.Errorf("Agones GET %s returned %s", path, response.Status) + } + return json.NewDecoder(response.Body).Decode(target) +} + +func (s *Supervisor) sdkPost(ctx context.Context, path string) error { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.config.SDKBaseURL, "/")+path, nil) + if err != nil { + return err + } + response, err := s.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode/100 != 2 { + return fmt.Errorf("Agones POST %s returned %s", path, response.Status) + } + return nil +} diff --git a/server/supervisor/supervisor_test.go b/server/supervisor/supervisor_test.go new file mode 100644 index 00000000..73ff1e64 --- /dev/null +++ b/server/supervisor/supervisor_test.go @@ -0,0 +1,72 @@ +package supervisor + +import ( + "context" + "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") + command := []string{"/bin/sh", "-c", "env > " + path} + s, err := New(Config{Command: command, SDKBaseURL: server.URL, ReadyURL: server.URL + "/ready-probe", 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) + } + if !readyCalled { + t.Fatal("Agones Ready was called before process-ready probe") + } +} + +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) + } +}