feat: add Agones readiness supervisor core

This commit is contained in:
Josh Creek
2026-08-31 20:40:51 +01:00
parent 698413cd91
commit a0195987bd
3 changed files with 242 additions and 2 deletions
+2 -2
View File
@@ -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 |
+168
View File
@@ -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
}
+72
View File
@@ -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)
}
}