feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+328
View File
@@ -0,0 +1,328 @@
package engineclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"galaxy/model/rest"
)
const (
pathAdminInit = "/api/v1/admin/init"
pathAdminStatus = "/api/v1/admin/status"
pathAdminTurn = "/api/v1/admin/turn"
pathAdminRaceBanish = "/api/v1/admin/race/banish"
pathPlayerCommand = "/api/v1/command"
pathPlayerOrder = "/api/v1/order"
pathPlayerReport = "/api/v1/report"
pathHealthz = "/healthz"
)
// Config configures one HTTP-backed engine client.
type Config struct {
// CallTimeout bounds turn-generation-class operations: init, turn,
// banish, command, order. Mirrors `BACKEND_ENGINE_CALL_TIMEOUT`.
CallTimeout time.Duration
// ProbeTimeout bounds inspect-style reads: status, report, healthz.
// Mirrors `BACKEND_ENGINE_PROBE_TIMEOUT`.
ProbeTimeout time.Duration
}
// Client is the engine HTTP client. The zero value is not usable — use
// NewClient.
type Client struct {
callTimeout time.Duration
probeTimeout time.Duration
httpClient *http.Client
closeIdleConnections func()
}
// NewClient constructs a Client with an `otelhttp`-instrumented
// transport cloned from `http.DefaultTransport`. Close releases idle
// connections owned by the cloned transport.
func NewClient(cfg Config) (*Client, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("engineclient: default transport is not *http.Transport")
}
cloned := transport.Clone()
return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections)
}
// NewClientWithHTTP constructs a Client around a caller-supplied
// `*http.Client`. Used in tests to inject `httptest`-backed transports.
func NewClientWithHTTP(cfg Config, hc *http.Client) (*Client, error) {
return newClient(cfg, hc, nil)
}
func newClient(cfg Config, hc *http.Client, closeIdle func()) (*Client, error) {
switch {
case cfg.CallTimeout <= 0:
return nil, errors.New("engineclient: call timeout must be positive")
case cfg.ProbeTimeout <= 0:
return nil, errors.New("engineclient: probe timeout must be positive")
case hc == nil:
return nil, errors.New("engineclient: http client must not be nil")
}
return &Client{
callTimeout: cfg.CallTimeout,
probeTimeout: cfg.ProbeTimeout,
httpClient: hc,
closeIdleConnections: closeIdle,
}, nil
}
// Close releases idle HTTP connections owned by the underlying
// transport. Safe to call multiple times.
func (c *Client) Close() error {
if c == nil || c.closeIdleConnections == nil {
return nil
}
c.closeIdleConnections()
return nil
}
// Init calls `POST /api/v1/admin/init`.
func (c *Client) Init(ctx context.Context, baseURL string, request rest.InitRequest) (rest.StateResponse, error) {
if err := validateBaseURL(baseURL); err != nil {
return rest.StateResponse{}, err
}
body, err := json.Marshal(request)
if err != nil {
return rest.StateResponse{}, fmt.Errorf("engineclient init: encode request: %w", err)
}
payload, status, doErr := c.doRequest(ctx, http.MethodPost, baseURL+pathAdminInit, body, c.callTimeout)
if doErr != nil {
return rest.StateResponse{}, fmt.Errorf("%w: engine init: %w", ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK, http.StatusCreated:
return decodeStateResponse(payload, "engine init")
case http.StatusBadRequest:
return rest.StateResponse{}, fmt.Errorf("%w: engine init: %s", ErrEngineValidation, summariseEngineError(payload, status))
default:
return rest.StateResponse{}, fmt.Errorf("%w: engine init: %s", ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// Status calls `GET /api/v1/admin/status`.
func (c *Client) Status(ctx context.Context, baseURL string) (rest.StateResponse, error) {
if err := validateBaseURL(baseURL); err != nil {
return rest.StateResponse{}, err
}
payload, status, doErr := c.doRequest(ctx, http.MethodGet, baseURL+pathAdminStatus, nil, c.probeTimeout)
if doErr != nil {
return rest.StateResponse{}, fmt.Errorf("%w: engine status: %w", ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK:
return decodeStateResponse(payload, "engine status")
case http.StatusBadRequest:
return rest.StateResponse{}, fmt.Errorf("%w: engine status: %s", ErrEngineValidation, summariseEngineError(payload, status))
default:
return rest.StateResponse{}, fmt.Errorf("%w: engine status: %s", ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// Turn calls `PUT /api/v1/admin/turn`.
func (c *Client) Turn(ctx context.Context, baseURL string) (rest.StateResponse, error) {
if err := validateBaseURL(baseURL); err != nil {
return rest.StateResponse{}, err
}
payload, status, doErr := c.doRequest(ctx, http.MethodPut, baseURL+pathAdminTurn, nil, c.callTimeout)
if doErr != nil {
return rest.StateResponse{}, fmt.Errorf("%w: engine turn: %w", ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK:
return decodeStateResponse(payload, "engine turn")
case http.StatusBadRequest:
return rest.StateResponse{}, fmt.Errorf("%w: engine turn: %s", ErrEngineValidation, summariseEngineError(payload, status))
default:
return rest.StateResponse{}, fmt.Errorf("%w: engine turn: %s", ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// BanishRace calls `POST /api/v1/admin/race/banish` with body
// `{race_name}`. Engine returns 204 on success.
func (c *Client) BanishRace(ctx context.Context, baseURL, raceName string) error {
if err := validateBaseURL(baseURL); err != nil {
return err
}
if strings.TrimSpace(raceName) == "" {
return errors.New("engineclient banish: race name must not be empty")
}
body, err := json.Marshal(rest.BanishRequest{RaceName: raceName})
if err != nil {
return fmt.Errorf("engineclient banish: encode: %w", err)
}
payload, status, doErr := c.doRequest(ctx, http.MethodPost, baseURL+pathAdminRaceBanish, body, c.callTimeout)
if doErr != nil {
return fmt.Errorf("%w: engine banish: %w", ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusNoContent, http.StatusOK:
return nil
case http.StatusBadRequest:
return fmt.Errorf("%w: engine banish: %s", ErrEngineValidation, summariseEngineError(payload, status))
default:
return fmt.Errorf("%w: engine banish: %s", ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// ExecuteCommands calls `PUT /api/v1/command` with payload forwarded
// verbatim. The engine response body is returned verbatim; on 4xx the
// body is returned alongside ErrEngineValidation so callers can
// forward the per-command error.
func (c *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command")
}
// PutOrders calls `PUT /api/v1/order` with the same forwarding
// semantics as ExecuteCommands.
func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
}
// GetReport calls `GET /api/v1/report?player=<raceName>&turn=<turn>`
// and returns the engine response body verbatim.
func (c *Client) GetReport(ctx context.Context, baseURL, raceName string, turn int) (json.RawMessage, error) {
if err := validateBaseURL(baseURL); err != nil {
return nil, err
}
if strings.TrimSpace(raceName) == "" {
return nil, errors.New("engineclient report: race name must not be empty")
}
if turn < 0 {
return nil, fmt.Errorf("engineclient report: turn must not be negative, got %d", turn)
}
values := url.Values{}
values.Set("player", raceName)
values.Set("turn", strconv.Itoa(turn))
target := baseURL + pathPlayerReport + "?" + values.Encode()
body, status, doErr := c.doRequest(ctx, http.MethodGet, target, nil, c.probeTimeout)
if doErr != nil {
return nil, fmt.Errorf("%w: engine report: %w", ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK:
if len(body) == 0 {
return nil, fmt.Errorf("%w: engine report: empty response body", ErrEngineProtocolViolation)
}
return json.RawMessage(body), nil
case http.StatusBadRequest:
return json.RawMessage(body), fmt.Errorf("%w: engine report: %s", ErrEngineValidation, summariseEngineError(body, status))
default:
return nil, fmt.Errorf("%w: engine report: %s", ErrEngineUnreachable, summariseEngineError(body, status))
}
}
// Healthz calls `GET /healthz`. Returns nil on 2xx.
func (c *Client) Healthz(ctx context.Context, baseURL string) error {
if err := validateBaseURL(baseURL); err != nil {
return err
}
body, status, doErr := c.doRequest(ctx, http.MethodGet, baseURL+pathHealthz, nil, c.probeTimeout)
if doErr != nil {
return fmt.Errorf("%w: engine healthz: %w", ErrEngineUnreachable, doErr)
}
if status/100 == 2 {
return nil
}
return fmt.Errorf("%w: engine healthz: %s", ErrEngineUnreachable, summariseEngineError(body, status))
}
func (c *Client) forwardPlayerWrite(ctx context.Context, baseURL, requestPath string, payload json.RawMessage, opLabel string) (json.RawMessage, error) {
if err := validateBaseURL(baseURL); err != nil {
return nil, err
}
if len(bytes.TrimSpace(payload)) == 0 {
return nil, fmt.Errorf("%s: payload must not be empty", opLabel)
}
body, status, doErr := c.doRequest(ctx, http.MethodPut, baseURL+requestPath, []byte(payload), c.callTimeout)
if doErr != nil {
return nil, fmt.Errorf("%w: %s: %w", ErrEngineUnreachable, opLabel, doErr)
}
switch status {
case http.StatusOK, http.StatusAccepted:
return json.RawMessage(body), nil
case http.StatusBadRequest, http.StatusConflict:
return json.RawMessage(body), fmt.Errorf("%w: %s: %s", ErrEngineValidation, opLabel, summariseEngineError(body, status))
default:
return nil, fmt.Errorf("%w: %s: %s", ErrEngineUnreachable, opLabel, summariseEngineError(body, status))
}
}
func (c *Client) doRequest(ctx context.Context, method, target string, body []byte, timeout time.Duration) ([]byte, int, error) {
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(reqCtx, method, target, reader)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer func() { _ = resp.Body.Close() }()
payload, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("read body: %w", err)
}
return payload, resp.StatusCode, nil
}
func validateBaseURL(baseURL string) error {
if strings.TrimSpace(baseURL) == "" {
return errors.New("engineclient: baseURL must not be empty")
}
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return fmt.Errorf("engineclient: baseURL %q must start with http:// or https://", baseURL)
}
return nil
}
func decodeStateResponse(body []byte, op string) (rest.StateResponse, error) {
if len(bytes.TrimSpace(body)) == 0 {
return rest.StateResponse{}, fmt.Errorf("%w: %s: empty body", ErrEngineProtocolViolation, op)
}
var out rest.StateResponse
if err := json.Unmarshal(body, &out); err != nil {
return rest.StateResponse{}, fmt.Errorf("%w: %s: %v", ErrEngineProtocolViolation, op, err)
}
return out, nil
}
func summariseEngineError(body []byte, status int) string {
if len(body) == 0 {
return fmt.Sprintf("status=%d", status)
}
trimmed := strings.TrimSpace(string(body))
if len(trimmed) > 256 {
trimmed = trimmed[:256] + "…"
}
return fmt.Sprintf("status=%d body=%s", status, trimmed)
}
@@ -0,0 +1,236 @@
package engineclient
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"galaxy/model/rest"
"github.com/google/uuid"
)
func newTestClient(t *testing.T, srv *httptest.Server) *Client {
t.Helper()
cli, err := NewClientWithHTTP(Config{CallTimeout: 2 * time.Second, ProbeTimeout: 1 * time.Second}, srv.Client())
if err != nil {
t.Fatalf("NewClientWithHTTP: %v", err)
}
return cli
}
func TestClientInitSuccess(t *testing.T) {
wantID := uuid.New()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathAdminInit {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Fatalf("unexpected method: %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "alpha"}}})
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
if got.ID != wantID {
t.Fatalf("ID = %s, want %s", got.ID, wantID)
}
if got.Turn != 1 {
t.Fatalf("Turn = %d, want 1", got.Turn)
}
}
func TestClientInitValidationError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"reason":"races empty"}`, http.StatusBadRequest)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
_, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "x"}}})
if !errors.Is(err, ErrEngineValidation) {
t.Fatalf("expected ErrEngineValidation, got %v", err)
}
}
func TestClientInitUnreachableOn5xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
_, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "x"}}})
if !errors.Is(err, ErrEngineUnreachable) {
t.Fatalf("expected ErrEngineUnreachable, got %v", err)
}
}
func TestClientInitProtocolViolation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("not-json"))
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
_, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "x"}}})
if !errors.Is(err, ErrEngineProtocolViolation) {
t.Fatalf("expected ErrEngineProtocolViolation, got %v", err)
}
}
func TestClientStatusOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathAdminStatus || r.Method != http.MethodGet {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_ = json.NewEncoder(w).Encode(rest.StateResponse{Turn: 5})
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
got, err := cli.Status(context.Background(), srv.URL)
if err != nil {
t.Fatalf("Status: %v", err)
}
if got.Turn != 5 {
t.Fatalf("Turn = %d, want 5", got.Turn)
}
}
func TestClientTurnOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathAdminTurn || r.Method != http.MethodPut {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_ = json.NewEncoder(w).Encode(rest.StateResponse{Turn: 6, Finished: true})
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
got, err := cli.Turn(context.Background(), srv.URL)
if err != nil {
t.Fatalf("Turn: %v", err)
}
if !got.Finished {
t.Fatalf("expected finished=true")
}
}
func TestClientBanishRace(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathAdminRaceBanish || r.Method != http.MethodPost {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var got rest.BanishRequest
_ = json.NewDecoder(r.Body).Decode(&got)
if got.RaceName != "loser" {
t.Fatalf("got race name %q", got.RaceName)
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
if err := cli.BanishRace(context.Background(), srv.URL, "loser"); err != nil {
t.Fatalf("BanishRace: %v", err)
}
}
func TestClientCommandsForwardsBody(t *testing.T) {
want := json.RawMessage(`{"actor":"alpha","cmd":[{"@type":"raceQuit"}]}`)
gotResp := json.RawMessage(`{"applied":true}`)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathPlayerCommand || r.Method != http.MethodPut {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_, _ = w.Write(gotResp)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
resp, err := cli.ExecuteCommands(context.Background(), srv.URL, want)
if err != nil {
t.Fatalf("ExecuteCommands: %v", err)
}
if string(resp) != string(gotResp) {
t.Fatalf("response = %s, want %s", string(resp), string(gotResp))
}
}
func TestClientReportsForwardsQuery(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathPlayerReport {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("player") != "alpha" {
t.Fatalf("player = %q", r.URL.Query().Get("player"))
}
if r.URL.Query().Get("turn") != "3" {
t.Fatalf("turn = %q", r.URL.Query().Get("turn"))
}
_, _ = w.Write([]byte(`{"turn":3}`))
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
body, err := cli.GetReport(context.Background(), srv.URL, "alpha", 3)
if err != nil {
t.Fatalf("GetReport: %v", err)
}
if !strings.Contains(string(body), `"turn":3`) {
t.Fatalf("body = %s", body)
}
}
func TestClientHealthzSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathHealthz {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"status":"ok"}`))
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
if err := cli.Healthz(context.Background(), srv.URL); err != nil {
t.Fatalf("Healthz: %v", err)
}
}
func TestClientHealthzFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "down", http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
if err := cli.Healthz(context.Background(), srv.URL); !errors.Is(err, ErrEngineUnreachable) {
t.Fatalf("expected ErrEngineUnreachable, got %v", err)
}
}
func TestClientRejectsInvalidBaseURL(t *testing.T) {
cli, err := NewClientWithHTTP(Config{CallTimeout: time.Second, ProbeTimeout: time.Second}, http.DefaultClient)
if err != nil {
t.Fatalf("NewClientWithHTTP: %v", err)
}
if _, err := cli.Status(context.Background(), ""); err == nil {
t.Fatalf("expected error on empty base URL")
}
if _, err := cli.Status(context.Background(), "ftp://example.test"); err == nil {
t.Fatalf("expected error on non-http base URL")
}
}
+43
View File
@@ -0,0 +1,43 @@
// Package engineclient is the trusted-internal HTTP client `internal/runtime`
// uses to talk to a running `galaxy-game` engine container. The engine
// contract is the OpenAPI document shipped with the engine module
// (`galaxy/game/openapi.yaml`); this package reuses the existing typed
// DTOs in `pkg/model/{rest,order,report}` rather than introducing its
// own request/response types.
//
// The engine endpoint URL is per-call: the runtime stores it on
// `runtime_records.engine_endpoint` (the value the dockerclient adapter
// returns from Run). The client therefore does not bind a base URL at
// construction time — only the per-call timeouts are wired through
// `Config`.
//
// Error model:
//
// - ErrEngineUnreachable — network failure, 5xx, or timeout. The
// caller transitions the runtime record to `engine_unreachable`
// and re-tries on the next snapshot tick.
// - ErrEngineValidation — engine rejected the request (HTTP 4xx).
// The caller surfaces the engine's body verbatim through to the
// user.
// - ErrEngineProtocolViolation — engine returned an empty body or a
// malformed JSON response on a path that requires one.
package engineclient
import "errors"
var (
// ErrEngineUnreachable means the engine call failed because of a
// transport error (network, DNS, connect refused, timeout, 5xx).
// The implementation callers map this to a runtime status of
// `engine_unreachable` after a snapshot read.
ErrEngineUnreachable = errors.New("engineclient: engine unreachable")
// ErrEngineValidation means the engine returned a 4xx response.
// Callers forward the engine body so end users see the engine's
// per-command error reason verbatim.
ErrEngineValidation = errors.New("engineclient: engine validation failed")
// ErrEngineProtocolViolation means the engine returned an empty or
// malformed body on a path that contractually requires one.
ErrEngineProtocolViolation = errors.New("engineclient: engine protocol violation")
)