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