feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,225 @@
// Package rtmclient provides the trusted-internal Runtime Manager
// REST client Game Master uses for synchronous lifecycle operations
// against an already-running container. Two routes are mounted:
//
// - POST /api/v1/internal/runtimes/{game_id}/stop
// - POST /api/v1/internal/runtimes/{game_id}/patch
//
// `Restart` is reserved per `gamemaster/PLAN.md` Stage 10 and is not
// part of the v1 surface.
package rtmclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"galaxy/gamemaster/internal/ports"
)
const (
stopPathTemplate = "/api/v1/internal/runtimes/%s/stop"
patchPathTemplate = "/api/v1/internal/runtimes/%s/patch"
)
// Config configures one HTTP-backed Runtime Manager internal client.
type Config struct {
// BaseURL stores the absolute base URL of the Runtime Manager
// internal HTTP listener (e.g. `http://rtmanager:8096`).
BaseURL string
// RequestTimeout bounds one outbound stop/patch request.
RequestTimeout time.Duration
}
// Client speaks REST/JSON to the Runtime Manager internal API.
type Client struct {
baseURL string
requestTimeout time.Duration
httpClient *http.Client
closeIdleConnections func()
}
type stopRequestEnvelope struct {
Reason string `json:"reason"`
}
type patchRequestEnvelope struct {
ImageRef string `json:"image_ref"`
}
type errorEnvelope struct {
Error *errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// NewClient constructs an RTM internal client with otelhttp-wrapped
// transport cloned from `http.DefaultTransport`. Call `Close` to
// release idle connections at shutdown.
func NewClient(cfg Config) (*Client, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("new rtm client: default transport is not *http.Transport")
}
cloned := transport.Clone()
return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections)
}
func newClient(cfg Config, httpClient *http.Client, closeIdleConnections func()) (*Client, error) {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return nil, errors.New("new rtm client: base url must not be empty")
case cfg.RequestTimeout <= 0:
return nil, errors.New("new rtm client: request timeout must be positive")
case httpClient == nil:
return nil, errors.New("new rtm client: http client must not be nil")
}
parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"))
if err != nil {
return nil, fmt.Errorf("new rtm client: parse base url: %w", err)
}
if parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("new rtm client: base url must be absolute")
}
return &Client{
baseURL: parsed.String(),
requestTimeout: cfg.RequestTimeout,
httpClient: httpClient,
closeIdleConnections: closeIdleConnections,
}, nil
}
// Close releases idle HTTP connections owned by the underlying
// transport. Safe to call multiple times.
func (client *Client) Close() error {
if client == nil || client.closeIdleConnections == nil {
return nil
}
client.closeIdleConnections()
return nil
}
// Stop calls POST /api/v1/internal/runtimes/{game_id}/stop with body
// `{reason}`. Any non-success outcome is wrapped with
// `ports.ErrRTMUnavailable`.
func (client *Client) Stop(ctx context.Context, gameID, reason string) error {
if err := client.validate(ctx, gameID); err != nil {
return err
}
if strings.TrimSpace(reason) == "" {
return errors.New("rtm stop: reason must not be empty")
}
body, err := json.Marshal(stopRequestEnvelope{Reason: reason})
if err != nil {
return fmt.Errorf("rtm stop: encode request: %w", err)
}
return client.callMutation(ctx, fmt.Sprintf(stopPathTemplate, url.PathEscape(gameID)), body, "rtm stop")
}
// Patch calls POST /api/v1/internal/runtimes/{game_id}/patch with body
// `{image_ref}`. A `409 conflict` from RTM (semver violation) is also
// wrapped with `ports.ErrRTMUnavailable`; the underlying `error_code`
// is preserved in the wrapped error message so callers can branch on
// the substring if needed.
func (client *Client) Patch(ctx context.Context, gameID, imageRef string) error {
if err := client.validate(ctx, gameID); err != nil {
return err
}
if strings.TrimSpace(imageRef) == "" {
return errors.New("rtm patch: image ref must not be empty")
}
body, err := json.Marshal(patchRequestEnvelope{ImageRef: imageRef})
if err != nil {
return fmt.Errorf("rtm patch: encode request: %w", err)
}
return client.callMutation(ctx, fmt.Sprintf(patchPathTemplate, url.PathEscape(gameID)), body, "rtm patch")
}
func (client *Client) validate(ctx context.Context, gameID string) error {
if client == nil || client.httpClient == nil {
return errors.New("rtm client: nil client")
}
if ctx == nil {
return errors.New("rtm client: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
if strings.TrimSpace(gameID) == "" {
return errors.New("rtm client: game id must not be empty")
}
return nil
}
func (client *Client) callMutation(ctx context.Context, requestPath string, body []byte, opLabel string) error {
payload, statusCode, err := client.doRequest(ctx, http.MethodPost, requestPath, body)
if err != nil {
return fmt.Errorf("%w: %s: %w", ports.ErrRTMUnavailable, opLabel, err)
}
if statusCode >= 200 && statusCode < 300 {
return nil
}
errorCode := decodeErrorCode(payload)
if errorCode != "" {
return fmt.Errorf("%w: %s: unexpected status %d (error_code=%s)", ports.ErrRTMUnavailable, opLabel, statusCode, errorCode)
}
return fmt.Errorf("%w: %s: unexpected status %d", ports.ErrRTMUnavailable, opLabel, statusCode)
}
func (client *Client) doRequest(ctx context.Context, method, requestPath string, body []byte) ([]byte, int, error) {
attemptCtx, cancel := context.WithTimeout(ctx, client.requestTimeout)
defer cancel()
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(attemptCtx, method, client.baseURL+requestPath, reader)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("read response body: %w", err)
}
return respBody, resp.StatusCode, nil
}
func decodeErrorCode(payload []byte) string {
if len(payload) == 0 {
return ""
}
var envelope errorEnvelope
if err := json.Unmarshal(payload, &envelope); err != nil {
return ""
}
if envelope.Error == nil {
return ""
}
return envelope.Error.Code
}
// Compile-time assertion: Client implements ports.RTMClient.
var _ ports.RTMClient = (*Client)(nil)
@@ -0,0 +1,156 @@
package rtmclient
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"galaxy/gamemaster/internal/ports"
)
func newTestClient(t *testing.T, baseURL string, timeout time.Duration) *Client {
t.Helper()
client, err := NewClient(Config{BaseURL: baseURL, RequestTimeout: timeout})
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
return client
}
func TestNewClientValidatesConfig(t *testing.T) {
cases := map[string]Config{
"empty base url": {BaseURL: "", RequestTimeout: time.Second},
"non-absolute": {BaseURL: "rtm:8096", RequestTimeout: time.Second},
"zero timeout": {BaseURL: "http://rtm:8096", RequestTimeout: 0},
"negative timeout": {BaseURL: "http://rtm:8096", RequestTimeout: -time.Second},
}
for name, cfg := range cases {
t.Run(name, func(t *testing.T) {
_, err := NewClient(cfg)
require.Error(t, err)
})
}
}
func TestStopHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/internal/runtimes/game-1/stop", r.URL.Path)
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
var got stopRequestEnvelope
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "admin_request", got.Reason)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"game_id":"game-1","status":"stopped"}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
require.NoError(t, client.Stop(context.Background(), "game-1", "admin_request"))
}
func TestStopRejectsBadInput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact rtm on bad input")
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
require.Error(t, client.Stop(context.Background(), " ", "admin_request"))
require.Error(t, client.Stop(context.Background(), "g", " "))
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := client.Stop(ctx, "g", "admin_request")
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
}
func TestStopInternalError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"boom"}}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
err := client.Stop(context.Background(), "g", "admin_request")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrRTMUnavailable))
assert.Contains(t, err.Error(), "internal_error")
}
func TestStopTimeoutMapsToUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(120 * time.Millisecond)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, 30*time.Millisecond)
err := client.Stop(context.Background(), "g", "admin_request")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrRTMUnavailable))
}
func TestPatchHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/internal/runtimes/g/patch", r.URL.Path)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
var got patchRequestEnvelope
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "galaxy/game:1.2.4", got.ImageRef)
_, _ = w.Write([]byte(`{"game_id":"g","status":"running"}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
require.NoError(t, client.Patch(context.Background(), "g", "galaxy/game:1.2.4"))
}
func TestPatchSemverConflictMapsToUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":{"code":"semver_patch_only","message":"cross-major patch not allowed"}}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
err := client.Patch(context.Background(), "g", "galaxy/game:2.0.0")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrRTMUnavailable))
assert.Contains(t, err.Error(), "semver_patch_only")
}
func TestPatchRejectsBadInput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact rtm on bad input")
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
require.Error(t, client.Patch(context.Background(), " ", "galaxy/game:1.0.0"))
require.Error(t, client.Patch(context.Background(), "g", " "))
}
func TestCloseIsIdempotent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
require.NoError(t, client.Stop(context.Background(), "g", "admin_request"))
require.NoError(t, client.Close())
require.NoError(t, client.Close())
}