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