feat: runtime manager

This commit is contained in:
Ilia Denisov
2026-04-28 20:39:18 +02:00
committed by GitHub
parent e0a99b346b
commit a7cee15115
289 changed files with 45660 additions and 2207 deletions
@@ -0,0 +1,219 @@
// Package lobbyclient provides the trusted-internal Lobby REST client
// Runtime Manager uses to fetch ancillary game metadata for diagnostics.
//
// The client is intentionally minimal: the GetGame fetch is ancillary
// diagnostics because the start envelope already carries the only
// required field (`image_ref`). A failed call surfaces as
// `ports.ErrLobbyUnavailable` so callers can distinguish "not found"
// from transport faults and continue without aborting the start
// operation.
package lobbyclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"galaxy/rtmanager/internal/ports"
)
const (
getGamePathSuffix = "/api/v1/internal/games/%s"
)
// Config configures one HTTP-backed Lobby internal client.
type Config struct {
// BaseURL stores the absolute base URL of the Lobby internal HTTP
// listener (e.g. `http://lobby:8095`).
BaseURL string
// RequestTimeout bounds one outbound lookup request.
RequestTimeout time.Duration
}
// Client resolves Lobby game records through the trusted internal HTTP
// API.
type Client struct {
baseURL string
requestTimeout time.Duration
httpClient *http.Client
closeIdleConnections func()
}
type gameRecordEnvelope struct {
GameID string `json:"game_id"`
Status string `json:"status"`
TargetEngineVersion string `json:"target_engine_version"`
}
type errorEnvelope struct {
Error *errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// NewClient constructs a Lobby internal client that uses
// repository-standard HTTP transport instrumentation through otelhttp.
// The cloned default transport keeps the production wiring isolated
// from caller-provided transports.
func NewClient(cfg Config) (*Client, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("new lobby internal 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 lobby internal client: base URL must not be empty")
case cfg.RequestTimeout <= 0:
return nil, errors.New("new lobby internal client: request timeout must be positive")
case httpClient == nil:
return nil, errors.New("new lobby internal 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 lobby internal client: parse base URL: %w", err)
}
if parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("new lobby internal 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 client transport.
// Call once on shutdown.
func (client *Client) Close() error {
if client == nil || client.closeIdleConnections == nil {
return nil
}
client.closeIdleConnections()
return nil
}
// GetGame returns the Lobby game record for gameID. It maps Lobby's
// `404 not_found` to `ports.ErrLobbyGameNotFound`; every other failure
// (transport, timeout, non-2xx response) maps to
// `ports.ErrLobbyUnavailable` wrapped with the original error so callers
// keep the diagnostic detail.
func (client *Client) GetGame(ctx context.Context, gameID string) (ports.LobbyGameRecord, error) {
if client == nil || client.httpClient == nil {
return ports.LobbyGameRecord{}, errors.New("lobby get game: nil client")
}
if ctx == nil {
return ports.LobbyGameRecord{}, errors.New("lobby get game: nil context")
}
if err := ctx.Err(); err != nil {
return ports.LobbyGameRecord{}, err
}
if strings.TrimSpace(gameID) == "" {
return ports.LobbyGameRecord{}, errors.New("lobby get game: game id must not be empty")
}
payload, statusCode, err := client.doRequest(ctx, http.MethodGet, fmt.Sprintf(getGamePathSuffix, url.PathEscape(gameID)))
if err != nil {
return ports.LobbyGameRecord{}, fmt.Errorf("%w: %w", ports.ErrLobbyUnavailable, err)
}
switch statusCode {
case http.StatusOK:
var envelope gameRecordEnvelope
if err := decodeJSONPayload(payload, &envelope); err != nil {
return ports.LobbyGameRecord{}, fmt.Errorf("%w: decode success response: %w", ports.ErrLobbyUnavailable, err)
}
if strings.TrimSpace(envelope.GameID) == "" {
return ports.LobbyGameRecord{}, fmt.Errorf("%w: success response missing game_id", ports.ErrLobbyUnavailable)
}
return ports.LobbyGameRecord{
GameID: envelope.GameID,
Status: envelope.Status,
TargetEngineVersion: envelope.TargetEngineVersion,
}, nil
case http.StatusNotFound:
return ports.LobbyGameRecord{}, ports.ErrLobbyGameNotFound
default:
errorCode := decodeErrorCode(payload)
if errorCode != "" {
return ports.LobbyGameRecord{}, fmt.Errorf("%w: unexpected status %d (error_code=%s)", ports.ErrLobbyUnavailable, statusCode, errorCode)
}
return ports.LobbyGameRecord{}, fmt.Errorf("%w: unexpected status %d", ports.ErrLobbyUnavailable, statusCode)
}
}
func (client *Client) doRequest(ctx context.Context, method, requestPath string) ([]byte, int, error) {
attemptCtx, cancel := context.WithTimeout(ctx, client.requestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(attemptCtx, method, client.baseURL+requestPath, nil)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := client.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, fmt.Errorf("read response body: %w", err)
}
return body, resp.StatusCode, nil
}
// decodeJSONPayload tolerantly decodes a JSON object; unknown fields
// are ignored so additive Lobby schema changes do not break us.
func decodeJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return 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.LobbyInternalClient.
var _ ports.LobbyInternalClient = (*Client)(nil)