mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 20:33:44 +00:00
fd18cf6ac0
Investigated the Fleet-manifest wiring task flagged last commit and found a deeper, previously-undesigned gap: Kubernetes env vars are fixed at pod creation, but Agones allocates a match to an already- running Ready pod well after it starts -- so there was no channel at all for match-specific data (match ID) to reach that pod's processes. Close it using the Agones GameServerAllocation API's documented spec.metadata.annotations field, which Agones applies to the allocated GameServer's own object_meta on success: server/agones.Client.Allocate now requests cosmic-clash.io/match-id and cosmic-clash.io/allocation-id annotations, and the supervisor reads them back from the same /gameserver SDK call it already makes for the assigned port/address (GameServer.ObjectMeta.Annotations), falling back to them for its own control-plane registration only when MatchID isn't explicitly configured -- an explicit value always wins, and a match ID resolvable from neither source fails Start() closed before any HTTP call. The exact object_meta vs objectMeta JSON key from a live Agones SDK sidecar is not independently verified from this sandbox; documented inline, and the fallback degrades safely (empty annotations map, same as before this change) if it turns out to be wrong. Covered by two new tests: the annotation actually flowing through to the registration body, and fail-closed with neither config nor annotation supplying a match ID (registerCalled stays false, not just that Start() errors).
226 lines
8.0 KiB
Go
226 lines
8.0 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"
|
|
)
|
|
|
|
type Client struct {
|
|
BaseURL string
|
|
Namespace string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
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"`
|
|
} `json:"metadata"`
|
|
Status struct {
|
|
State string `json:"state"`
|
|
} `json:"status"`
|
|
} `json:"items"`
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
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
|
|
}
|