feat(multiplayer): expose server shutdown acknowledgement

This commit is contained in:
Josh Creek
2026-09-01 16:50:26 +01:00
parent 1d926e705b
commit cc12260225
9 changed files with 224 additions and 3 deletions
+35 -3
View File
@@ -40,6 +40,9 @@ type ResultSubmitter interface {
type ServerRegistrar interface {
RegisterServer(context.Context, domain.WorkloadBinding, int, bool, string, time.Time) error
}
type ServerShutdowner interface {
ShutdownServer(context.Context, domain.WorkloadBinding, string, string, time.Time) error
}
type QueueBackend interface {
Create(context.Context, string, string, string, domain.QueueSpec, time.Time) (domain.QueueTicket, error)
@@ -113,6 +116,7 @@ type Service struct {
WorkloadVerify WorkloadVerifier
ResultSubmitter ResultSubmitter
ServerRegistrar ServerRegistrar
ServerShutdowner ServerShutdowner
Assignment AssignmentProvider
Roster RosterProvider
Now func() time.Time
@@ -432,7 +436,8 @@ func (s *Service) contractAssignment(w http.ResponseWriter, r *http.Request) {
func (s *Service) contractServerMutation(w http.ResponseWriter, r *http.Request) {
// Unlike contractAssignment, the documented shape here is two segments
// (/servers/{serverId}/result, /servers/{serverId}/register) — rejecting
// (/servers/{serverId}/result, /servers/{serverId}/register, or
// /servers/{serverId}/shutdown) — rejecting
// any "/" would 404 every real call. Delegate shape validation to
// serverMutation, which already enforces exactly {id}/{result|register}.
path := strings.TrimPrefix(r.URL.Path, "/api/v1/servers/")
@@ -464,7 +469,7 @@ type serverRegistrationRequest struct {
func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/servers/"), "/")
if len(parts) != 2 || parts[0] == "" || (parts[1] != "result" && parts[1] != "register" && parts[1] != "roster") {
if len(parts) != 2 || parts[0] == "" || (parts[1] != "result" && parts[1] != "register" && parts[1] != "roster" && parts[1] != "shutdown") {
writeError(w, http.StatusNotFound, "not_found")
return
}
@@ -472,7 +477,7 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
return
}
if s.WorkloadVerify == nil || (parts[1] == "result" && s.ResultSubmitter == nil) || (parts[1] == "register" && s.ServerRegistrar == nil) || (parts[1] == "roster" && s.Roster == nil) {
if s.WorkloadVerify == nil || (parts[1] == "result" && s.ResultSubmitter == nil) || (parts[1] == "register" && s.ServerRegistrar == nil) || (parts[1] == "roster" && s.Roster == nil) || (parts[1] == "shutdown" && s.ServerShutdowner == nil) {
writeError(w, http.StatusServiceUnavailable, "server_unavailable")
return
}
@@ -538,6 +543,33 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
return
}
if parts[1] == "shutdown" {
var input struct {
Reason string `json:"reason"`
}
if !decodeBody(w, r, &input) {
return
}
if input.Reason == "" || len(input.Reason) > 96 || strings.ContainsAny(input.Reason, "\r\n\t") {
s.logEvent(observability.Event{Event: "server_shutdown", MatchID: binding.MatchID, ServerID: parts[0], Stage: "rejected", OccurredAt: now})
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
return
}
if err := s.ServerShutdowner.ShutdownServer(r.Context(), binding, input.Reason, key, now); err != nil {
stage := "invalid"
if errors.Is(err, domain.ErrConflict) {
stage = "conflict"
writeError(w, http.StatusConflict, "conflict")
} else {
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
}
s.logEvent(observability.Event{Event: "server_shutdown", MatchID: binding.MatchID, ServerID: parts[0], Stage: stage, OccurredAt: now})
return
}
s.logEvent(observability.Event{Event: "server_shutdown", MatchID: binding.MatchID, ServerID: parts[0], Stage: "acknowledged", OccurredAt: now})
w.WriteHeader(http.StatusNoContent)
return
}
var input resultRequest
if !decodeBody(w, r, &input) {
return
+48
View File
@@ -52,6 +52,20 @@ type serverRegistrarSpy struct {
err error
}
type serverShutdownerSpy struct {
calls int
binding domain.WorkloadBinding
reason string
key string
err error
}
func (s *serverShutdownerSpy) ShutdownServer(_ context.Context, binding domain.WorkloadBinding, reason, key string, _ time.Time) error {
s.calls++
s.binding, s.reason, s.key = binding, reason, key
return s.err
}
func (s *serverRegistrarSpy) RegisterServer(_ context.Context, binding domain.WorkloadBinding, protocol int, assignmentReady bool, _ string, _ time.Time) error {
s.calls++
s.binding, s.protocol, s.assignmentReady = binding, protocol, assignmentReady
@@ -1358,6 +1372,40 @@ func TestServerRegistrationAPIRequiresBoundWorkloadAndValidDigest(t *testing.T)
response.Body.Close()
}
func TestServerShutdownAPIRequiresBoundWorkloadAndDelegatesAcknowledgement(t *testing.T) {
now := time.Unix(1000, 0).UTC()
binding := domain.WorkloadBinding{AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1"}
shutdowner := &serverShutdownerSpy{}
service := &Service{Now: func() time.Time { return now }, WorkloadVerify: func(token string, at time.Time) (domain.WorkloadBinding, error) {
if token != "workload-token" || !at.Equal(now) {
t.Fatalf("verifier input=%q %v", token, at)
}
return binding, nil
}, ServerShutdowner: shutdowner}
server := httptest.NewServer(service.Handler())
defer server.Close()
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/shutdown", strings.NewReader(`{"reason":"server_draining"}`))
req.Header.Set("Authorization", "Bearer workload-token")
req.Header.Set("Idempotency-Key", "shutdown-key-123456")
response, err := http.DefaultClient.Do(req)
if err != nil || response.StatusCode != http.StatusNoContent {
t.Fatalf("status=%v err=%v", response.StatusCode, err)
}
response.Body.Close()
if shutdowner.calls != 1 || shutdowner.binding != binding || shutdowner.reason != "server_draining" || shutdowner.key != "shutdown-key-123456" {
t.Fatalf("shutdown=%+v", shutdowner)
}
req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/shutdown", strings.NewReader(`{"reason":"bad\nreason"}`))
req.Header.Set("Authorization", "Bearer workload-token")
req.Header.Set("Idempotency-Key", "shutdown-key-123456")
response, err = http.DefaultClient.Do(req)
if err != nil || response.StatusCode != http.StatusUnprocessableEntity || shutdowner.calls != 1 {
t.Fatalf("invalid shutdown status=%v err=%v calls=%d", response.StatusCode, err, shutdowner.calls)
}
response.Body.Close()
}
func TestProbeAPIUsesServerEvidenceAndRejectsClientRTTField(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
+13
View File
@@ -74,6 +74,19 @@ func ServerRegistrarFromStore(db *sql.DB) ServerRegistrar {
return postgresServerRegistrar{db: db}
}
type postgresServerShutdowner struct{ db *sql.DB }
func (p postgresServerShutdowner) ShutdownServer(ctx context.Context, binding domain.WorkloadBinding, reason, idempotencyKey string, now time.Time) error {
return store.RecordServerShutdown(ctx, p.db, binding, reason, idempotencyKey, now)
}
func ServerShutdownerFromStore(db *sql.DB) ServerShutdowner {
if db == nil {
return nil
}
return postgresServerShutdowner{db: db}
}
// WorkloadVerifierFromSignedToken builds WorkloadVerify from a control-plane
// -owned signed token instead of a Kubernetes-projected JWT (see
// workload/signed_token.go for why: it needs no live cluster to verify).