// 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"` } `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, } 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 }