feat: runtime manager

This commit is contained in:
Ilia Denisov
2026-04-28 20:39:18 +02:00
committed by GitHub
parent e0a99b346b
commit a7cee15115
289 changed files with 45660 additions and 2207 deletions
@@ -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))
}
}