175 lines
5.4 KiB
Go
175 lines
5.4 KiB
Go
// 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)
|