Files
galaxy-game/rtmanager/integration/harness/rest.go
T
2026-04-28 20:39:18 +02:00

196 lines
6.3 KiB
Go

package harness
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
)
// defaultHTTPClient backs the runtime-readiness poll and the REST
// helpers below. A short timeout is enough — every internal endpoint
// runs against an in-process listener.
var defaultHTTPClient = &http.Client{Timeout: 5 * time.Second}
// newRequest is a thin shim over `http.NewRequestWithContext` so the
// readiness poll and the REST client share one constructor.
func newRequest(ctx context.Context, method, fullURL string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, fullURL, body)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Galaxy-Caller", "admin")
return req, nil
}
// REST is a tiny client for the trusted internal HTTP surface RTM
// exposes to Game Master and Admin Service. It always identifies the
// caller as `admin` (the operation_log records `admin_rest`); tests
// that need GM semantics should add an option later. v1 keeps the
// helper minimal because the integration scenarios only need
// admin-driven flows.
type REST struct {
baseURL string
httpc *http.Client
}
// NewREST builds a REST client targeting env.InternalAddr.
func NewREST(env *Env) *REST {
return &REST{
baseURL: "http://" + env.InternalAddr,
httpc: defaultHTTPClient,
}
}
// Get issues GET path and returns the response body and status code.
func (r *REST) Get(t testing.TB, path string) ([]byte, int) {
t.Helper()
return r.do(t, http.MethodGet, path, nil)
}
// Post issues POST path with body (a Go value JSON-marshaled).
func (r *REST) Post(t testing.TB, path string, body any) ([]byte, int) {
t.Helper()
return r.do(t, http.MethodPost, path, body)
}
// Delete issues DELETE path with no body.
func (r *REST) Delete(t testing.TB, path string) ([]byte, int) {
t.Helper()
return r.do(t, http.MethodDelete, path, nil)
}
// GetRuntime fetches a runtime record by game id and returns the
// decoded payload, the status code, and the raw bytes for diagnostics.
func (r *REST) GetRuntime(t testing.TB, gameID string) (RuntimeRecordResponse, int) {
t.Helper()
body, status := r.Get(t, fmt.Sprintf("/api/v1/internal/runtimes/%s", url.PathEscape(gameID)))
var resp RuntimeRecordResponse
if status == http.StatusOK {
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("decode get-runtime response: %v; body=%s", err, string(body))
}
}
return resp, status
}
// StartRuntime invokes the start endpoint with imageRef.
func (r *REST) StartRuntime(t testing.TB, gameID, imageRef string) (RuntimeRecordResponse, int) {
t.Helper()
body, status := r.Post(t,
fmt.Sprintf("/api/v1/internal/runtimes/%s/start", url.PathEscape(gameID)),
map[string]string{"image_ref": imageRef},
)
return decodeRecord(t, body, status, "start")
}
// StopRuntime invokes the stop endpoint with reason.
func (r *REST) StopRuntime(t testing.TB, gameID, reason string) (RuntimeRecordResponse, int) {
t.Helper()
body, status := r.Post(t,
fmt.Sprintf("/api/v1/internal/runtimes/%s/stop", url.PathEscape(gameID)),
map[string]string{"reason": reason},
)
return decodeRecord(t, body, status, "stop")
}
// RestartRuntime invokes the restart endpoint.
func (r *REST) RestartRuntime(t testing.TB, gameID string) (RuntimeRecordResponse, int) {
t.Helper()
body, status := r.Post(t,
fmt.Sprintf("/api/v1/internal/runtimes/%s/restart", url.PathEscape(gameID)),
struct{}{},
)
return decodeRecord(t, body, status, "restart")
}
// PatchRuntime invokes the patch endpoint with imageRef.
func (r *REST) PatchRuntime(t testing.TB, gameID, imageRef string) (RuntimeRecordResponse, int) {
t.Helper()
body, status := r.Post(t,
fmt.Sprintf("/api/v1/internal/runtimes/%s/patch", url.PathEscape(gameID)),
map[string]string{"image_ref": imageRef},
)
return decodeRecord(t, body, status, "patch")
}
// CleanupRuntime invokes the DELETE container endpoint.
func (r *REST) CleanupRuntime(t testing.TB, gameID string) (RuntimeRecordResponse, int) {
t.Helper()
body, status := r.Delete(t,
fmt.Sprintf("/api/v1/internal/runtimes/%s/container", url.PathEscape(gameID)),
)
return decodeRecord(t, body, status, "cleanup")
}
// RuntimeRecordResponse mirrors the OpenAPI RuntimeRecord schema. Only
// the fields integration scenarios assert against live here; the
// listener encodes everything else.
type RuntimeRecordResponse struct {
GameID string `json:"game_id"`
Status string `json:"status"`
CurrentContainerID *string `json:"current_container_id"`
CurrentImageRef *string `json:"current_image_ref"`
EngineEndpoint *string `json:"engine_endpoint"`
StatePath string `json:"state_path"`
DockerNetwork string `json:"docker_network"`
StartedAt *string `json:"started_at"`
StoppedAt *string `json:"stopped_at"`
RemovedAt *string `json:"removed_at"`
LastOpAt string `json:"last_op_at"`
CreatedAt string `json:"created_at"`
}
func (r *REST) do(t testing.TB, method, path string, body any) ([]byte, int) {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request body: %v", err)
}
reader = bytes.NewReader(raw)
}
req, err := newRequest(context.Background(), method, r.baseURL+path, reader)
if err != nil {
t.Fatalf("build %s %s request: %v", method, path, err)
}
resp, err := r.httpc.Do(req)
if err != nil {
t.Fatalf("execute %s %s: %v", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read %s %s response: %v", method, path, err)
}
return raw, resp.StatusCode
}
func decodeRecord(t testing.TB, body []byte, status int, op string) (RuntimeRecordResponse, int) {
t.Helper()
if status != http.StatusOK {
return RuntimeRecordResponse{}, status
}
var resp RuntimeRecordResponse
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("decode %s response: %v; body=%s", op, err, string(body))
}
return resp, status
}
// PathEscape is a re-export so test files can call it without
// importing `net/url` directly. Keeps the test source focused on
// scenarios.
func PathEscape(value string) string { return url.PathEscape(strings.TrimSpace(value)) }