feat: game lobby service
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// Package gmclient provides the HTTP adapter for the ports.GMClient
|
||||
// surface. It implements the registration path
|
||||
// `POST /api/v1/internal/games/{game_id}/register-runtime` and the
|
||||
// liveness probe `GET /api/v1/internal/healthz` used by the voluntary
|
||||
// resume flow.
|
||||
//
|
||||
// Every transport-level failure (timeout, network error, non-2xx
|
||||
// response) is wrapped with ports.ErrGMUnavailable so callers can
|
||||
// detect the GM-unavailable case via errors.Is and follow the
|
||||
// `lobby.runtime_paused_after_start` branch or the
|
||||
// `service_unavailable` branch documented in the
|
||||
// README Game Start Flow.
|
||||
package gmclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
// Client implements ports.GMClient against the trusted internal HTTP
|
||||
// surface of Game Master.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Config groups the construction parameters of Client.
|
||||
type Config struct {
|
||||
// BaseURL is the absolute root URL of Game Master (no trailing
|
||||
// slash required).
|
||||
BaseURL string
|
||||
|
||||
// Timeout bounds one round trip including TLS handshake. It must
|
||||
// be positive.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable Client configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.BaseURL) == "":
|
||||
return errors.New("gm client base url must not be empty")
|
||||
case cfg.Timeout <= 0:
|
||||
return errors.New("gm client timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient constructs a Client from cfg. The transport is wrapped with
|
||||
// otelhttp.NewTransport so traces propagate to Game Master.
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new gm client: %w", err)
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// registerRuntimeBody mirrors the JSON body Lobby sends to Game Master.
|
||||
// The shape is owned by Lobby for the Game Master is expected to
|
||||
// accept it as-is when it implements the receiving handler.
|
||||
type registerRuntimeBody struct {
|
||||
ContainerID string `json:"container_id"`
|
||||
EngineEndpoint string `json:"engine_endpoint"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
}
|
||||
|
||||
// RegisterGame issues
|
||||
// POST /api/v1/internal/games/{game_id}/register-runtime against Game
|
||||
// Master. Any non-success outcome (validation error, transport error,
|
||||
// timeout, non-2xx response) is wrapped with ports.ErrGMUnavailable so
|
||||
// the caller can branch on errors.Is(err, ports.ErrGMUnavailable).
|
||||
func (client *Client) RegisterGame(ctx context.Context, request ports.RegisterGameRequest) error {
|
||||
if client == nil || client.httpClient == nil {
|
||||
return errors.New("register game: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("register game: nil context")
|
||||
}
|
||||
if err := request.Validate(); err != nil {
|
||||
return fmt.Errorf("register game: %w", err)
|
||||
}
|
||||
|
||||
endpoint := client.baseURL + "/api/v1/internal/games/" + url.PathEscape(request.GameID.String()) + "/register-runtime"
|
||||
body := registerRuntimeBody{
|
||||
ContainerID: request.ContainerID,
|
||||
EngineEndpoint: request.EngineEndpoint,
|
||||
TargetEngineVersion: request.TargetEngineVersion,
|
||||
TurnSchedule: request.TurnSchedule,
|
||||
}
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("register game: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return fmt.Errorf("register game: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("register game: %w", errors.Join(ports.ErrGMUnavailable, err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf(
|
||||
"register game: unexpected status %d: %w",
|
||||
resp.StatusCode, ports.ErrGMUnavailable,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping issues GET /api/v1/internal/healthz against Game Master. Any
|
||||
// non-success outcome (validation error, transport error, timeout,
|
||||
// non-2xx response) is wrapped with ports.ErrGMUnavailable so the
|
||||
// caller can branch on errors.Is(err, ports.ErrGMUnavailable). Stage
|
||||
// 16 voluntary resume uses this method as the liveness gate before
|
||||
// transitioning a paused game back to running.
|
||||
func (client *Client) Ping(ctx context.Context) error {
|
||||
if client == nil || client.httpClient == nil {
|
||||
return errors.New("ping: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("ping: nil context")
|
||||
}
|
||||
|
||||
endpoint := client.baseURL + "/api/v1/internal/healthz"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ping: %w", errors.Join(ports.ErrGMUnavailable, err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf(
|
||||
"ping: unexpected status %d: %w",
|
||||
resp.StatusCode, ports.ErrGMUnavailable,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.GMClient = (*Client)(nil)
|
||||
@@ -0,0 +1,177 @@
|
||||
package gmclient_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gmclient"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validRequest() ports.RegisterGameRequest {
|
||||
return ports.RegisterGameRequest{
|
||||
GameID: common.GameID("game-1"),
|
||||
ContainerID: "container-1",
|
||||
EngineEndpoint: "engine.local:9000",
|
||||
TargetEngineVersion: "v1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientValidatesConfig(t *testing.T) {
|
||||
_, err := gmclient.NewClient(gmclient.Config{Timeout: time.Second})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = gmclient.NewClient(gmclient.Config{BaseURL: "http://gm.local"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRegisterGameSendsExpectedRequest(t *testing.T) {
|
||||
var observed struct {
|
||||
method string
|
||||
path string
|
||||
contentType string
|
||||
body []byte
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observed.method = r.Method
|
||||
observed.path = r.URL.Path
|
||||
observed.contentType = r.Header.Get("Content-Type")
|
||||
observed.body, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, client.RegisterGame(context.Background(), validRequest()))
|
||||
|
||||
assert.Equal(t, http.MethodPost, observed.method)
|
||||
assert.Equal(t, "/api/v1/internal/games/game-1/register-runtime", observed.path)
|
||||
assert.Equal(t, "application/json", observed.contentType)
|
||||
|
||||
var decoded map[string]string
|
||||
require.NoError(t, json.Unmarshal(observed.body, &decoded))
|
||||
assert.Equal(t, "container-1", decoded["container_id"])
|
||||
assert.Equal(t, "engine.local:9000", decoded["engine_endpoint"])
|
||||
assert.Equal(t, "v1.2.3", decoded["target_engine_version"])
|
||||
assert.Equal(t, "0 18 * * *", decoded["turn_schedule"])
|
||||
}
|
||||
|
||||
func TestRegisterGameWrapsServerErrorWithUnavailable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.RegisterGame(context.Background(), validRequest())
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ports.ErrGMUnavailable)
|
||||
}
|
||||
|
||||
func TestRegisterGameWrapsTimeoutWithUnavailable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: 10 * time.Millisecond})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.RegisterGame(context.Background(), validRequest())
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ports.ErrGMUnavailable)
|
||||
}
|
||||
|
||||
func TestPingHitsExpectedEndpoint(t *testing.T) {
|
||||
var observed struct {
|
||||
method string
|
||||
path string
|
||||
accept string
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observed.method = r.Method
|
||||
observed.path = r.URL.Path
|
||||
observed.accept = r.Header.Get("Accept")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, client.Ping(context.Background()))
|
||||
|
||||
assert.Equal(t, http.MethodGet, observed.method)
|
||||
assert.Equal(t, "/api/v1/internal/healthz", observed.path)
|
||||
assert.Equal(t, "application/json", observed.accept)
|
||||
}
|
||||
|
||||
func TestPingWrapsServerErrorWithUnavailable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Ping(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ports.ErrGMUnavailable)
|
||||
}
|
||||
|
||||
func TestPingWrapsTimeoutWithUnavailable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: 10 * time.Millisecond})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Ping(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ports.ErrGMUnavailable)
|
||||
}
|
||||
|
||||
func TestRegisterGameValidatesRequest(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
bad := validRequest()
|
||||
bad.ContainerID = ""
|
||||
err = client.RegisterGame(context.Background(), bad)
|
||||
require.Error(t, err)
|
||||
|
||||
bad = validRequest()
|
||||
bad.GameID = common.GameID("bogus")
|
||||
err = client.RegisterGame(context.Background(), bad)
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user