feat: add authenticated supervisor drain

This commit is contained in:
Josh Creek
2026-08-31 20:52:37 +01:00
parent 7ce28559d2
commit 893db17c03
3 changed files with 57 additions and 1 deletions
+1 -1
View File
@@ -1218,7 +1218,7 @@ the local/CI/community transport, not a silent production fallback.
| 8.33 `[D:8.26,8.32]` | On-demand-only live capacity and measured N+1: loss of largest node leaves two Ready slots plus headroom for surviving Allocated matches | Interruptible nodes cannot receive live matches; forced node loss neither overloads survivors nor prevents the next allocation |
| 8.34 `[D:8.28,8.29]` | Native x86_64 benchmark of boot-to-process-ready and assignment-ready, p99 CPU/RSS/network and 60 Hz ticks; limits/node cap with 30% headroom | Measurements replace old estimates and certify density with no tick backlog |
| 8.35 `[D:8.17,8.19,8.20,8.30,8.31]` | **IN PROGRESS.** Pure Go initial-connect policy decides ranked 30 s no-show cancellation with abandon ladder and casual 60 s bot start only when each team has a human; empty-team casual allocations cancel | `server/domain/noshow.go` covers wait/deadline boundaries, deterministic no-show/innocent ordering, ranked cooldown history and no pre-live rating action; persistent ticket restoration, allocation shutdown, bot spawn and live integration remain |
| 8.36 `[D:8.10,8.25,8.28,8.30]` | Go PID-1 supervisor traps TERM and authenticates localhost drain; 300 s grace/285 s infrastructure abort; PDB + Agones-aware Fleet drain; planned releases never TERM Allocated pods | Rollout/rollback waits Allocated=0; TERM path is exercised; forced timeout is classified/refunded; unexpected node loss is not claimed graceful |
| 8.36 `[D:8.10,8.25,8.28,8.30]` | **IN PROGRESS.** Supervisor exposes an authenticated localhost drain request boundary that never places the token in command arguments/logs | `server/supervisor/` covers bearer-token enforcement and rejection of missing drain credentials; TERM signal handling, 300 s/285 s lifecycle, PDB/Fleet drain and infrastructure-abort classification remain |
| 8.37 `[D:8.5,8.10,8.25,8.26,8.31]` | Horizontally scaled primary control plane + warm standby, EU/NA fleets, RPO <=5 m/RTO <=30 m; signed result annotation/reconciliation preserves delivery outages | Restore/failover meet targets; live simulation continues; valid delayed result commits exactly once after recovery |
| 8.38 `[D:7.7,8.26,8.36,8.37]` | Provider migration after Valve approves both providers' EU/NA POPs/certs and public UDP: restore, validate coordinator trust, switch allocations, drain old | Both geographies complete Hosted-SDR matches on new provider and no old-provider live match is terminated |
+25
View File
@@ -31,6 +31,8 @@ type Config struct {
SDKBaseURL string
ReadyURL string
Transport string
DrainURL string
DrainToken string
ReadyTimeout time.Duration
PollInterval time.Duration
HTTPClient *http.Client
@@ -115,6 +117,29 @@ func (s *Supervisor) Wait() error {
return s.cmd.Wait()
}
// Drain asks the allocated Godot process to stop accepting new work. The
// token is sent only over the configured localhost control endpoint and is
// never placed in command arguments or logs.
func (s *Supervisor) Drain(ctx context.Context) error {
if s.config.DrainURL == "" || s.config.DrainToken == "" {
return fmt.Errorf("authenticated drain endpoint is required")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.DrainURL, nil)
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+s.config.DrainToken)
response, err := s.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode/100 != 2 {
return fmt.Errorf("drain endpoint returned %s", response.Status)
}
return nil
}
func (s *Supervisor) assignedEndpoint(ctx context.Context) (int, string, error) {
var server GameServer
if err := s.sdkGet(ctx, "/gameserver", &server); err != nil {
+31
View File
@@ -111,3 +111,34 @@ func TestDirectModeDoesNotRequireAgonesReadiness(t *testing.T) {
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")
}
}