feat: runtime manager
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/rtmanager/internal/api/internalhttp/handlers"
|
||||
domainruntime "galaxy/rtmanager/internal/domain/runtime"
|
||||
"galaxy/rtmanager/internal/ports"
|
||||
"galaxy/rtmanager/internal/service/cleanupcontainer"
|
||||
"galaxy/rtmanager/internal/service/patchruntime"
|
||||
"galaxy/rtmanager/internal/service/restartruntime"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
"galaxy/rtmanager/internal/service/stopruntime"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/getkin/kin-openapi/routers/legacy"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestInternalRESTConformance loads the OpenAPI specification, drives
|
||||
// every runtime operation against the live internal HTTP listener
|
||||
// backed by stub services, and validates each response body against
|
||||
// the spec via `openapi3filter.ValidateResponse`. The test catches
|
||||
// drift between the wire shape produced by the handler layer and the
|
||||
// frozen contract; failure-path response shapes are validated by the
|
||||
// per-handler tests in `handlers/<op>_test.go`.
|
||||
func TestInternalRESTConformance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadConformanceSpec(t)
|
||||
|
||||
router, err := legacy.NewRouter(doc)
|
||||
require.NoError(t, err)
|
||||
|
||||
deps := newConformanceDeps(t)
|
||||
server, err := NewServer(newConformanceConfig(), Dependencies{
|
||||
Logger: nil,
|
||||
Telemetry: nil,
|
||||
Readiness: nil,
|
||||
RuntimeRecords: deps.records,
|
||||
StartRuntime: deps.start,
|
||||
StopRuntime: deps.stop,
|
||||
RestartRuntime: deps.restart,
|
||||
PatchRuntime: deps.patch,
|
||||
CleanupContainer: deps.cleanup,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []conformanceCase{
|
||||
{
|
||||
name: "internalListRuntimes",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/runtimes",
|
||||
},
|
||||
{
|
||||
name: "internalGetRuntime",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID,
|
||||
},
|
||||
{
|
||||
name: "internalStartRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/start",
|
||||
contentType: "application/json",
|
||||
body: `{"image_ref":"galaxy/game:v1.2.3"}`,
|
||||
},
|
||||
{
|
||||
name: "internalStopRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/stop",
|
||||
contentType: "application/json",
|
||||
body: `{"reason":"admin_request"}`,
|
||||
},
|
||||
{
|
||||
name: "internalRestartRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/restart",
|
||||
},
|
||||
{
|
||||
name: "internalPatchRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/patch",
|
||||
contentType: "application/json",
|
||||
body: `{"image_ref":"galaxy/game:v1.2.4"}`,
|
||||
},
|
||||
{
|
||||
name: "internalCleanupRuntimeContainer",
|
||||
method: http.MethodDelete,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/container",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runConformanceCase(t, server.handler, router, tc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceGameID is the path variable used for every per-game
|
||||
// conformance request.
|
||||
const conformanceGameID = "game-conformance"
|
||||
|
||||
// conformanceServerURL mirrors the canonical `servers[0].url` entry in
|
||||
// `rtmanager/api/internal-openapi.yaml`. The legacy router matches
|
||||
// requests against this prefix; updating the spec's server URL
|
||||
// requires updating this constant.
|
||||
const conformanceServerURL = "http://localhost:8096"
|
||||
|
||||
// conformanceCase describes one request the conformance test drives.
|
||||
type conformanceCase struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
contentType string
|
||||
body string
|
||||
}
|
||||
|
||||
func runConformanceCase(t *testing.T, handler http.Handler, router routers.Router, tc conformanceCase) {
|
||||
t.Helper()
|
||||
|
||||
// Drive the handler with the path-only form so the listener's
|
||||
// http.ServeMux matches the registered routes (which use raw paths,
|
||||
// without the OpenAPI server URL prefix).
|
||||
var bodyReader io.Reader
|
||||
if tc.body != "" {
|
||||
bodyReader = strings.NewReader(tc.body)
|
||||
}
|
||||
request := httptest.NewRequest(tc.method, tc.path, bodyReader)
|
||||
if tc.contentType != "" {
|
||||
request.Header.Set("Content-Type", tc.contentType)
|
||||
}
|
||||
request.Header.Set("X-Galaxy-Caller", "admin")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
require.Equalf(t, http.StatusOK, recorder.Code, "operation %s returned %d: %s", tc.name, recorder.Code, recorder.Body.String())
|
||||
|
||||
// kin-openapi's legacy router requires the request URL to match a
|
||||
// `servers[].url` entry; rebuild the validation request with the
|
||||
// canonical local server URL declared in the spec.
|
||||
validationURL := conformanceServerURL + tc.path
|
||||
validationRequest := httptest.NewRequest(tc.method, validationURL, bodyReaderFor(tc.body))
|
||||
if tc.contentType != "" {
|
||||
validationRequest.Header.Set("Content-Type", tc.contentType)
|
||||
}
|
||||
validationRequest.Header.Set("X-Galaxy-Caller", "admin")
|
||||
|
||||
route, pathParams, err := router.FindRoute(validationRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
requestInput := &openapi3filter.RequestValidationInput{
|
||||
Request: validationRequest,
|
||||
PathParams: pathParams,
|
||||
Route: route,
|
||||
Options: &openapi3filter.Options{
|
||||
IncludeResponseStatus: true,
|
||||
},
|
||||
}
|
||||
require.NoError(t, openapi3filter.ValidateRequest(context.Background(), requestInput))
|
||||
|
||||
responseInput := &openapi3filter.ResponseValidationInput{
|
||||
RequestValidationInput: requestInput,
|
||||
Status: recorder.Code,
|
||||
Header: recorder.Header(),
|
||||
Options: &openapi3filter.Options{
|
||||
IncludeResponseStatus: true,
|
||||
},
|
||||
}
|
||||
responseInput.SetBodyBytes(recorder.Body.Bytes())
|
||||
require.NoError(t, openapi3filter.ValidateResponse(context.Background(), responseInput))
|
||||
}
|
||||
|
||||
func loadConformanceSpec(t *testing.T) *openapi3.T {
|
||||
t.Helper()
|
||||
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
require.True(t, ok)
|
||||
|
||||
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "api", "internal-openapi.yaml")
|
||||
loader := openapi3.NewLoader()
|
||||
doc, err := loader.LoadFromFile(specPath)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, doc.Validate(context.Background()))
|
||||
return doc
|
||||
}
|
||||
|
||||
func bodyReaderFor(raw string) io.Reader {
|
||||
if raw == "" {
|
||||
return http.NoBody
|
||||
}
|
||||
return bytes.NewBufferString(raw)
|
||||
}
|
||||
|
||||
// conformanceDeps groups the stub collaborators handed to the listener.
|
||||
type conformanceDeps struct {
|
||||
records *conformanceRecords
|
||||
start *conformanceStart
|
||||
stop *conformanceStop
|
||||
restart *conformanceRestart
|
||||
patch *conformancePatch
|
||||
cleanup *conformanceCleanup
|
||||
}
|
||||
|
||||
func newConformanceDeps(t *testing.T) *conformanceDeps {
|
||||
t.Helper()
|
||||
return &conformanceDeps{
|
||||
records: newConformanceRecords(),
|
||||
start: &conformanceStart{},
|
||||
stop: &conformanceStop{},
|
||||
restart: &conformanceRestart{},
|
||||
patch: &conformancePatch{},
|
||||
cleanup: &conformanceCleanup{},
|
||||
}
|
||||
}
|
||||
|
||||
func newConformanceConfig() Config {
|
||||
return Config{
|
||||
Addr: ":0",
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
WriteTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceRecord builds a canonical running record used by every
|
||||
// stub service.
|
||||
func conformanceRecord() domainruntime.RuntimeRecord {
|
||||
started := time.Date(2026, 4, 26, 13, 0, 0, 0, time.UTC)
|
||||
return domainruntime.RuntimeRecord{
|
||||
GameID: conformanceGameID,
|
||||
Status: domainruntime.StatusRunning,
|
||||
CurrentContainerID: "container-conformance",
|
||||
CurrentImageRef: "galaxy/game:v1.2.3",
|
||||
EngineEndpoint: "http://galaxy-game-" + conformanceGameID + ":8080",
|
||||
StatePath: "/var/lib/galaxy/" + conformanceGameID,
|
||||
DockerNetwork: "galaxy-engine",
|
||||
StartedAt: &started,
|
||||
LastOpAt: started,
|
||||
CreatedAt: started,
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceRecords is an in-memory record store seeded with one
|
||||
// canonical record so the get / list endpoints have something to
|
||||
// return.
|
||||
type conformanceRecords struct {
|
||||
mu sync.Mutex
|
||||
stored map[string]domainruntime.RuntimeRecord
|
||||
}
|
||||
|
||||
func newConformanceRecords() *conformanceRecords {
|
||||
return &conformanceRecords{
|
||||
stored: map[string]domainruntime.RuntimeRecord{
|
||||
conformanceGameID: conformanceRecord(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *conformanceRecords) Get(_ context.Context, gameID string) (domainruntime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record, ok := s.stored[gameID]
|
||||
if !ok {
|
||||
return domainruntime.RuntimeRecord{}, domainruntime.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *conformanceRecords) Upsert(_ context.Context, _ domainruntime.RuntimeRecord) error {
|
||||
return errors.New("not used in conformance test")
|
||||
}
|
||||
|
||||
func (s *conformanceRecords) UpdateStatus(_ context.Context, _ ports.UpdateStatusInput) error {
|
||||
return errors.New("not used in conformance test")
|
||||
}
|
||||
|
||||
func (s *conformanceRecords) ListByStatus(_ context.Context, _ domainruntime.Status) ([]domainruntime.RuntimeRecord, error) {
|
||||
return nil, errors.New("not used in conformance test")
|
||||
}
|
||||
|
||||
func (s *conformanceRecords) List(_ context.Context) ([]domainruntime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
|
||||
for _, record := range s.stored {
|
||||
out = append(out, record)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// conformanceStart is the stub StartService used by the conformance
|
||||
// test. Every Handle call returns the canonical record.
|
||||
type conformanceStart struct{}
|
||||
|
||||
func (s *conformanceStart) Handle(_ context.Context, _ startruntime.Input) (startruntime.Result, error) {
|
||||
return startruntime.Result{
|
||||
Record: conformanceRecord(),
|
||||
Outcome: "success",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceStop struct{}
|
||||
|
||||
func (s *conformanceStop) Handle(_ context.Context, _ stopruntime.Input) (stopruntime.Result, error) {
|
||||
rec := conformanceRecord()
|
||||
rec.Status = domainruntime.StatusStopped
|
||||
stopped := rec.LastOpAt.Add(time.Second)
|
||||
rec.StoppedAt = &stopped
|
||||
rec.LastOpAt = stopped
|
||||
return stopruntime.Result{Record: rec, Outcome: "success"}, nil
|
||||
}
|
||||
|
||||
type conformanceRestart struct{}
|
||||
|
||||
func (s *conformanceRestart) Handle(_ context.Context, _ restartruntime.Input) (restartruntime.Result, error) {
|
||||
return restartruntime.Result{Record: conformanceRecord(), Outcome: "success"}, nil
|
||||
}
|
||||
|
||||
type conformancePatch struct{}
|
||||
|
||||
func (s *conformancePatch) Handle(_ context.Context, in patchruntime.Input) (patchruntime.Result, error) {
|
||||
rec := conformanceRecord()
|
||||
if in.NewImageRef != "" {
|
||||
rec.CurrentImageRef = in.NewImageRef
|
||||
}
|
||||
return patchruntime.Result{Record: rec, Outcome: "success"}, nil
|
||||
}
|
||||
|
||||
type conformanceCleanup struct{}
|
||||
|
||||
func (s *conformanceCleanup) Handle(_ context.Context, _ cleanupcontainer.Input) (cleanupcontainer.Result, error) {
|
||||
rec := conformanceRecord()
|
||||
rec.Status = domainruntime.StatusRemoved
|
||||
rec.CurrentContainerID = ""
|
||||
removed := rec.LastOpAt.Add(time.Minute)
|
||||
rec.RemovedAt = &removed
|
||||
rec.LastOpAt = removed
|
||||
return cleanupcontainer.Result{Record: rec, Outcome: "success"}, nil
|
||||
}
|
||||
|
||||
// Compile-time guards: the stubs must satisfy the handler-level
|
||||
// service ports plus ports.RuntimeRecordStore so the listener accepts
|
||||
// them.
|
||||
var (
|
||||
_ handlers.StartService = (*conformanceStart)(nil)
|
||||
_ handlers.StopService = (*conformanceStop)(nil)
|
||||
_ handlers.RestartService = (*conformanceRestart)(nil)
|
||||
_ handlers.PatchService = (*conformancePatch)(nil)
|
||||
_ handlers.CleanupService = (*conformanceCleanup)(nil)
|
||||
_ ports.RuntimeRecordStore = (*conformanceRecords)(nil)
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/rtmanager/internal/domain/operation"
|
||||
"galaxy/rtmanager/internal/service/cleanupcontainer"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
)
|
||||
|
||||
// newCleanupHandler returns the handler for
|
||||
// `DELETE /api/v1/internal/runtimes/{game_id}/container`. The OpenAPI
|
||||
// spec declares no request body for this operation; any client-provided
|
||||
// body is ignored.
|
||||
func newCleanupHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.cleanup")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.CleanupContainer == nil {
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"cleanup container service is not wired",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.CleanupContainer.Handle(request.Context(), cleanupcontainer.Input{
|
||||
GameID: gameID,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "cleanup container service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"cleanup container service failed",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/rtmanager/internal/domain/operation"
|
||||
"galaxy/rtmanager/internal/domain/runtime"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
)
|
||||
|
||||
// JSONContentType is the Content-Type used by every internal REST
|
||||
// response. Exported so the listener-level tests can match it without
|
||||
// re-declaring the constant.
|
||||
const JSONContentType = "application/json; charset=utf-8"
|
||||
|
||||
// gameIDPathParam is the name of the {game_id} path variable shared by
|
||||
// every per-game runtime endpoint.
|
||||
const gameIDPathParam = "game_id"
|
||||
|
||||
// callerHeader is the HTTP header that distinguishes Game Master from
|
||||
// Admin Service in the operation log. Documented in
|
||||
// `rtmanager/api/internal-openapi.yaml` and
|
||||
// `rtmanager/docs/services.md` §18.
|
||||
const callerHeader = "X-Galaxy-Caller"
|
||||
|
||||
// errorCodeDockerUnavailable mirrors the OpenAPI error code value. The
|
||||
// lifecycle services do not currently emit it (they use
|
||||
// `service_unavailable` for Docker daemon failures); the handler layer
|
||||
// maps it to 503 anyway so future producers do not require a handler
|
||||
// change.
|
||||
const errorCodeDockerUnavailable = "docker_unavailable"
|
||||
|
||||
// errorBody mirrors the `error` element of the OpenAPI ErrorResponse
|
||||
// schema.
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// errorResponse mirrors the OpenAPI ErrorResponse envelope.
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
// runtimeRecordResponse mirrors the OpenAPI RuntimeRecord schema.
|
||||
// Required fields use plain strings; nullable fields use pointers so an
|
||||
// absent value encodes as the JSON literal `null` (matches the
|
||||
// `nullable: true` declaration in the spec). Times are RFC3339 UTC.
|
||||
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"`
|
||||
}
|
||||
|
||||
// runtimesListResponse mirrors the OpenAPI RuntimesList schema. Items
|
||||
// is always non-nil so the JSON form carries `[]` rather than `null`
|
||||
// for an empty result.
|
||||
type runtimesListResponse struct {
|
||||
Items []runtimeRecordResponse `json:"items"`
|
||||
}
|
||||
|
||||
// encodeRuntimeRecord turns a domain RuntimeRecord into its wire shape.
|
||||
func encodeRuntimeRecord(record runtime.RuntimeRecord) runtimeRecordResponse {
|
||||
resp := runtimeRecordResponse{
|
||||
GameID: record.GameID,
|
||||
Status: string(record.Status),
|
||||
StatePath: record.StatePath,
|
||||
DockerNetwork: record.DockerNetwork,
|
||||
LastOpAt: record.LastOpAt.UTC().Format(time.RFC3339Nano),
|
||||
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if record.CurrentContainerID != "" {
|
||||
v := record.CurrentContainerID
|
||||
resp.CurrentContainerID = &v
|
||||
}
|
||||
if record.CurrentImageRef != "" {
|
||||
v := record.CurrentImageRef
|
||||
resp.CurrentImageRef = &v
|
||||
}
|
||||
if record.EngineEndpoint != "" {
|
||||
v := record.EngineEndpoint
|
||||
resp.EngineEndpoint = &v
|
||||
}
|
||||
if record.StartedAt != nil {
|
||||
v := record.StartedAt.UTC().Format(time.RFC3339Nano)
|
||||
resp.StartedAt = &v
|
||||
}
|
||||
if record.StoppedAt != nil {
|
||||
v := record.StoppedAt.UTC().Format(time.RFC3339Nano)
|
||||
resp.StoppedAt = &v
|
||||
}
|
||||
if record.RemovedAt != nil {
|
||||
v := record.RemovedAt.UTC().Format(time.RFC3339Nano)
|
||||
resp.RemovedAt = &v
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// encodeRuntimesList builds the wire shape returned by the list handler.
|
||||
// records may be nil (empty store); the result still carries an empty
|
||||
// items slice so the JSON form is `{"items":[]}`.
|
||||
func encodeRuntimesList(records []runtime.RuntimeRecord) runtimesListResponse {
|
||||
resp := runtimesListResponse{
|
||||
Items: make([]runtimeRecordResponse, 0, len(records)),
|
||||
}
|
||||
for _, record := range records {
|
||||
resp.Items = append(resp.Items, encodeRuntimeRecord(record))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// writeJSON writes payload as a JSON response with the given status code.
|
||||
func writeJSON(writer http.ResponseWriter, statusCode int, payload any) {
|
||||
writer.Header().Set("Content-Type", JSONContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
|
||||
// writeError writes the canonical error envelope at statusCode.
|
||||
func writeError(writer http.ResponseWriter, statusCode int, code, message string) {
|
||||
writeJSON(writer, statusCode, errorResponse{
|
||||
Error: errorBody{Code: code, Message: message},
|
||||
})
|
||||
}
|
||||
|
||||
// writeFailure writes the canonical error envelope using the HTTP
|
||||
// status mapped from code. Used by every lifecycle handler when its
|
||||
// service returns `Outcome=failure`.
|
||||
func writeFailure(writer http.ResponseWriter, code, message string) {
|
||||
writeError(writer, mapErrorCodeToStatus(code), code, message)
|
||||
}
|
||||
|
||||
// mapErrorCodeToStatus maps a stable error code to the HTTP status
|
||||
// declared by `rtmanager/api/internal-openapi.yaml`. Unknown codes
|
||||
// degrade to 500 so a future error code that ships ahead of its
|
||||
// handler-layer mapping still produces a structurally valid response.
|
||||
func mapErrorCodeToStatus(code string) int {
|
||||
switch code {
|
||||
case startruntime.ErrorCodeInvalidRequest,
|
||||
startruntime.ErrorCodeStartConfigInvalid,
|
||||
startruntime.ErrorCodeImageRefNotSemver:
|
||||
return http.StatusBadRequest
|
||||
case startruntime.ErrorCodeNotFound:
|
||||
return http.StatusNotFound
|
||||
case startruntime.ErrorCodeConflict,
|
||||
startruntime.ErrorCodeSemverPatchOnly:
|
||||
return http.StatusConflict
|
||||
case startruntime.ErrorCodeServiceUnavailable,
|
||||
errorCodeDockerUnavailable:
|
||||
return http.StatusServiceUnavailable
|
||||
case startruntime.ErrorCodeImagePullFailed,
|
||||
startruntime.ErrorCodeContainerStartFailed,
|
||||
startruntime.ErrorCodeInternal:
|
||||
return http.StatusInternalServerError
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// decodeStrictJSON decodes one request body into target with strict
|
||||
// JSON semantics: unknown fields are rejected and trailing content is
|
||||
// rejected. Mirrors the helper used by lobby's internal HTTP layer.
|
||||
func decodeStrictJSON(body io.Reader, target any) error {
|
||||
decoder := json.NewDecoder(body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if decoder.More() {
|
||||
return errors.New("unexpected trailing content after JSON body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractGameID pulls the {game_id} path variable from request. An empty
|
||||
// or whitespace-only value writes a `400 invalid_request` and returns
|
||||
// ok=false so callers can short-circuit.
|
||||
func extractGameID(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||
raw := request.PathValue(gameIDPathParam)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest,
|
||||
startruntime.ErrorCodeInvalidRequest,
|
||||
"game id is required",
|
||||
)
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// resolveOpSource maps the X-Galaxy-Caller header to an
|
||||
// `operation.OpSource`. Missing or unknown values default to
|
||||
// `OpSourceAdminRest`, matching the contract documented in
|
||||
// `rtmanager/api/internal-openapi.yaml`.
|
||||
func resolveOpSource(request *http.Request) operation.OpSource {
|
||||
switch strings.ToLower(strings.TrimSpace(request.Header.Get(callerHeader))) {
|
||||
case "gm":
|
||||
return operation.OpSourceGMRest
|
||||
default:
|
||||
return operation.OpSourceAdminRest
|
||||
}
|
||||
}
|
||||
|
||||
// requestSourceRef returns an opaque per-request reference recorded in
|
||||
// `operation_log.source_ref`. v1 reads the `X-Request-ID` header when
|
||||
// present so callers may correlate REST requests with audit rows; the
|
||||
// listener does not currently install a request-id middleware so the
|
||||
// header path is the only source.
|
||||
func requestSourceRef(request *http.Request) string {
|
||||
if v := strings.TrimSpace(request.Header.Get("X-Request-ID")); v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// loggerFor returns a logger annotated with the operation tag. Each
|
||||
// handler scopes its logs by op so operators filtering on
|
||||
// `op=internal_rest.start` see exactly the lifecycle they care about.
|
||||
func loggerFor(parent *slog.Logger, op string) *slog.Logger {
|
||||
if parent == nil {
|
||||
parent = slog.Default()
|
||||
}
|
||||
return parent.With("component", "internal_http.handlers", "op", op)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/rtmanager/internal/domain/runtime"
|
||||
"galaxy/rtmanager/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fixedClock is the wall-clock used to build canonical sample records
|
||||
// across the handler tests. UTC Sunday 1pm 2026-04-26 is far enough in
|
||||
// the future to be obvious in test output.
|
||||
var fixedClock = time.Date(2026, 4, 26, 13, 0, 0, 0, time.UTC)
|
||||
|
||||
// sampleRunningRecord returns a canonical running record used by every
|
||||
// happy-path test in this package.
|
||||
func sampleRunningRecord(t *testing.T) runtime.RuntimeRecord {
|
||||
t.Helper()
|
||||
started := fixedClock
|
||||
return runtime.RuntimeRecord{
|
||||
GameID: "game-test",
|
||||
Status: runtime.StatusRunning,
|
||||
CurrentContainerID: "container-test",
|
||||
CurrentImageRef: "galaxy/game:v1.2.3",
|
||||
EngineEndpoint: "http://galaxy-game-game-test:8080",
|
||||
StatePath: "/var/lib/galaxy/game-test",
|
||||
DockerNetwork: "galaxy-engine",
|
||||
StartedAt: &started,
|
||||
LastOpAt: fixedClock,
|
||||
CreatedAt: fixedClock,
|
||||
}
|
||||
}
|
||||
|
||||
// sampleStoppedRecord returns a canonical stopped record useful for
|
||||
// cleanup-handler and list-handler tests.
|
||||
func sampleStoppedRecord(t *testing.T) runtime.RuntimeRecord {
|
||||
t.Helper()
|
||||
started := fixedClock
|
||||
stopped := fixedClock.Add(time.Minute)
|
||||
return runtime.RuntimeRecord{
|
||||
GameID: "game-stopped",
|
||||
Status: runtime.StatusStopped,
|
||||
CurrentContainerID: "container-stopped",
|
||||
CurrentImageRef: "galaxy/game:v1.2.3",
|
||||
EngineEndpoint: "http://galaxy-game-game-stopped:8080",
|
||||
StatePath: "/var/lib/galaxy/game-stopped",
|
||||
DockerNetwork: "galaxy-engine",
|
||||
StartedAt: &started,
|
||||
StoppedAt: &stopped,
|
||||
LastOpAt: stopped,
|
||||
CreatedAt: fixedClock,
|
||||
}
|
||||
}
|
||||
|
||||
// drive routes one request through a full mux configured by Register.
|
||||
// It returns the captured ResponseRecorder so tests can assert on
|
||||
// status, headers, and body.
|
||||
func drive(t *testing.T, deps Dependencies, method, path string, headers http.Header, body io.Reader) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
Register(mux, deps)
|
||||
|
||||
request := httptest.NewRequest(method, path, body)
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
request.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
// decodeRecordResponse asserts that the response carried a 200 with
|
||||
// the canonical content type and decodes the record body.
|
||||
func decodeRecordResponse(t *testing.T, rec *httptest.ResponseRecorder) runtimeRecordResponse {
|
||||
t.Helper()
|
||||
require.Equalf(t, http.StatusOK, rec.Code, "expected 200, got body: %s", rec.Body.String())
|
||||
require.Equal(t, JSONContentType, rec.Header().Get("Content-Type"))
|
||||
|
||||
var resp runtimeRecordResponse
|
||||
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
|
||||
return resp
|
||||
}
|
||||
|
||||
// decodeErrorBody asserts the canonical error envelope and decodes it.
|
||||
func decodeErrorBody(t *testing.T, rec *httptest.ResponseRecorder, wantStatus int) errorBody {
|
||||
t.Helper()
|
||||
require.Equalf(t, wantStatus, rec.Code, "expected %d, got body: %s", wantStatus, rec.Body.String())
|
||||
require.Equal(t, JSONContentType, rec.Header().Get("Content-Type"))
|
||||
|
||||
var resp errorResponse
|
||||
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
|
||||
return resp.Error
|
||||
}
|
||||
|
||||
// fakeRuntimeRecords is an in-memory ports.RuntimeRecordStore used by
|
||||
// list / get tests. It is intentionally minimal — services use their
|
||||
// own fakes in `internal/service/<op>/service_test.go` and do not
|
||||
// share this helper.
|
||||
type fakeRuntimeRecords struct {
|
||||
mu sync.Mutex
|
||||
stored map[string]runtime.RuntimeRecord
|
||||
listErr error
|
||||
getErr error
|
||||
}
|
||||
|
||||
func newFakeRuntimeRecords() *fakeRuntimeRecords {
|
||||
return &fakeRuntimeRecords{stored: map[string]runtime.RuntimeRecord{}}
|
||||
}
|
||||
|
||||
func (s *fakeRuntimeRecords) put(record runtime.RuntimeRecord) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.stored[record.GameID] = record
|
||||
}
|
||||
|
||||
func (s *fakeRuntimeRecords) Get(_ context.Context, gameID string) (runtime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.getErr != nil {
|
||||
return runtime.RuntimeRecord{}, s.getErr
|
||||
}
|
||||
record, ok := s.stored[gameID]
|
||||
if !ok {
|
||||
return runtime.RuntimeRecord{}, runtime.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *fakeRuntimeRecords) Upsert(_ context.Context, _ runtime.RuntimeRecord) error {
|
||||
return errors.New("not used in handler tests")
|
||||
}
|
||||
|
||||
func (s *fakeRuntimeRecords) UpdateStatus(_ context.Context, _ ports.UpdateStatusInput) error {
|
||||
return errors.New("not used in handler tests")
|
||||
}
|
||||
|
||||
func (s *fakeRuntimeRecords) ListByStatus(_ context.Context, _ runtime.Status) ([]runtime.RuntimeRecord, error) {
|
||||
return nil, errors.New("not used in handler tests")
|
||||
}
|
||||
|
||||
func (s *fakeRuntimeRecords) List(_ context.Context) ([]runtime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.listErr != nil {
|
||||
return nil, s.listErr
|
||||
}
|
||||
if len(s.stored) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
records := make([]runtime.RuntimeRecord, 0, len(s.stored))
|
||||
for _, record := range s.stored {
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// jsonHeaders returns the default headers used by tests that send a
|
||||
// JSON body.
|
||||
func jsonHeaders() http.Header {
|
||||
h := http.Header{}
|
||||
h.Set("Content-Type", "application/json")
|
||||
return h
|
||||
}
|
||||
|
||||
// withCaller adds the X-Galaxy-Caller header to h and returns h. The
|
||||
// helper exists to keep test cases readable when the header is the
|
||||
// only difference between two table rows.
|
||||
func withCaller(h http.Header, value string) http.Header {
|
||||
if h == nil {
|
||||
h = http.Header{}
|
||||
}
|
||||
h.Set(callerHeader, value)
|
||||
return h
|
||||
}
|
||||
|
||||
// strReader builds an io.Reader from raw JSON.
|
||||
func strReader(raw string) io.Reader {
|
||||
return strings.NewReader(raw)
|
||||
}
|
||||
|
||||
// Compile-time assertions that the in-memory fake satisfies the port.
|
||||
var _ ports.RuntimeRecordStore = (*fakeRuntimeRecords)(nil)
|
||||
@@ -0,0 +1,55 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/rtmanager/internal/domain/runtime"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
)
|
||||
|
||||
// newGetHandler returns the handler for
|
||||
// `GET /api/v1/internal/runtimes/{game_id}`. The handler reads
|
||||
// directly from the runtime record store and translates
|
||||
// `runtime.ErrNotFound` to `404 not_found`. Like list, it does not
|
||||
// run through the service layer and does not produce an operation_log
|
||||
// row.
|
||||
func newGetHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.get")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.RuntimeRecords == nil {
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"runtime records store is not wired",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := deps.RuntimeRecords.Get(request.Context(), gameID)
|
||||
if errors.Is(err, runtime.ErrNotFound) {
|
||||
writeError(writer, http.StatusNotFound,
|
||||
startruntime.ErrorCodeNotFound,
|
||||
"runtime record not found",
|
||||
)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "get runtime record",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"failed to read runtime record",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"galaxy/rtmanager/internal/ports"
|
||||
)
|
||||
|
||||
// Route paths registered by Register. The values match the operation
|
||||
// IDs frozen by `rtmanager/api/internal-openapi.yaml` and
|
||||
// `rtmanager/contract_openapi_test.go`.
|
||||
const (
|
||||
listRuntimesPath = "/api/v1/internal/runtimes"
|
||||
getRuntimePath = "/api/v1/internal/runtimes/{game_id}"
|
||||
startRuntimePath = "/api/v1/internal/runtimes/{game_id}/start"
|
||||
stopRuntimePath = "/api/v1/internal/runtimes/{game_id}/stop"
|
||||
restartRuntimePath = "/api/v1/internal/runtimes/{game_id}/restart"
|
||||
patchRuntimePath = "/api/v1/internal/runtimes/{game_id}/patch"
|
||||
cleanupRuntimePath = "/api/v1/internal/runtimes/{game_id}/container"
|
||||
)
|
||||
|
||||
// Dependencies bundles the collaborators required to serve the GM/Admin
|
||||
// REST surface. Any service may be nil for tests that exercise a
|
||||
// subset of the surface; in that case the unwired routes return
|
||||
// `500 internal_error` (mirrors lobby's "service is not wired"
|
||||
// pattern).
|
||||
type Dependencies struct {
|
||||
// Logger receives structured logs scoped per handler. nil falls back
|
||||
// to slog.Default.
|
||||
Logger *slog.Logger
|
||||
|
||||
// RuntimeRecords backs the read-only list and get handlers. They do
|
||||
// not produce operation_log rows because they do not mutate state.
|
||||
RuntimeRecords ports.RuntimeRecordStore
|
||||
|
||||
// StartRuntime executes the start lifecycle operation. Production
|
||||
// wiring passes `*startruntime.Service` (the concrete service
|
||||
// satisfies StartService).
|
||||
StartRuntime StartService
|
||||
|
||||
// StopRuntime executes the stop lifecycle operation.
|
||||
StopRuntime StopService
|
||||
|
||||
// RestartRuntime executes the restart lifecycle operation.
|
||||
RestartRuntime RestartService
|
||||
|
||||
// PatchRuntime executes the patch lifecycle operation.
|
||||
PatchRuntime PatchService
|
||||
|
||||
// CleanupContainer executes the cleanup_container lifecycle
|
||||
// operation.
|
||||
CleanupContainer CleanupService
|
||||
}
|
||||
|
||||
// Register attaches every internal REST route to mux using deps. Each
|
||||
// route reads its dependency lazily so a partially-wired Dependencies
|
||||
// (e.g., a probe-only listener test) does not crash; missing
|
||||
// dependencies surface as `500 internal_error`. Routes use Go 1.22
|
||||
// method-aware mux patterns.
|
||||
func Register(mux *http.ServeMux, deps Dependencies) {
|
||||
mux.HandleFunc("GET "+listRuntimesPath, newListHandler(deps))
|
||||
mux.HandleFunc("GET "+getRuntimePath, newGetHandler(deps))
|
||||
mux.HandleFunc("POST "+startRuntimePath, newStartHandler(deps))
|
||||
mux.HandleFunc("POST "+stopRuntimePath, newStopHandler(deps))
|
||||
mux.HandleFunc("POST "+restartRuntimePath, newRestartHandler(deps))
|
||||
mux.HandleFunc("POST "+patchRuntimePath, newPatchHandler(deps))
|
||||
mux.HandleFunc("DELETE "+cleanupRuntimePath, newCleanupHandler(deps))
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"galaxy/rtmanager/internal/api/internalhttp/handlers/mocks"
|
||||
"galaxy/rtmanager/internal/domain/operation"
|
||||
"galaxy/rtmanager/internal/domain/runtime"
|
||||
"galaxy/rtmanager/internal/service/cleanupcontainer"
|
||||
"galaxy/rtmanager/internal/service/patchruntime"
|
||||
"galaxy/rtmanager/internal/service/restartruntime"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
"galaxy/rtmanager/internal/service/stopruntime"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// Tests for the mutating handlers (start, stop, restart, patch,
|
||||
// cleanup). Each handler delegates to one lifecycle service through a
|
||||
// narrow `mockgen`-backed interface; the handler layer is responsible
|
||||
// for input parsing, the `X-Galaxy-Caller` → `op_source` mapping, and
|
||||
// the canonical `ErrorCode` → HTTP status table documented in
|
||||
// `rtmanager/docs/services.md` §18.
|
||||
|
||||
// --- start ---
|
||||
|
||||
func TestStartHandlerReturnsRecordOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStartService(ctrl)
|
||||
|
||||
record := sampleRunningRecord(t)
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(startruntime.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in startruntime.Input) (startruntime.Result, error) {
|
||||
assert.Equal(t, "game-test", in.GameID)
|
||||
assert.Equal(t, "galaxy/game:v1.2.3", in.ImageRef)
|
||||
assert.Equal(t, operation.OpSourceAdminRest, in.OpSource)
|
||||
return startruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
deps := Dependencies{StartRuntime: mock}
|
||||
rec := drive(t, deps, http.MethodPost, "/api/v1/internal/runtimes/game-test/start",
|
||||
jsonHeaders(),
|
||||
strReader(`{"image_ref":"galaxy/game:v1.2.3"}`),
|
||||
)
|
||||
|
||||
resp := decodeRecordResponse(t, rec)
|
||||
assert.Equal(t, "game-test", resp.GameID)
|
||||
assert.Equal(t, "running", resp.Status)
|
||||
}
|
||||
|
||||
func TestStartHandlerReturnsRecordOnReplayNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStartService(ctrl)
|
||||
|
||||
record := sampleRunningRecord(t)
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.Any()).
|
||||
Return(startruntime.Result{
|
||||
Record: record,
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
ErrorCode: startruntime.ErrorCodeReplayNoOp,
|
||||
}, nil)
|
||||
|
||||
rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/start",
|
||||
jsonHeaders(),
|
||||
strReader(`{"image_ref":"galaxy/game:v1.2.3"}`),
|
||||
)
|
||||
|
||||
resp := decodeRecordResponse(t, rec)
|
||||
assert.Equal(t, "game-test", resp.GameID)
|
||||
}
|
||||
|
||||
func TestStartHandlerMapsServiceFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
errorCode string
|
||||
wantStatus int
|
||||
}{
|
||||
{"start_config_invalid", startruntime.ErrorCodeStartConfigInvalid, http.StatusBadRequest},
|
||||
{"image_pull_failed", startruntime.ErrorCodeImagePullFailed, http.StatusInternalServerError},
|
||||
{"container_start_failed", startruntime.ErrorCodeContainerStartFailed, http.StatusInternalServerError},
|
||||
{"conflict", startruntime.ErrorCodeConflict, http.StatusConflict},
|
||||
{"service_unavailable", startruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable},
|
||||
{"internal_error", startruntime.ErrorCodeInternal, http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStartService(ctrl)
|
||||
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.Any()).
|
||||
Return(startruntime.Result{
|
||||
Outcome: operation.OutcomeFailure,
|
||||
ErrorCode: tc.errorCode,
|
||||
ErrorMessage: "synthetic " + tc.name,
|
||||
}, nil)
|
||||
|
||||
rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/start",
|
||||
jsonHeaders(),
|
||||
strReader(`{"image_ref":"galaxy/game:v1.2.3"}`),
|
||||
)
|
||||
|
||||
body := decodeErrorBody(t, rec, tc.wantStatus)
|
||||
assert.Equal(t, tc.errorCode, body.Code)
|
||||
assert.Equal(t, "synthetic "+tc.name, body.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartHandlerRejectsUnknownJSONFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStartService(ctrl)
|
||||
|
||||
rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/start",
|
||||
jsonHeaders(),
|
||||
strReader(`{"image_ref":"x","extra":"y"}`),
|
||||
)
|
||||
|
||||
body := decodeErrorBody(t, rec, http.StatusBadRequest)
|
||||
assert.Equal(t, "invalid_request", body.Code)
|
||||
}
|
||||
|
||||
func TestStartHandlerRejectsMalformedJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStartService(ctrl)
|
||||
|
||||
rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/start",
|
||||
jsonHeaders(),
|
||||
strReader(`{"image_ref":`),
|
||||
)
|
||||
|
||||
body := decodeErrorBody(t, rec, http.StatusBadRequest)
|
||||
assert.Equal(t, "invalid_request", body.Code)
|
||||
}
|
||||
|
||||
func TestStartHandlerHonoursXGalaxyCallerHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
header string
|
||||
want operation.OpSource
|
||||
hdrLabel string
|
||||
}{
|
||||
{"gm", operation.OpSourceGMRest, "gm"},
|
||||
{"GM", operation.OpSourceGMRest, "uppercase gm"},
|
||||
{"admin", operation.OpSourceAdminRest, "admin"},
|
||||
{"unknown", operation.OpSourceAdminRest, "unknown value"},
|
||||
{"", operation.OpSourceAdminRest, "missing header"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.hdrLabel, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStartService(ctrl)
|
||||
|
||||
record := sampleRunningRecord(t)
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(startruntime.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in startruntime.Input) (startruntime.Result, error) {
|
||||
assert.Equal(t, tc.want, in.OpSource)
|
||||
return startruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
headers := jsonHeaders()
|
||||
if tc.header != "" {
|
||||
headers = withCaller(headers, tc.header)
|
||||
}
|
||||
rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/start",
|
||||
headers,
|
||||
strReader(`{"image_ref":"galaxy/game:v1.2.3"}`),
|
||||
)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartHandlerForwardsXRequestIDAsSourceRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStartService(ctrl)
|
||||
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(startruntime.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in startruntime.Input) (startruntime.Result, error) {
|
||||
assert.Equal(t, "req-42", in.SourceRef)
|
||||
return startruntime.Result{Record: sampleRunningRecord(t), Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
headers := jsonHeaders()
|
||||
headers.Set("X-Request-ID", "req-42")
|
||||
rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/start",
|
||||
headers,
|
||||
strReader(`{"image_ref":"galaxy/game:v1.2.3"}`),
|
||||
)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestStartHandlerReturnsInternalErrorWhenServiceErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStartService(ctrl)
|
||||
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.Any()).
|
||||
Return(startruntime.Result{}, assert.AnError)
|
||||
|
||||
rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/start",
|
||||
jsonHeaders(),
|
||||
strReader(`{"image_ref":"galaxy/game:v1.2.3"}`),
|
||||
)
|
||||
|
||||
body := decodeErrorBody(t, rec, http.StatusInternalServerError)
|
||||
assert.Equal(t, "internal_error", body.Code)
|
||||
}
|
||||
|
||||
func TestStartHandlerReturnsInternalErrorWhenServiceNotWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := drive(t, Dependencies{}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/start",
|
||||
jsonHeaders(),
|
||||
strReader(`{"image_ref":"galaxy/game:v1.2.3"}`),
|
||||
)
|
||||
|
||||
body := decodeErrorBody(t, rec, http.StatusInternalServerError)
|
||||
assert.Equal(t, "internal_error", body.Code)
|
||||
}
|
||||
|
||||
// --- stop ---
|
||||
|
||||
func TestStopHandlerReturnsRecordOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStopService(ctrl)
|
||||
|
||||
record := sampleStoppedRecord(t)
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(stopruntime.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in stopruntime.Input) (stopruntime.Result, error) {
|
||||
assert.Equal(t, "game-test", in.GameID)
|
||||
assert.Equal(t, stopruntime.StopReasonAdminRequest, in.Reason)
|
||||
assert.Equal(t, operation.OpSourceAdminRest, in.OpSource)
|
||||
return stopruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
rec := drive(t, Dependencies{StopRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/stop",
|
||||
jsonHeaders(),
|
||||
strReader(`{"reason":"admin_request"}`),
|
||||
)
|
||||
|
||||
resp := decodeRecordResponse(t, rec)
|
||||
assert.Equal(t, "stopped", resp.Status)
|
||||
}
|
||||
|
||||
func TestStopHandlerMapsServiceFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
errorCode string
|
||||
wantStatus int
|
||||
}{
|
||||
{"not_found", startruntime.ErrorCodeNotFound, http.StatusNotFound},
|
||||
{"conflict", startruntime.ErrorCodeConflict, http.StatusConflict},
|
||||
{"invalid_request", startruntime.ErrorCodeInvalidRequest, http.StatusBadRequest},
|
||||
{"service_unavailable", startruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable},
|
||||
{"internal_error", startruntime.ErrorCodeInternal, http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStopService(ctrl)
|
||||
mock.EXPECT().Handle(gomock.Any(), gomock.Any()).Return(stopruntime.Result{
|
||||
Outcome: operation.OutcomeFailure, ErrorCode: tc.errorCode, ErrorMessage: tc.name,
|
||||
}, nil)
|
||||
|
||||
rec := drive(t, Dependencies{StopRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/stop",
|
||||
jsonHeaders(),
|
||||
strReader(`{"reason":"admin_request"}`),
|
||||
)
|
||||
body := decodeErrorBody(t, rec, tc.wantStatus)
|
||||
assert.Equal(t, tc.errorCode, body.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopHandlerRejectsUnknownJSONFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStopService(ctrl)
|
||||
|
||||
rec := drive(t, Dependencies{StopRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/stop",
|
||||
jsonHeaders(),
|
||||
strReader(`{"reason":"admin_request","extra":1}`),
|
||||
)
|
||||
body := decodeErrorBody(t, rec, http.StatusBadRequest)
|
||||
assert.Equal(t, "invalid_request", body.Code)
|
||||
}
|
||||
|
||||
func TestStopHandlerHonoursXGalaxyCallerHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockStopService(ctrl)
|
||||
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(stopruntime.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in stopruntime.Input) (stopruntime.Result, error) {
|
||||
assert.Equal(t, operation.OpSourceGMRest, in.OpSource)
|
||||
return stopruntime.Result{Record: sampleStoppedRecord(t), Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
rec := drive(t, Dependencies{StopRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/stop",
|
||||
withCaller(jsonHeaders(), "gm"),
|
||||
strReader(`{"reason":"cancelled"}`),
|
||||
)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestStopHandlerReturnsInternalErrorWhenServiceNotWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := drive(t, Dependencies{}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/stop",
|
||||
jsonHeaders(),
|
||||
strReader(`{"reason":"admin_request"}`),
|
||||
)
|
||||
body := decodeErrorBody(t, rec, http.StatusInternalServerError)
|
||||
assert.Equal(t, "internal_error", body.Code)
|
||||
}
|
||||
|
||||
// --- restart ---
|
||||
|
||||
func TestRestartHandlerReturnsRecordOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockRestartService(ctrl)
|
||||
|
||||
record := sampleRunningRecord(t)
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(restartruntime.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in restartruntime.Input) (restartruntime.Result, error) {
|
||||
assert.Equal(t, "game-test", in.GameID)
|
||||
assert.Equal(t, operation.OpSourceAdminRest, in.OpSource)
|
||||
return restartruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
rec := drive(t, Dependencies{RestartRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/restart", nil, nil,
|
||||
)
|
||||
resp := decodeRecordResponse(t, rec)
|
||||
assert.Equal(t, "running", resp.Status)
|
||||
}
|
||||
|
||||
func TestRestartHandlerMapsServiceFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
errorCode string
|
||||
wantStatus int
|
||||
}{
|
||||
{"not_found", startruntime.ErrorCodeNotFound, http.StatusNotFound},
|
||||
{"conflict", startruntime.ErrorCodeConflict, http.StatusConflict},
|
||||
{"service_unavailable", startruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable},
|
||||
{"internal_error", startruntime.ErrorCodeInternal, http.StatusInternalServerError},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockRestartService(ctrl)
|
||||
mock.EXPECT().Handle(gomock.Any(), gomock.Any()).Return(restartruntime.Result{
|
||||
Outcome: operation.OutcomeFailure, ErrorCode: tc.errorCode, ErrorMessage: tc.name,
|
||||
}, nil)
|
||||
|
||||
rec := drive(t, Dependencies{RestartRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/restart", nil, nil,
|
||||
)
|
||||
body := decodeErrorBody(t, rec, tc.wantStatus)
|
||||
assert.Equal(t, tc.errorCode, body.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartHandlerHonoursXGalaxyCallerHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockRestartService(ctrl)
|
||||
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(restartruntime.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in restartruntime.Input) (restartruntime.Result, error) {
|
||||
assert.Equal(t, operation.OpSourceGMRest, in.OpSource)
|
||||
return restartruntime.Result{Record: sampleRunningRecord(t), Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
rec := drive(t, Dependencies{RestartRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/restart",
|
||||
withCaller(http.Header{}, "gm"), nil,
|
||||
)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestRestartHandlerReturnsInternalErrorWhenServiceNotWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := drive(t, Dependencies{}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/restart", nil, nil,
|
||||
)
|
||||
body := decodeErrorBody(t, rec, http.StatusInternalServerError)
|
||||
assert.Equal(t, "internal_error", body.Code)
|
||||
}
|
||||
|
||||
// --- patch ---
|
||||
|
||||
func TestPatchHandlerReturnsRecordOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockPatchService(ctrl)
|
||||
|
||||
record := sampleRunningRecord(t)
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(patchruntime.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in patchruntime.Input) (patchruntime.Result, error) {
|
||||
assert.Equal(t, "game-test", in.GameID)
|
||||
assert.Equal(t, "galaxy/game:v1.2.4", in.NewImageRef)
|
||||
return patchruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
rec := drive(t, Dependencies{PatchRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/patch",
|
||||
jsonHeaders(),
|
||||
strReader(`{"image_ref":"galaxy/game:v1.2.4"}`),
|
||||
)
|
||||
resp := decodeRecordResponse(t, rec)
|
||||
assert.Equal(t, "running", resp.Status)
|
||||
}
|
||||
|
||||
func TestPatchHandlerMapsServiceFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
errorCode string
|
||||
wantStatus int
|
||||
}{
|
||||
{"image_ref_not_semver", startruntime.ErrorCodeImageRefNotSemver, http.StatusBadRequest},
|
||||
{"semver_patch_only", startruntime.ErrorCodeSemverPatchOnly, http.StatusConflict},
|
||||
{"not_found", startruntime.ErrorCodeNotFound, http.StatusNotFound},
|
||||
{"conflict", startruntime.ErrorCodeConflict, http.StatusConflict},
|
||||
{"service_unavailable", startruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockPatchService(ctrl)
|
||||
mock.EXPECT().Handle(gomock.Any(), gomock.Any()).Return(patchruntime.Result{
|
||||
Outcome: operation.OutcomeFailure, ErrorCode: tc.errorCode, ErrorMessage: tc.name,
|
||||
}, nil)
|
||||
|
||||
rec := drive(t, Dependencies{PatchRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/patch",
|
||||
jsonHeaders(),
|
||||
strReader(`{"image_ref":"galaxy/game:v1.2.4"}`),
|
||||
)
|
||||
body := decodeErrorBody(t, rec, tc.wantStatus)
|
||||
assert.Equal(t, tc.errorCode, body.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchHandlerRejectsUnknownJSONFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockPatchService(ctrl)
|
||||
|
||||
rec := drive(t, Dependencies{PatchRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/patch",
|
||||
jsonHeaders(),
|
||||
strReader(`{"image_ref":"x","unexpected":true}`),
|
||||
)
|
||||
body := decodeErrorBody(t, rec, http.StatusBadRequest)
|
||||
assert.Equal(t, "invalid_request", body.Code)
|
||||
}
|
||||
|
||||
func TestPatchHandlerHonoursXGalaxyCallerHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockPatchService(ctrl)
|
||||
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(patchruntime.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in patchruntime.Input) (patchruntime.Result, error) {
|
||||
assert.Equal(t, operation.OpSourceGMRest, in.OpSource)
|
||||
return patchruntime.Result{Record: sampleRunningRecord(t), Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
rec := drive(t, Dependencies{PatchRuntime: mock}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/patch",
|
||||
withCaller(jsonHeaders(), "gm"),
|
||||
strReader(`{"image_ref":"galaxy/game:v1.2.4"}`),
|
||||
)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestPatchHandlerReturnsInternalErrorWhenServiceNotWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := drive(t, Dependencies{}, http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-test/patch",
|
||||
jsonHeaders(),
|
||||
strReader(`{"image_ref":"galaxy/game:v1.2.4"}`),
|
||||
)
|
||||
body := decodeErrorBody(t, rec, http.StatusInternalServerError)
|
||||
assert.Equal(t, "internal_error", body.Code)
|
||||
}
|
||||
|
||||
// --- cleanup ---
|
||||
|
||||
func TestCleanupHandlerReturnsRecordOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockCleanupService(ctrl)
|
||||
|
||||
record := sampleStoppedRecord(t)
|
||||
record.Status = runtime.StatusRemoved
|
||||
record.CurrentContainerID = ""
|
||||
removed := record.LastOpAt
|
||||
record.RemovedAt = &removed
|
||||
|
||||
mock.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(cleanupcontainer.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in cleanupcontainer.Input) (cleanupcontainer.Result, error) {
|
||||
assert.Equal(t, "game-stopped", in.GameID)
|
||||
assert.Equal(t, operation.OpSourceAdminRest, in.OpSource)
|
||||
return cleanupcontainer.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
rec := drive(t, Dependencies{CleanupContainer: mock}, http.MethodDelete,
|
||||
"/api/v1/internal/runtimes/game-stopped/container", nil, nil,
|
||||
)
|
||||
resp := decodeRecordResponse(t, rec)
|
||||
assert.Equal(t, "removed", resp.Status)
|
||||
assert.Nil(t, resp.CurrentContainerID, "container id must be null after cleanup")
|
||||
}
|
||||
|
||||
func TestCleanupHandlerMapsServiceFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
errorCode string
|
||||
wantStatus int
|
||||
}{
|
||||
{"not_found", startruntime.ErrorCodeNotFound, http.StatusNotFound},
|
||||
{"conflict", startruntime.ErrorCodeConflict, http.StatusConflict},
|
||||
{"service_unavailable", startruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mocks.NewMockCleanupService(ctrl)
|
||||
mock.EXPECT().Handle(gomock.Any(), gomock.Any()).Return(cleanupcontainer.Result{
|
||||
Outcome: operation.OutcomeFailure, ErrorCode: tc.errorCode, ErrorMessage: tc.name,
|
||||
}, nil)
|
||||
|
||||
rec := drive(t, Dependencies{CleanupContainer: mock}, http.MethodDelete,
|
||||
"/api/v1/internal/runtimes/game-test/container", nil, nil,
|
||||
)
|
||||
body := decodeErrorBody(t, rec, tc.wantStatus)
|
||||
assert.Equal(t, tc.errorCode, body.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupHandlerReturnsInternalErrorWhenServiceNotWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := drive(t, Dependencies{}, http.MethodDelete,
|
||||
"/api/v1/internal/runtimes/game-test/container", nil, nil,
|
||||
)
|
||||
body := decodeErrorBody(t, rec, http.StatusInternalServerError)
|
||||
assert.Equal(t, "internal_error", body.Code)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Tests for the read-only handlers (`internalListRuntimes`,
|
||||
// `internalGetRuntime`). These bypass the service layer and read
|
||||
// directly from `ports.RuntimeRecordStore` — see
|
||||
// `rtmanager/docs/services.md` §18.
|
||||
|
||||
func TestListHandlerReturnsEmptyItemsForEmptyStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := Dependencies{RuntimeRecords: newFakeRuntimeRecords()}
|
||||
rec := drive(t, deps, http.MethodGet, "/api/v1/internal/runtimes", nil, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, JSONContentType, rec.Header().Get("Content-Type"))
|
||||
|
||||
var resp runtimesListResponse
|
||||
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
|
||||
require.NotNil(t, resp.Items, "items must never be nil")
|
||||
assert.Empty(t, resp.Items)
|
||||
}
|
||||
|
||||
func TestListHandlerReturnsEveryStoredRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newFakeRuntimeRecords()
|
||||
store.put(sampleRunningRecord(t))
|
||||
store.put(sampleStoppedRecord(t))
|
||||
|
||||
rec := drive(t, Dependencies{RuntimeRecords: store}, http.MethodGet, "/api/v1/internal/runtimes", nil, nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp runtimesListResponse
|
||||
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
|
||||
require.Len(t, resp.Items, 2)
|
||||
|
||||
gotIDs := map[string]string{}
|
||||
for _, item := range resp.Items {
|
||||
gotIDs[item.GameID] = item.Status
|
||||
}
|
||||
assert.Equal(t, "running", gotIDs["game-test"])
|
||||
assert.Equal(t, "stopped", gotIDs["game-stopped"])
|
||||
}
|
||||
|
||||
func TestListHandlerReturnsInternalErrorWhenStoreFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newFakeRuntimeRecords()
|
||||
store.listErr = errors.New("postgres exploded")
|
||||
|
||||
rec := drive(t, Dependencies{RuntimeRecords: store}, http.MethodGet, "/api/v1/internal/runtimes", nil, nil)
|
||||
body := decodeErrorBody(t, rec, http.StatusInternalServerError)
|
||||
assert.Equal(t, "internal_error", body.Code)
|
||||
}
|
||||
|
||||
func TestListHandlerReturnsInternalErrorWhenStoreNotWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := drive(t, Dependencies{}, http.MethodGet, "/api/v1/internal/runtimes", nil, nil)
|
||||
body := decodeErrorBody(t, rec, http.StatusInternalServerError)
|
||||
assert.Equal(t, "internal_error", body.Code)
|
||||
}
|
||||
|
||||
func TestGetHandlerReturnsTheRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newFakeRuntimeRecords()
|
||||
record := sampleRunningRecord(t)
|
||||
store.put(record)
|
||||
|
||||
rec := drive(t, Dependencies{RuntimeRecords: store}, http.MethodGet, "/api/v1/internal/runtimes/game-test", nil, nil)
|
||||
resp := decodeRecordResponse(t, rec)
|
||||
assert.Equal(t, "game-test", resp.GameID)
|
||||
assert.Equal(t, "running", resp.Status)
|
||||
if assert.NotNil(t, resp.CurrentImageRef) {
|
||||
assert.Equal(t, "galaxy/game:v1.2.3", *resp.CurrentImageRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHandlerReturnsNotFoundForMissingRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := drive(t, Dependencies{RuntimeRecords: newFakeRuntimeRecords()}, http.MethodGet, "/api/v1/internal/runtimes/game-missing", nil, nil)
|
||||
body := decodeErrorBody(t, rec, http.StatusNotFound)
|
||||
assert.Equal(t, "not_found", body.Code)
|
||||
}
|
||||
|
||||
func TestGetHandlerReturnsInternalErrorWhenStoreFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newFakeRuntimeRecords()
|
||||
store.getErr = errors.New("transport blew up")
|
||||
|
||||
rec := drive(t, Dependencies{RuntimeRecords: store}, http.MethodGet, "/api/v1/internal/runtimes/game-test", nil, nil)
|
||||
body := decodeErrorBody(t, rec, http.StatusInternalServerError)
|
||||
assert.Equal(t, "internal_error", body.Code)
|
||||
}
|
||||
|
||||
func TestGetHandlerReturnsInternalErrorWhenStoreNotWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := drive(t, Dependencies{}, http.MethodGet, "/api/v1/internal/runtimes/game-test", nil, nil)
|
||||
body := decodeErrorBody(t, rec, http.StatusInternalServerError)
|
||||
assert.Equal(t, "internal_error", body.Code)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
)
|
||||
|
||||
// newListHandler returns the handler for `GET /api/v1/internal/runtimes`.
|
||||
// The handler reads directly from `ports.RuntimeRecordStore.List` —
|
||||
// this surface is read-only and does not produce operation_log rows
|
||||
// (rationale: see `rtmanager/docs/services.md` §18).
|
||||
func newListHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.list")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.RuntimeRecords == nil {
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"runtime records store is not wired",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
records, err := deps.RuntimeRecords.List(request.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "list runtime records",
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"failed to list runtime records",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimesList(records))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: galaxy/rtmanager/internal/api/internalhttp/handlers (interfaces: StartService,StopService,RestartService,PatchService,CleanupService)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=mocks/mock_services.go -package=mocks galaxy/rtmanager/internal/api/internalhttp/handlers StartService,StopService,RestartService,PatchService,CleanupService
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
cleanupcontainer "galaxy/rtmanager/internal/service/cleanupcontainer"
|
||||
patchruntime "galaxy/rtmanager/internal/service/patchruntime"
|
||||
restartruntime "galaxy/rtmanager/internal/service/restartruntime"
|
||||
startruntime "galaxy/rtmanager/internal/service/startruntime"
|
||||
stopruntime "galaxy/rtmanager/internal/service/stopruntime"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockStartService is a mock of StartService interface.
|
||||
type MockStartService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockStartServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockStartServiceMockRecorder is the mock recorder for MockStartService.
|
||||
type MockStartServiceMockRecorder struct {
|
||||
mock *MockStartService
|
||||
}
|
||||
|
||||
// NewMockStartService creates a new mock instance.
|
||||
func NewMockStartService(ctrl *gomock.Controller) *MockStartService {
|
||||
mock := &MockStartService{ctrl: ctrl}
|
||||
mock.recorder = &MockStartServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockStartService) EXPECT() *MockStartServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockStartService) Handle(ctx context.Context, in startruntime.Input) (startruntime.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(startruntime.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockStartServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockStartService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockStopService is a mock of StopService interface.
|
||||
type MockStopService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockStopServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockStopServiceMockRecorder is the mock recorder for MockStopService.
|
||||
type MockStopServiceMockRecorder struct {
|
||||
mock *MockStopService
|
||||
}
|
||||
|
||||
// NewMockStopService creates a new mock instance.
|
||||
func NewMockStopService(ctrl *gomock.Controller) *MockStopService {
|
||||
mock := &MockStopService{ctrl: ctrl}
|
||||
mock.recorder = &MockStopServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockStopService) EXPECT() *MockStopServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockStopService) Handle(ctx context.Context, in stopruntime.Input) (stopruntime.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(stopruntime.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockStopServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockStopService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockRestartService is a mock of RestartService interface.
|
||||
type MockRestartService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRestartServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRestartServiceMockRecorder is the mock recorder for MockRestartService.
|
||||
type MockRestartServiceMockRecorder struct {
|
||||
mock *MockRestartService
|
||||
}
|
||||
|
||||
// NewMockRestartService creates a new mock instance.
|
||||
func NewMockRestartService(ctrl *gomock.Controller) *MockRestartService {
|
||||
mock := &MockRestartService{ctrl: ctrl}
|
||||
mock.recorder = &MockRestartServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRestartService) EXPECT() *MockRestartServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockRestartService) Handle(ctx context.Context, in restartruntime.Input) (restartruntime.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(restartruntime.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockRestartServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockRestartService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockPatchService is a mock of PatchService interface.
|
||||
type MockPatchService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockPatchServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockPatchServiceMockRecorder is the mock recorder for MockPatchService.
|
||||
type MockPatchServiceMockRecorder struct {
|
||||
mock *MockPatchService
|
||||
}
|
||||
|
||||
// NewMockPatchService creates a new mock instance.
|
||||
func NewMockPatchService(ctrl *gomock.Controller) *MockPatchService {
|
||||
mock := &MockPatchService{ctrl: ctrl}
|
||||
mock.recorder = &MockPatchServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockPatchService) EXPECT() *MockPatchServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockPatchService) Handle(ctx context.Context, in patchruntime.Input) (patchruntime.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(patchruntime.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockPatchServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockPatchService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockCleanupService is a mock of CleanupService interface.
|
||||
type MockCleanupService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockCleanupServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockCleanupServiceMockRecorder is the mock recorder for MockCleanupService.
|
||||
type MockCleanupServiceMockRecorder struct {
|
||||
mock *MockCleanupService
|
||||
}
|
||||
|
||||
// NewMockCleanupService creates a new mock instance.
|
||||
func NewMockCleanupService(ctrl *gomock.Controller) *MockCleanupService {
|
||||
mock := &MockCleanupService{ctrl: ctrl}
|
||||
mock.recorder = &MockCleanupServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockCleanupService) EXPECT() *MockCleanupServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockCleanupService) Handle(ctx context.Context, in cleanupcontainer.Input) (cleanupcontainer.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(cleanupcontainer.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockCleanupServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockCleanupService)(nil).Handle), ctx, in)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/rtmanager/internal/domain/operation"
|
||||
"galaxy/rtmanager/internal/service/patchruntime"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
)
|
||||
|
||||
// patchRequestBody mirrors the OpenAPI PatchRequest schema. The
|
||||
// service layer validates `image_ref` shape (semver, distribution
|
||||
// reference) and surfaces `image_ref_not_semver` /
|
||||
// `semver_patch_only` as needed.
|
||||
type patchRequestBody struct {
|
||||
ImageRef string `json:"image_ref"`
|
||||
}
|
||||
|
||||
// newPatchHandler returns the handler for
|
||||
// `POST /api/v1/internal/runtimes/{game_id}/patch`.
|
||||
func newPatchHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.patch")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.PatchRuntime == nil {
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"patch runtime service is not wired",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body patchRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest,
|
||||
startruntime.ErrorCodeInvalidRequest,
|
||||
err.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.PatchRuntime.Handle(request.Context(), patchruntime.Input{
|
||||
GameID: gameID,
|
||||
NewImageRef: body.ImageRef,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "patch runtime service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"patch runtime service failed",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/rtmanager/internal/domain/operation"
|
||||
"galaxy/rtmanager/internal/service/restartruntime"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
)
|
||||
|
||||
// newRestartHandler returns the handler for
|
||||
// `POST /api/v1/internal/runtimes/{game_id}/restart`. The OpenAPI spec
|
||||
// declares no request body for this operation; any client-provided
|
||||
// body is ignored.
|
||||
func newRestartHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.restart")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.RestartRuntime == nil {
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"restart runtime service is not wired",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.RestartRuntime.Handle(request.Context(), restartruntime.Input{
|
||||
GameID: gameID,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "restart runtime service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"restart runtime service failed",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Package handlers ships the GM/Admin-facing internal REST surface of
|
||||
// Runtime Manager. The package is consumed by
|
||||
// `galaxy/rtmanager/internal/api/internalhttp`; each handler delegates
|
||||
// to one of the lifecycle services in `internal/service/`
|
||||
// (`startruntime`, `stopruntime`, `restartruntime`, `patchruntime`,
|
||||
// `cleanupcontainer`) or reads directly from `ports.RuntimeRecordStore`
|
||||
// (list / get).
|
||||
//
|
||||
// The interfaces declared in this file mirror the single `Handle`
|
||||
// method exposed by every concrete lifecycle service. Production wiring
|
||||
// passes the concrete service pointers; tests pass `mockgen`-generated
|
||||
// mocks. The narrow shape keeps the handler layer free of service
|
||||
// internals (lease tokens, telemetry, durable side effects) and matches
|
||||
// the repo-wide `mockgen` convention for wide / recorder ports.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/rtmanager/internal/service/cleanupcontainer"
|
||||
"galaxy/rtmanager/internal/service/patchruntime"
|
||||
"galaxy/rtmanager/internal/service/restartruntime"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
"galaxy/rtmanager/internal/service/stopruntime"
|
||||
)
|
||||
|
||||
//go:generate go run go.uber.org/mock/mockgen -destination=mocks/mock_services.go -package=mocks galaxy/rtmanager/internal/api/internalhttp/handlers StartService,StopService,RestartService,PatchService,CleanupService
|
||||
|
||||
// StartService is the narrow port the start handler depends on. It
|
||||
// matches the public Handle method of `startruntime.Service`; the
|
||||
// concrete service satisfies the interface implicitly.
|
||||
type StartService interface {
|
||||
Handle(ctx context.Context, in startruntime.Input) (startruntime.Result, error)
|
||||
}
|
||||
|
||||
// StopService is the narrow port the stop handler depends on.
|
||||
type StopService interface {
|
||||
Handle(ctx context.Context, in stopruntime.Input) (stopruntime.Result, error)
|
||||
}
|
||||
|
||||
// RestartService is the narrow port the restart handler depends on.
|
||||
type RestartService interface {
|
||||
Handle(ctx context.Context, in restartruntime.Input) (restartruntime.Result, error)
|
||||
}
|
||||
|
||||
// PatchService is the narrow port the patch handler depends on.
|
||||
type PatchService interface {
|
||||
Handle(ctx context.Context, in patchruntime.Input) (patchruntime.Result, error)
|
||||
}
|
||||
|
||||
// CleanupService is the narrow port the cleanup handler depends on.
|
||||
type CleanupService interface {
|
||||
Handle(ctx context.Context, in cleanupcontainer.Input) (cleanupcontainer.Result, error)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/rtmanager/internal/domain/operation"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
)
|
||||
|
||||
// startRequestBody mirrors the OpenAPI StartRequest schema. Only
|
||||
// `image_ref` is accepted; unknown fields are rejected by
|
||||
// decodeStrictJSON.
|
||||
type startRequestBody struct {
|
||||
ImageRef string `json:"image_ref"`
|
||||
}
|
||||
|
||||
// newStartHandler returns the handler for
|
||||
// `POST /api/v1/internal/runtimes/{game_id}/start`. The handler
|
||||
// delegates the entire lifecycle to `startruntime.Service`; failure
|
||||
// codes are mapped to HTTP statuses via mapErrorCodeToStatus.
|
||||
func newStartHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.start")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.StartRuntime == nil {
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"start runtime service is not wired",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body startRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest,
|
||||
startruntime.ErrorCodeInvalidRequest,
|
||||
err.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.StartRuntime.Handle(request.Context(), startruntime.Input{
|
||||
GameID: gameID,
|
||||
ImageRef: body.ImageRef,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "start runtime service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"start runtime service failed",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/rtmanager/internal/domain/operation"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
"galaxy/rtmanager/internal/service/stopruntime"
|
||||
)
|
||||
|
||||
// stopRequestBody mirrors the OpenAPI StopRequest schema. The reason
|
||||
// enum is validated at the service layer (`stopruntime.Input.Validate`);
|
||||
// unknown values surface as `invalid_request`.
|
||||
type stopRequestBody struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// newStopHandler returns the handler for
|
||||
// `POST /api/v1/internal/runtimes/{game_id}/stop`.
|
||||
func newStopHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.stop")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.StopRuntime == nil {
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"stop runtime service is not wired",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body stopRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest,
|
||||
startruntime.ErrorCodeInvalidRequest,
|
||||
err.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.StopRuntime.Handle(request.Context(), stopruntime.Input{
|
||||
GameID: gameID,
|
||||
Reason: stopruntime.StopReason(body.Reason),
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "stop runtime service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError,
|
||||
startruntime.ErrorCodeInternal,
|
||||
"stop runtime service failed",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// Package internalhttp provides the trusted internal HTTP listener used
|
||||
// by the runnable Runtime Manager process. It exposes `/healthz` and
|
||||
// `/readyz` plus the GM/Admin REST surface backed by the lifecycle
|
||||
// services in `internal/service/`.
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/rtmanager/internal/api/internalhttp/handlers"
|
||||
"galaxy/rtmanager/internal/ports"
|
||||
"galaxy/rtmanager/internal/telemetry"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
// errorCodeServiceUnavailable mirrors the stable error code declared in
|
||||
// `rtmanager/api/internal-openapi.yaml` `§Error Model`.
|
||||
const errorCodeServiceUnavailable = "service_unavailable"
|
||||
|
||||
// HealthzPath and ReadyzPath are the internal probe routes documented in
|
||||
// `rtmanager/api/internal-openapi.yaml`.
|
||||
const (
|
||||
HealthzPath = "/healthz"
|
||||
ReadyzPath = "/readyz"
|
||||
)
|
||||
|
||||
// ReadinessProbe reports whether the dependencies the listener guards
|
||||
// (PostgreSQL, Redis, Docker) are reachable. A non-nil error is reported
|
||||
// to the caller as `503 service_unavailable` with the wrapped message.
|
||||
type ReadinessProbe interface {
|
||||
Check(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Config describes the trusted internal HTTP listener owned by Runtime
|
||||
// Manager.
|
||||
type Config struct {
|
||||
// Addr is the TCP listen address used by the internal HTTP server.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds how long the listener may spend reading
|
||||
// request headers before the server rejects the connection.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds how long the listener may spend reading one
|
||||
// request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// WriteTimeout bounds how long the listener may spend writing one
|
||||
// response.
|
||||
WriteTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long the listener keeps an idle keep-alive
|
||||
// connection open.
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.Addr == "":
|
||||
return errors.New("internal HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return errors.New("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return errors.New("internal HTTP read timeout must be positive")
|
||||
case cfg.WriteTimeout <= 0:
|
||||
return errors.New("internal HTTP write timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return errors.New("internal HTTP idle timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies describes the collaborators used by the internal HTTP
|
||||
// transport layer. The listener still works when the lifecycle service
|
||||
// fields are zero — handlers register but each returns
|
||||
// `500 internal_error` until the runtime wires the real services.
|
||||
type Dependencies struct {
|
||||
// Logger writes structured listener lifecycle logs. When nil,
|
||||
// slog.Default is used.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Telemetry records low-cardinality probe metrics and lifecycle
|
||||
// events.
|
||||
Telemetry *telemetry.Runtime
|
||||
|
||||
// Readiness reports whether PG / Redis / Docker are reachable. A
|
||||
// nil readiness probe makes `/readyz` always answer `200`; the
|
||||
// runtime always supplies a real probe in production wiring.
|
||||
Readiness ReadinessProbe
|
||||
|
||||
// RuntimeRecords backs the read-only list/get handlers. When nil
|
||||
// those routes return `500 internal_error`.
|
||||
RuntimeRecords ports.RuntimeRecordStore
|
||||
|
||||
// StartRuntime, StopRuntime, RestartRuntime, PatchRuntime, and
|
||||
// CleanupContainer back the lifecycle handlers. Each accepts a
|
||||
// narrow interface so tests can pass `mockgen`-generated mocks;
|
||||
// production wiring passes the concrete `*<lifecycle>.Service`
|
||||
// pointer.
|
||||
StartRuntime handlers.StartService
|
||||
StopRuntime handlers.StopService
|
||||
RestartRuntime handlers.RestartService
|
||||
PatchRuntime handlers.PatchService
|
||||
CleanupContainer handlers.CleanupService
|
||||
}
|
||||
|
||||
// Server owns the trusted internal HTTP listener exposed by Runtime
|
||||
// Manager.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
|
||||
handler http.Handler
|
||||
logger *slog.Logger
|
||||
metrics *telemetry.Runtime
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs one trusted internal HTTP server for cfg and deps.
|
||||
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: newHandler(deps, logger),
|
||||
logger: logger.With("component", "internal_http"),
|
||||
metrics: deps.Telemetry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Addr returns the currently bound listener address after Run is called.
|
||||
// It returns an empty string if the server has not yet bound a listener.
|
||||
func (server *Server) Addr() string {
|
||||
server.stateMu.RLock()
|
||||
defer server.stateMu.RUnlock()
|
||||
if server.listener == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return server.listener.Addr().String()
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the internal HTTP surface
|
||||
// until Shutdown closes the server.
|
||||
func (server *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run internal HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", server.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: server.handler,
|
||||
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: server.cfg.ReadTimeout,
|
||||
WriteTimeout: server.cfg.WriteTimeout,
|
||||
IdleTimeout: server.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
server.stateMu.Lock()
|
||||
server.server = httpServer
|
||||
server.listener = listener
|
||||
server.stateMu.Unlock()
|
||||
|
||||
server.logger.Info("rtmanager internal HTTP server started", "addr", listener.Addr().String())
|
||||
|
||||
defer func() {
|
||||
server.stateMu.Lock()
|
||||
server.server = nil
|
||||
server.listener = nil
|
||||
server.stateMu.Unlock()
|
||||
}()
|
||||
|
||||
err = httpServer.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
server.logger.Info("rtmanager internal HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the internal HTTP server within ctx.
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown internal HTTP server: nil context")
|
||||
}
|
||||
|
||||
server.stateMu.RLock()
|
||||
httpServer := server.server
|
||||
server.stateMu.RUnlock()
|
||||
|
||||
if httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHandler(deps Dependencies, logger *slog.Logger) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET "+HealthzPath, handleHealthz)
|
||||
mux.HandleFunc("GET "+ReadyzPath, handleReadyz(deps.Readiness, logger))
|
||||
|
||||
handlers.Register(mux, handlers.Dependencies{
|
||||
Logger: logger,
|
||||
RuntimeRecords: deps.RuntimeRecords,
|
||||
StartRuntime: deps.StartRuntime,
|
||||
StopRuntime: deps.StopRuntime,
|
||||
RestartRuntime: deps.RestartRuntime,
|
||||
PatchRuntime: deps.PatchRuntime,
|
||||
CleanupContainer: deps.CleanupContainer,
|
||||
})
|
||||
|
||||
metrics := deps.Telemetry
|
||||
options := []otelhttp.Option{}
|
||||
if metrics != nil {
|
||||
options = append(options,
|
||||
otelhttp.WithTracerProvider(metrics.TracerProvider()),
|
||||
otelhttp.WithMeterProvider(metrics.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
return otelhttp.NewHandler(withObservability(mux, metrics), "rtmanager.internal_http", options...)
|
||||
}
|
||||
|
||||
func withObservability(next http.Handler, metrics *telemetry.Runtime) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
startedAt := time.Now()
|
||||
recorder := &statusRecorder{
|
||||
ResponseWriter: writer,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(recorder, request)
|
||||
|
||||
route := request.Pattern
|
||||
switch recorder.statusCode {
|
||||
case http.StatusMethodNotAllowed:
|
||||
route = "method_not_allowed"
|
||||
case http.StatusNotFound:
|
||||
route = "not_found"
|
||||
case 0:
|
||||
route = "unmatched"
|
||||
}
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
|
||||
if metrics != nil {
|
||||
metrics.RecordInternalHTTPRequest(
|
||||
request.Context(),
|
||||
[]attribute.KeyValue{
|
||||
attribute.String("route", route),
|
||||
attribute.String("method", request.Method),
|
||||
attribute.String("status_code", strconv.Itoa(recorder.statusCode)),
|
||||
},
|
||||
time.Since(startedAt),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func handleHealthz(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatusResponse(writer, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
func handleReadyz(probe ReadinessProbe, logger *slog.Logger) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if probe == nil {
|
||||
writeStatusResponse(writer, http.StatusOK, "ready")
|
||||
return
|
||||
}
|
||||
|
||||
if err := probe.Check(request.Context()); err != nil {
|
||||
logger.WarnContext(request.Context(), "rtmanager readiness probe failed",
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeServiceUnavailable(writer, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeStatusResponse(writer, http.StatusOK, "ready")
|
||||
}
|
||||
}
|
||||
|
||||
func writeStatusResponse(writer http.ResponseWriter, statusCode int, status string) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(statusResponse{Status: status})
|
||||
}
|
||||
|
||||
func writeServiceUnavailable(writer http.ResponseWriter, message string) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(writer).Encode(errorResponse{
|
||||
Error: errorBody{
|
||||
Code: errorCodeServiceUnavailable,
|
||||
Message: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (recorder *statusRecorder) WriteHeader(statusCode int) {
|
||||
recorder.statusCode = statusCode
|
||||
recorder.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestConfig() Config {
|
||||
return Config{
|
||||
Addr: ":0",
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
WriteTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
type stubReadiness struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (probe stubReadiness) Check(_ context.Context) error {
|
||||
return probe.err
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, deps Dependencies) http.Handler {
|
||||
t.Helper()
|
||||
server, err := NewServer(newTestConfig(), deps)
|
||||
require.NoError(t, err)
|
||||
return server.handler
|
||||
}
|
||||
|
||||
func TestHealthzReturnsOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, HealthzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, jsonContentType, rec.Header().Get("Content-Type"))
|
||||
|
||||
var body statusResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, "ok", body.Status)
|
||||
}
|
||||
|
||||
func TestReadyzReturnsReadyWhenProbeIsNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var body statusResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, "ready", body.Status)
|
||||
}
|
||||
|
||||
func TestReadyzReturnsReadyWhenProbeSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{Readiness: stubReadiness{}})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var body statusResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, "ready", body.Status)
|
||||
}
|
||||
|
||||
func TestReadyzReturnsServiceUnavailableWhenProbeFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{
|
||||
Readiness: stubReadiness{err: errors.New("postgres ping: connection refused")},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
require.Equal(t, jsonContentType, rec.Header().Get("Content-Type"))
|
||||
|
||||
var body errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, errorCodeServiceUnavailable, body.Error.Code)
|
||||
require.True(t, strings.Contains(body.Error.Message, "postgres"))
|
||||
}
|
||||
|
||||
func TestNewServerRejectsInvalidConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewServer(Config{}, Dependencies{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user