feat: gamemaster
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user