feat: add Agones allocation client

This commit is contained in:
Josh Creek
2026-09-01 09:49:09 +01:00
parent 4bbaf0976f
commit 931e51a647
4 changed files with 223 additions and 2 deletions
+147
View File
@@ -0,0 +1,147 @@
// 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"`
} `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"`
}
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)}}
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
}
+72
View File
@@ -0,0 +1,72 @@
package agones
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
func request() domain.AllocationRequest {
return domain.AllocationRequest{AllocationID: "allocation-1", MatchID: "match-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}
}
func TestAllocateBuildsStrictGameServerAllocationAndEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/apis/allocation.agones.dev/v1/namespaces/games/gameserverallocations" {
t.Fatalf("request=%s %s", r.Method, r.URL.Path)
}
var body allocationRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.APIVersion != "allocation.agones.dev/v1" || body.Kind != "GameServerAllocation" || len(body.Spec.Selectors) != 1 || body.Spec.Selectors[0].MatchLabels["cosmic-clash/region"] != "EU" {
t.Fatalf("body=%+v", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":{"state":"Allocated","gameServerName":"gs-a","address":"2001:db8::1","ports":[{"name":"default","port":7777}]}}`))
}))
defer server.Close()
got, err := (Client{BaseURL: server.URL, Namespace: "games", HTTP: server.Client()}).Allocate(context.Background(), request(), map[string]string{"cosmic-clash/region": "EU", "cosmic-clash/build": "build-1"}, time.Unix(1000, 0))
if err != nil {
t.Fatal(err)
}
if got.GameServer != "gs-a" || got.Endpoint != "[2001:db8::1]:7777" || got.Allocation.State != domain.ServerAllocated {
t.Fatalf("allocation=%+v", got)
}
}
func TestAllocateFailsClosedOnMalformedProviderResponses(t *testing.T) {
cases := []string{
`{"status":{"state":"UnAllocated","gameServerName":"gs","address":"127.0.0.1","ports":[{"name":"default","port":7777}]}}`,
`{"status":{"state":"Allocated","gameServerName":"gs","address":"127.0.0.1","ports":[]}}`,
`{"status":{"state":"Allocated","gameServerName":"gs","address":"127.0.0.1","ports":[{"name":"default","port":70000}]}}`,
}
for _, payload := range cases {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(payload)) }))
_, err := (Client{BaseURL: server.URL, Namespace: "games", HTTP: server.Client()}).Allocate(context.Background(), request(), map[string]string{"region": "EU"}, time.Unix(1000, 0))
server.Close()
if err == nil {
t.Fatalf("malformed response accepted: %s", payload)
}
}
}
func TestAllocateRejectsUnsafeConfigurationAndProviderFailure(t *testing.T) {
for _, client := range []Client{{BaseURL: "http://127.0.0.1:1/path", Namespace: "games"}, {BaseURL: "http://127.0.0.1:1", Namespace: "games/other"}} {
if _, err := client.Allocate(context.Background(), request(), map[string]string{"region": "EU"}, time.Unix(1000, 0)); err == nil {
t.Fatal("unsafe client configuration accepted")
}
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "no capacity", http.StatusConflict) }))
defer server.Close()
_, err := (Client{BaseURL: server.URL, Namespace: "games", HTTP: server.Client()}).Allocate(context.Background(), request(), map[string]string{"region": "EU"}, time.Unix(1000, 0))
if err == nil || !strings.Contains(err.Error(), "409") {
t.Fatalf("provider failure err=%v", err)
}
}