mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-12 23:52:03 +00:00
feat(multiplayer): deliver allocated server rosters
This commit is contained in:
@@ -8,11 +8,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -89,6 +91,11 @@ type Config struct {
|
||||
// listening and usable either way. Default 5 attempts, 2s apart.
|
||||
AssignmentReadyAttempts int
|
||||
AssignmentReadyBackoff time.Duration
|
||||
// RosterPath is an operator-mounted writable path where the supervisor
|
||||
// materializes the workload-authenticated signed roster before starting
|
||||
// Godot. It is deliberately separate from WorkloadTokenPath: the former
|
||||
// contains match join envelopes, the latter contains a bearer credential.
|
||||
RosterPath string
|
||||
}
|
||||
|
||||
type Supervisor struct {
|
||||
@@ -136,6 +143,9 @@ func New(config Config) (*Supervisor, error) {
|
||||
if config.ControlPlaneURL != "" && (config.ServerID == "" || config.ProtocolVersion < 1 || config.ImageDigest == "") {
|
||||
return nil, fmt.Errorf("control-plane registration requires a server ID, protocol version and image digest")
|
||||
}
|
||||
if config.RosterPath != "" && config.ControlPlaneURL == "" {
|
||||
return nil, fmt.Errorf("roster path requires control-plane URL")
|
||||
}
|
||||
// Neither MatchID nor WorkloadTokenPath is required here: both can
|
||||
// instead be resolved at Start time from the allocated GameServer's own
|
||||
// annotations (see registerControlPlane/workloadToken/matchID). They are
|
||||
@@ -172,6 +182,9 @@ func (s *Supervisor) Start(ctx context.Context) error {
|
||||
if s.config.Transport == "steam_sdr" {
|
||||
env = append(env, "SDR_LISTEN_PORT="+strconv.Itoa(port), "SDR_IP="+address+":"+strconv.Itoa(port))
|
||||
}
|
||||
if err := s.fetchRoster(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
command := withPort(s.config.Command, port)
|
||||
s.cmd = exec.CommandContext(ctx, command[0], command[1:]...)
|
||||
} else {
|
||||
@@ -205,6 +218,70 @@ func (s *Supervisor) Start(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Supervisor) fetchRoster(ctx context.Context) error {
|
||||
if s.config.RosterPath == "" {
|
||||
return nil
|
||||
}
|
||||
matchID := s.matchID()
|
||||
if matchID == "" {
|
||||
return fmt.Errorf("roster fetch has no match ID")
|
||||
}
|
||||
token, err := s.workloadToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rosterURL := strings.TrimRight(s.config.ControlPlaneURL, "/") + "/v1/servers/" + url.PathEscape(s.config.ServerID) + "/roster"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, rosterURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
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 roster returned %s", response.Status)
|
||||
}
|
||||
var roster []json.RawMessage
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&roster); err != nil || len(roster) == 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("empty roster")
|
||||
}
|
||||
return fmt.Errorf("decode control-plane roster: %w", err)
|
||||
}
|
||||
for _, envelope := range roster {
|
||||
if len(envelope) == 0 || string(envelope) == "null" {
|
||||
return fmt.Errorf("control-plane roster contains an invalid envelope")
|
||||
}
|
||||
}
|
||||
contents, err := json.Marshal(roster)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode roster: %w", err)
|
||||
}
|
||||
directory := filepath.Dir(s.config.RosterPath)
|
||||
temporary, err := os.CreateTemp(directory, ".cosmic-clash-roster-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create roster file: %w", err)
|
||||
}
|
||||
temporaryName := temporary.Name()
|
||||
defer os.Remove(temporaryName)
|
||||
if err := temporary.Chmod(0600); err == nil {
|
||||
_, err = temporary.Write(contents)
|
||||
}
|
||||
if closeErr := temporary.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("write roster file: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryName, s.config.RosterPath); err != nil {
|
||||
return fmt.Errorf("install roster file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reportAssignmentReady is best-effort: process-ready has already succeeded,
|
||||
// so the process is legitimately usable either way. A persistent failure is
|
||||
// written to stderr rather than returned, since treating it as fatal would
|
||||
|
||||
Reference in New Issue
Block a user