Files
CosmicClash/server/agones/allocation.go
T
2026-09-01 21:04:55 +01:00

330 lines
13 KiB
Go

// Package agones contains the narrow provider adapter used by the allocator.
// Domain policy and durable allocation records remain outside this package.
package agones
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/cosmic-clash/cosmic-clash/server/workload"
)
type Client struct {
BaseURL string
Namespace string
HTTP *http.Client
// WorkloadSecret, when set, mints a control-plane-self-issued signed
// workload token (server/workload/signed_token.go) for every allocation
// and requests it as the cosmic-clash.io/workload-token annotation
// alongside match-id/allocation-id -- the delivery channel
// supervisor.Supervisor.workloadToken() reads from. It must be the same
// secret cmd/control-plane verifies with (--workload-secret /
// COSMIC_CLASH_WORKLOAD_SECRET). Left unset, Allocate behaves exactly as
// before: no workload-token annotation is requested, matching how a
// deployment not yet using this delivery path (e.g. one still building
// toward a Kubernetes-JWT WorkloadVerify) is unaffected.
WorkloadSecret []byte
// WorkloadTokenTTL bounds how long the minted token remains valid; it
// must comfortably exceed the time between allocation and this
// GameServer completing process-ready/assignment-ready registration.
// Zero defaults to 30 minutes.
WorkloadTokenTTL time.Duration
}
type AllocatedServer struct {
Allocation domain.Allocation
Endpoint string
GameServer string
}
type allocationRequest struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Spec struct {
Selectors []struct {
MatchLabels map[string]string `json:"matchLabels"`
} `json:"selectors"`
// Metadata.Annotations is applied to the allocated GameServer's own
// object_meta by Agones on successful allocation (a documented part
// of the GameServerAllocation spec, independent of the Selectors
// used to find capacity). This is the only way match-specific data
// reaches an already-Ready pod after allocation: Kubernetes env vars
// are fixed at pod creation, long before Agones assigns a match to
// that pod, so there is no other channel for it. The allocated
// process reads these back via the SDK's own GameServer call
// (server/supervisor's existing /gameserver request).
Metadata struct {
Annotations map[string]string `json:"annotations"`
} `json:"metadata"`
} `json:"spec"`
}
type allocationResponse struct {
Status struct {
State string `json:"state"`
GameServerName string `json:"gameServerName"`
Address string `json:"address"`
Ports []struct {
Name string `json:"name"`
Port int `json:"port"`
} `json:"ports"`
} `json:"status"`
}
type gameServerListResponse struct {
Items []struct {
Metadata struct {
Name string `json:"name"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
} `json:"metadata"`
Status struct {
State string `json:"state"`
Address string `json:"address"`
Ports []struct {
Name string `json:"name"`
Port int `json:"port"`
} `json:"ports"`
} `json:"status"`
} `json:"items"`
}
// RecoverAllocation finds a provider-side allocation that may have completed
// before the durable allocation record was written. The allocation ID and
// compatibility tuple are checked together so a stale or forged provider
// object cannot be rebound to another match.
func (c Client) RecoverAllocation(ctx context.Context, request domain.AllocationRequest, now time.Time) (AllocatedServer, bool, error) {
if request.AllocationID == "" || request.MatchID == "" || now.IsZero() {
return AllocatedServer{}, false, domain.ErrAllocationInput
}
if c.HTTP == nil {
c.HTTP = http.DefaultClient
}
base, err := c.endpoint()
if err != nil {
return AllocatedServer{}, false, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/apis/agones.dev/v1/namespaces/"+url.PathEscape(c.Namespace)+"/gameservers", nil)
if err != nil {
return AllocatedServer{}, false, err
}
response, err := c.HTTP.Do(req)
if err != nil {
return AllocatedServer{}, false, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return AllocatedServer{}, false, fmt.Errorf("Agones GameServer recovery returned %s", response.Status)
}
var decoded gameServerListResponse
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&decoded); err != nil {
return AllocatedServer{}, false, fmt.Errorf("decode Agones recovery list: %w", err)
}
found := false
var recovered AllocatedServer
for _, item := range decoded.Items {
if item.Status.State != "Allocated" || item.Metadata.Annotations["cosmic-clash.io/allocation-id"] != request.AllocationID {
continue
}
if found {
return AllocatedServer{}, false, domain.ErrConflict
}
if item.Metadata.Name == "" || item.Status.Address == "" || strings.ContainsAny(item.Status.Address, " \t\r\n") {
return AllocatedServer{}, false, fmt.Errorf("Agones recovered GameServer has invalid identity or address")
}
if item.Metadata.Annotations["cosmic-clash.io/match-id"] != request.MatchID {
return AllocatedServer{}, false, domain.ErrConflict
}
if item.Metadata.Labels["cosmic-clash.io/region"] != request.Region || item.Metadata.Labels["cosmic-clash.io/build"] != request.Build || item.Metadata.Labels["cosmic-clash.io/protocol"] != strconv.Itoa(request.Protocol) || item.Metadata.Labels["cosmic-clash.io/transport"] != request.Transport {
return AllocatedServer{}, false, domain.ErrConflict
}
port, err := selectPort(item.Status.Ports)
if err != nil {
return AllocatedServer{}, false, err
}
recovered = AllocatedServer{Allocation: domain.Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: item.Metadata.Name, Region: request.Region, Build: request.Build, Protocol: request.Protocol, Transport: request.Transport, State: domain.ServerAllocated, AllocatedAt: now}, Endpoint: net.JoinHostPort(item.Status.Address, strconv.Itoa(port)), GameServer: item.Metadata.Name}
found = true
}
return recovered, found, nil
}
// ListReadyServers projects only Agones Ready GameServers into the durable
// allocator registry. Compatibility fields must be present as Fleet labels;
// malformed Ready objects fail closed instead of creating selectable capacity.
func (c Client) ListReadyServers(ctx context.Context) ([]domain.ReadyServer, error) {
if c.HTTP == nil {
c.HTTP = http.DefaultClient
}
base, err := c.endpoint()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/apis/agones.dev/v1/namespaces/"+url.PathEscape(c.Namespace)+"/gameservers", nil)
if err != nil {
return nil, err
}
response, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("Agones GameServer list returned %s", response.Status)
}
var decoded gameServerListResponse
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&decoded); err != nil {
return nil, fmt.Errorf("decode Agones GameServer list: %w", err)
}
ready := make([]domain.ReadyServer, 0, len(decoded.Items))
for _, item := range decoded.Items {
if item.Status.State != "Ready" {
continue
}
server, err := readyServerFromGameServer(item.Metadata.Name, item.Metadata.Labels)
if err != nil {
return nil, err
}
ready = append(ready, server)
}
return ready, nil
}
func readyServerFromGameServer(name string, labels map[string]string) (domain.ReadyServer, error) {
protocol, err := strconv.Atoi(labels["cosmic-clash.io/protocol"])
server := domain.ReadyServer{ServerID: name, Region: labels["cosmic-clash.io/region"], Build: labels["cosmic-clash.io/build"], Protocol: protocol, Transport: labels["cosmic-clash.io/transport"], State: domain.ServerReady}
if err != nil || server.ServerID == "" || (server.Region != "EU" && server.Region != "NA") || server.Build == "" || server.Protocol < 1 || (server.Transport != "enet" && server.Transport != "steam_sdr") {
return domain.ReadyServer{}, fmt.Errorf("invalid Ready GameServer compatibility labels")
}
return server, nil
}
func (c Client) Allocate(ctx context.Context, request domain.AllocationRequest, labels map[string]string, now time.Time) (AllocatedServer, error) {
if c.HTTP == nil {
c.HTTP = http.DefaultClient
}
base, err := c.endpoint()
if err != nil {
return AllocatedServer{}, err
}
if request.AllocationID == "" || request.MatchID == "" || (request.Region != "EU" && request.Region != "NA") || request.Build == "" || request.Protocol <= 0 || (request.Transport != "enet" && request.Transport != "steam_sdr") || now.IsZero() {
return AllocatedServer{}, domain.ErrAllocationInput
}
if len(labels) == 0 {
return AllocatedServer{}, fmt.Errorf("allocation labels are required")
}
for key, value := range labels {
if key == "" || value == "" || strings.ContainsAny(key+value, "\r\n") {
return AllocatedServer{}, fmt.Errorf("invalid allocation label")
}
}
var body allocationRequest
body.APIVersion = "allocation.agones.dev/v1"
body.Kind = "GameServerAllocation"
body.Spec.Selectors = []struct {
MatchLabels map[string]string `json:"matchLabels"`
}{{MatchLabels: cloneLabels(labels)}}
body.Spec.Metadata.Annotations = map[string]string{
"cosmic-clash.io/match-id": request.MatchID,
"cosmic-clash.io/allocation-id": request.AllocationID,
"cosmic-clash.io/region": request.Region,
"cosmic-clash.io/build": request.Build,
"cosmic-clash.io/protocol": strconv.Itoa(request.Protocol),
"cosmic-clash.io/transport": request.Transport,
}
if request.ArenaPath != "" {
body.Spec.Metadata.Annotations["cosmic-clash.io/arena-path"] = request.ArenaPath
}
if playlist := labels["cosmic-clash.io/playlist"]; playlist == string(domain.Casual) || playlist == string(domain.Ranked) {
body.Spec.Metadata.Annotations["cosmic-clash.io/playlist"] = playlist
}
if len(c.WorkloadSecret) > 0 {
ttl := c.WorkloadTokenTTL
if ttl <= 0 {
ttl = 30 * time.Minute
}
token, err := workload.IssueSignedWorkloadToken(c.WorkloadSecret, request.AllocationID, now, ttl)
if err != nil {
return AllocatedServer{}, fmt.Errorf("issue workload token: %w", err)
}
body.Spec.Metadata.Annotations["cosmic-clash.io/workload-token"] = token
}
encoded, err := json.Marshal(body)
if err != nil {
return AllocatedServer{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/apis/allocation.agones.dev/v1/namespaces/"+url.PathEscape(c.Namespace)+"/gameserverallocations", bytes.NewReader(encoded))
if err != nil {
return AllocatedServer{}, err
}
req.Header.Set("Content-Type", "application/json")
response, err := c.HTTP.Do(req)
if err != nil {
return AllocatedServer{}, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return AllocatedServer{}, fmt.Errorf("Agones allocation returned %s", response.Status)
}
var decoded allocationResponse
decoder := json.NewDecoder(io.LimitReader(response.Body, 64<<10))
if err := decoder.Decode(&decoded); err != nil {
return AllocatedServer{}, fmt.Errorf("decode Agones allocation: %w", err)
}
if decoded.Status.State != "Allocated" || decoded.Status.GameServerName == "" || decoded.Status.Address == "" {
return AllocatedServer{}, fmt.Errorf("Agones allocation is incomplete")
}
port, err := selectPort(decoded.Status.Ports)
if err != nil {
return AllocatedServer{}, err
}
return AllocatedServer{Allocation: domain.Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: decoded.Status.GameServerName, Region: request.Region, Build: request.Build, Protocol: request.Protocol, Transport: request.Transport, State: domain.ServerAllocated, AllocatedAt: now}, Endpoint: net.JoinHostPort(decoded.Status.Address, strconv.Itoa(port)), GameServer: decoded.Status.GameServerName}, nil
}
func (c Client) endpoint() (string, error) {
if c.Namespace == "" || strings.ContainsAny(c.Namespace, "/\r\n") {
return "", fmt.Errorf("invalid Agones namespace")
}
u, err := url.Parse(c.BaseURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
return "", fmt.Errorf("invalid Agones base URL")
}
return strings.TrimRight(c.BaseURL, "/"), nil
}
func selectPort(ports []struct {
Name string `json:"name"`
Port int `json:"port"`
}) (int, error) {
for _, port := range ports {
if port.Name == "default" {
if port.Port < 1 || port.Port > 65535 {
return 0, fmt.Errorf("Agones returned invalid default port")
}
return port.Port, nil
}
}
if len(ports) != 1 || ports[0].Port < 1 || ports[0].Port > 65535 {
return 0, fmt.Errorf("Agones returned no usable game port")
}
return ports[0].Port, nil
}
func cloneLabels(labels map[string]string) map[string]string {
copy := make(map[string]string, len(labels))
for key, value := range labels {
copy[key] = value
}
return copy
}