mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat: add Agones readiness supervisor core
This commit is contained in:
+2
-2
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user