diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 0b96a00f..cf8aed16 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -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 | diff --git a/server/supervisor/supervisor.go b/server/supervisor/supervisor.go index ec8f8cf4..f26fab88 100644 --- a/server/supervisor/supervisor.go +++ b/server/supervisor/supervisor.go @@ -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 { diff --git a/server/supervisor/supervisor_test.go b/server/supervisor/supervisor_test.go index bd488b76..1951dba7 100644 --- a/server/supervisor/supervisor_test.go +++ b/server/supervisor/supervisor_test.go @@ -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") + } +}