239 lines
8.2 KiB
Go
239 lines
8.2 KiB
Go
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)
|
|
}
|