diff --git a/multiplayer-next.md b/multiplayer-next.md index 7bcc7bae..d5be4e44 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1432,3 +1432,5 @@ Clients now consume planned shutdowns: the reason is retained for presentation, An adversarial UI review found the lobby’s generic disconnect handler still replaced that message with the main menu immediately afterward. Planned disconnects are now fenced in the lobby, and a lobby reached from an active match restores the retained reason on startup; unplanned disconnects keep the existing main-menu behavior. The workload-authenticated `POST /servers/{serverId}/shutdown` contract is now exposed for allocated servers. It validates the bound credential and reason, records an idempotent `SERVER_SHUTDOWN` audit event under a serializable transaction, and returns a stable acknowledgment on retry; match-state transitions remain owned by the no-show/result transactions. API/store tests cover authorization, validation, idempotency SQL, and audit wiring; live PostgreSQL delivery remains an integration gate. + +The allocated supervisor now calls that shutdown acknowledgment during signal-bound controlled drain, using the same workload credential and a deterministic idempotency key after the local drain request succeeds. The lifecycle test verifies the drain-before-ack ordering, credential separation, and bounded graceful child exit; live pod termination and control-plane outage behavior remain deployment gates. diff --git a/server/cmd/game-server-supervisor/main.go b/server/cmd/game-server-supervisor/main.go index ce4a179d..d3aaefd8 100644 --- a/server/cmd/game-server-supervisor/main.go +++ b/server/cmd/game-server-supervisor/main.go @@ -16,7 +16,8 @@ const usageText = `Usage: game-server-supervisor [options] -- 128 { + key = key[:128] + } + request.Header.Set("Idempotency-Key", key) + response, err := s.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode/100 != 2 { + return fmt.Errorf("control-plane shutdown 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 e983955d..43ef921d 100644 --- a/server/supervisor/supervisor_test.go +++ b/server/supervisor/supervisor_test.go @@ -629,6 +629,82 @@ func TestRunDrainsBeforeChildExit(t *testing.T) { } } +func TestRunAcknowledgesControlledShutdownWithWorkloadCredential(t *testing.T) { + marker := filepath.Join(t.TempDir(), "drained") + tokenPath := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenPath, []byte("workload-secret"), 0600); err != nil { + t.Fatal(err) + } + var shutdownCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/drain": + if r.Header.Get("Authorization") != "Bearer run-secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if err := os.WriteFile(marker, []byte("drained"), 0600); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusAccepted) + case "/v1/servers/server-1/shutdown": + if r.Header.Get("Authorization") != "Bearer workload-secret" || r.Header.Get("Idempotency-Key") == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + shutdownCalls++ + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + s, err := New(Config{ + Command: []string{"/bin/sh", "-c", "while [ ! -f '" + marker + "' ]; do sleep 0.01; done"}, + DrainURL: server.URL + "/drain", DrainToken: "run-secret", ControlPlaneURL: server.URL, + WorkloadTokenPath: tokenPath, ServerID: "server-1", MatchID: "match-1", ProtocolVersion: 1, + ImageDigest: "sha256:aa", + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(30*time.Millisecond, cancel) + if err := s.Run(ctx, time.Second); err != nil { + t.Fatalf("graceful run: %v", err) + } + if shutdownCalls != 1 { + t.Fatalf("shutdown calls = %d, want 1", shutdownCalls) + } +} + +func TestRunDoesNotAcknowledgeWhenLocalDrainFails(t *testing.T) { + var shutdownCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/servers/server-1/shutdown" { + shutdownCalls++ + } + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + s, err := New(Config{ + Command: []string{"/bin/sh", "-c", "sleep 5"}, + DrainURL: server.URL + "/drain", DrainToken: "run-secret", ControlPlaneURL: server.URL, + WorkloadTokenPath: filepath.Join(t.TempDir(), "missing-token"), ServerID: "server-1", MatchID: "match-1", + ProtocolVersion: 1, ImageDigest: "sha256:aa", + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(30*time.Millisecond, cancel) + err = s.Run(ctx, 50*time.Millisecond) + if err == nil || shutdownCalls != 0 { + t.Fatalf("failed drain result=%v shutdown calls=%d", err, shutdownCalls) + } +} + func TestRunForceKillsUnresponsiveChildAtDeadline(t *testing.T) { s, err := New(Config{Command: []string{"/bin/sh", "-c", "trap '' TERM; sleep 5"}}) if err != nil {