mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(multiplayer): report process-ready to the control plane from the supervisor
Add opt-in control-plane registration to server/supervisor: once Agones
Ready succeeds, POST /v1/servers/{id}/register (assignment_ready=false)
using a workload token read fresh from disk each call -- matching how a
Kubernetes projected service account token is rotated in place by
kubelet, unlike a cached/env-var secret. ControlPlaneURL empty (the
default) is a total no-op, so direct/Compose mode and allocated-without-
control-plane mode are both byte-for-byte unaffected; New() rejects a
half-configured registration (URL set without token path/server/match/
digest) rather than silently skipping it.
ServerID/MatchID/ImageDigest are read from env vars named by CLI flags
(--server-id-env, --match-id-env, --image-digest-env), matching the
existing --drain-token-env convention in this same binary, rather than
parsed out of the Agones SDK's own GameServer JSON -- that shape isn't
independently verifiable from here, whereas the Kubernetes Downward API
(fieldRef: metadata.name) populating an env var is a standard, safe
pattern already used elsewhere in this codebase for exactly this class
of secret.
A registration failure now kills the child (matching the existing
waitReady failure path) rather than leaving Agones-Ready-but-
control-plane-unregistered process running -- a real gap the second new
test (TestControlPlaneRegistrationFailureKillsChildRatherThanRunningUnregistered)
had to be corrected to actually exercise: its first draft omitted
ReadyURL and was failing at waitReady, before ever reaching the code
path it claimed to test.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -38,6 +39,23 @@ type Config struct {
|
||||
ReadyTimeout time.Duration
|
||||
PollInterval time.Duration
|
||||
HTTPClient *http.Client
|
||||
|
||||
// ControlPlaneURL, when set, opts into reporting process-ready to the
|
||||
// matchmaking control plane (multiplayer-next.md task 8.28) once Agones
|
||||
// Ready succeeds. Leaving it empty preserves every existing behavior
|
||||
// exactly -- direct/Compose mode and allocated-without-control-plane mode
|
||||
// are both unaffected. WorkloadTokenPath is read fresh on every call
|
||||
// rather than cached, matching how a Kubernetes projected service account
|
||||
// token is rotated in place by kubelet before it expires; ServerID,
|
||||
// MatchID and ImageDigest are expected to be populated from the pod spec
|
||||
// (Downward API / mounted build metadata), not guessed at from the
|
||||
// Agones SDK's own GameServer response.
|
||||
ControlPlaneURL string
|
||||
WorkloadTokenPath string
|
||||
ServerID string
|
||||
MatchID string
|
||||
ProtocolVersion int
|
||||
ImageDigest string
|
||||
}
|
||||
|
||||
type Supervisor struct {
|
||||
@@ -75,6 +93,9 @@ func New(config Config) (*Supervisor, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if config.ControlPlaneURL != "" && (config.WorkloadTokenPath == "" || config.ServerID == "" || config.MatchID == "" || config.ProtocolVersion < 1 || config.ImageDigest == "") {
|
||||
return nil, fmt.Errorf("control-plane registration requires a workload token path, server ID, match ID, protocol version and image digest")
|
||||
}
|
||||
return &Supervisor{config: config, client: config.HTTPClient}, nil
|
||||
}
|
||||
|
||||
@@ -123,7 +144,70 @@ func (s *Supervisor) Start(ctx context.Context) error {
|
||||
_ = s.cmd.Process.Kill()
|
||||
return err
|
||||
}
|
||||
return s.sdkPost(ctx, "/ready")
|
||||
if err := s.sdkPost(ctx, "/ready"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.registerControlPlane(ctx, false); err != nil {
|
||||
// Unlike a bare Agones Ready, this failure leaves the match's durable
|
||||
// control-plane record stuck at ALLOCATING with no way for the
|
||||
// matcher/allocator to learn this process is actually listening --
|
||||
// players would wait indefinitely for a server that Agones considers
|
||||
// healthy. Kill the child so Kubernetes reschedules rather than
|
||||
// leaving that silent split-brain running.
|
||||
_ = s.cmd.Process.Kill()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerControlPlane reports the allocated process's readiness to the
|
||||
// matchmaking control plane (POST /v1/servers/{id}/register). It is a no-op
|
||||
// whenever ControlPlaneURL is unset, which is the default and preserves
|
||||
// every existing direct/Compose/allocated-only behavior exactly. The
|
||||
// workload token is read fresh from disk on every call rather than cached --
|
||||
// a Kubernetes projected service account token is rotated in place by
|
||||
// kubelet before it expires, so caching it risks presenting a stale one on a
|
||||
// long-lived process.
|
||||
func (s *Supervisor) registerControlPlane(ctx context.Context, assignmentReady bool) error {
|
||||
if s.config.ControlPlaneURL == "" {
|
||||
return nil
|
||||
}
|
||||
tokenBytes, err := os.ReadFile(s.config.WorkloadTokenPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read workload token: %w", err)
|
||||
}
|
||||
token := strings.TrimSpace(string(tokenBytes))
|
||||
if token == "" {
|
||||
return fmt.Errorf("workload token file %q is empty", s.config.WorkloadTokenPath)
|
||||
}
|
||||
body, err := json.Marshal(struct {
|
||||
MatchID string `json:"match_id"`
|
||||
ProtocolVersion int `json:"protocol_version"`
|
||||
ImageDigest string `json:"image_digest"`
|
||||
AssignmentReady bool `json:"assignment_ready"`
|
||||
}{s.config.MatchID, s.config.ProtocolVersion, s.config.ImageDigest, assignmentReady})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint := strings.TrimRight(s.config.ControlPlaneURL, "/") + "/v1/servers/" + url.PathEscape(s.config.ServerID) + "/register"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
// Idempotent per (server, readiness stage): a supervisor restart or a
|
||||
// dropped response retrying this exact call must replay, not conflict.
|
||||
request.Header.Set("Idempotency-Key", "supervisor-register-"+s.config.ServerID+"-"+strconv.FormatBool(assignmentReady))
|
||||
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 register returned %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func withPort(command []string, port int) []string {
|
||||
|
||||
Reference in New Issue
Block a user