mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 06:42:09 +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 |
|
| # | 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.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.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]` | **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.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.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.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 |
|
| 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