feat: runtime manager
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user