// 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)