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