196 lines
6.3 KiB
Go
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)) }
|