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