203 lines
6.1 KiB
Go
203 lines
6.1 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"galaxy/backend/internal/runtime"
|
|
"galaxy/backend/internal/server/handlers"
|
|
"galaxy/backend/internal/server/httperr"
|
|
"galaxy/backend/internal/telemetry"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// AdminRuntimesHandlers groups the admin-side runtime handlers under
|
|
// `/api/v1/admin/runtimes/*`. The implementation swaps the placeholder bodies
|
|
// for real `*runtime.Service` calls; tests that omit the service fall
|
|
// back to 501.
|
|
type AdminRuntimesHandlers struct {
|
|
svc *runtime.Service
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewAdminRuntimesHandlers constructs the handler set. svc may be
|
|
// nil — placeholders are returned in that case.
|
|
func NewAdminRuntimesHandlers(svc *runtime.Service, logger *zap.Logger) *AdminRuntimesHandlers {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &AdminRuntimesHandlers{svc: svc, logger: logger.Named("http.admin.runtimes")}
|
|
}
|
|
|
|
// Get handles GET /api/v1/admin/runtimes/{game_id}.
|
|
func (h *AdminRuntimesHandlers) Get() gin.HandlerFunc {
|
|
if h == nil || h.svc == nil {
|
|
return handlers.NotImplemented("adminRuntimesGet")
|
|
}
|
|
return func(c *gin.Context) {
|
|
gameID, ok := parseGameIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
rec, err := h.svc.GetRuntime(ctx, gameID)
|
|
if err != nil {
|
|
respondRuntimeError(c, h.logger, "admin runtimes get", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, runtimeRecordToWire(rec))
|
|
}
|
|
}
|
|
|
|
// Restart handles POST /api/v1/admin/runtimes/{game_id}/restart.
|
|
func (h *AdminRuntimesHandlers) Restart() gin.HandlerFunc {
|
|
if h == nil || h.svc == nil {
|
|
return handlers.NotImplemented("adminRuntimesRestart")
|
|
}
|
|
return func(c *gin.Context) {
|
|
gameID, ok := parseGameIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
op, err := h.svc.AdminRestart(ctx, gameID)
|
|
if err != nil {
|
|
respondRuntimeError(c, h.logger, "admin runtimes restart", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusAccepted, runtimeOperationToWire(op))
|
|
}
|
|
}
|
|
|
|
// Patch handles POST /api/v1/admin/runtimes/{game_id}/patch.
|
|
func (h *AdminRuntimesHandlers) Patch() gin.HandlerFunc {
|
|
if h == nil || h.svc == nil {
|
|
return handlers.NotImplemented("adminRuntimesPatch")
|
|
}
|
|
return func(c *gin.Context) {
|
|
var req runtimePatchRequestWire
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
|
return
|
|
}
|
|
gameID, ok := parseGameIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
op, err := h.svc.AdminPatch(ctx, gameID, req.TargetVersion)
|
|
if err != nil {
|
|
respondRuntimeError(c, h.logger, "admin runtimes patch", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusAccepted, runtimeOperationToWire(op))
|
|
}
|
|
}
|
|
|
|
// ForceNextTurn handles POST /api/v1/admin/runtimes/{game_id}/force-next-turn.
|
|
func (h *AdminRuntimesHandlers) ForceNextTurn() gin.HandlerFunc {
|
|
if h == nil || h.svc == nil {
|
|
return handlers.NotImplemented("adminRuntimesForceNextTurn")
|
|
}
|
|
return func(c *gin.Context) {
|
|
gameID, ok := parseGameIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
op, err := h.svc.AdminForceNextTurn(ctx, gameID)
|
|
if err != nil {
|
|
respondRuntimeError(c, h.logger, "admin runtimes force-next-turn", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, runtimeOperationToWire(op))
|
|
}
|
|
}
|
|
|
|
func respondRuntimeError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
|
switch {
|
|
case errors.Is(err, runtime.ErrNotFound):
|
|
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "runtime record not found")
|
|
case errors.Is(err, runtime.ErrInvalidInput),
|
|
errors.Is(err, runtime.ErrPatchSemverIncompatible):
|
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
|
case errors.Is(err, runtime.ErrConflict),
|
|
errors.Is(err, runtime.ErrEngineVersionDisabled):
|
|
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
|
|
case errors.Is(err, runtime.ErrJobQueueFull):
|
|
httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "runtime worker queue full, retry later")
|
|
default:
|
|
logger.Error(op+" failed",
|
|
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
|
)
|
|
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
|
|
}
|
|
}
|
|
|
|
// runtimeRecordWire mirrors `RuntimeRecord` from openapi.yaml. The
|
|
// schema declares `additionalProperties: true`, so we serialise the
|
|
// minimal documented shape.
|
|
type runtimeRecordWire struct {
|
|
GameID string `json:"game_id"`
|
|
Status string `json:"status"`
|
|
CurrentContainerID string `json:"current_container_id,omitempty"`
|
|
ImageRef string `json:"image_ref,omitempty"`
|
|
StartedAt *string `json:"started_at,omitempty"`
|
|
LastObservedAt *string `json:"last_observed_at,omitempty"`
|
|
}
|
|
|
|
func runtimeRecordToWire(r runtime.RuntimeRecord) runtimeRecordWire {
|
|
out := runtimeRecordWire{
|
|
GameID: r.GameID.String(),
|
|
Status: r.Status,
|
|
CurrentContainerID: r.CurrentContainerID,
|
|
ImageRef: r.CurrentImageRef,
|
|
}
|
|
if r.StartedAt != nil {
|
|
s := r.StartedAt.UTC().Format(timestampLayout)
|
|
out.StartedAt = &s
|
|
}
|
|
if r.LastObservedAt != nil {
|
|
s := r.LastObservedAt.UTC().Format(timestampLayout)
|
|
out.LastObservedAt = &s
|
|
}
|
|
return out
|
|
}
|
|
|
|
// runtimeOperationWire mirrors `RuntimeOperation` from openapi.yaml.
|
|
type runtimeOperationWire struct {
|
|
OperationID string `json:"operation_id"`
|
|
GameID string `json:"game_id"`
|
|
Op string `json:"op"`
|
|
Status string `json:"status"`
|
|
StartedAt string `json:"started_at"`
|
|
FinishedAt *string `json:"finished_at,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func runtimeOperationToWire(op runtime.OperationLog) runtimeOperationWire {
|
|
out := runtimeOperationWire{
|
|
OperationID: op.OperationID.String(),
|
|
GameID: op.GameID.String(),
|
|
Op: op.Op,
|
|
Status: op.Status,
|
|
StartedAt: op.StartedAt.UTC().Format(timestampLayout),
|
|
}
|
|
if op.FinishedAt != nil {
|
|
s := op.FinishedAt.UTC().Format(timestampLayout)
|
|
out.FinishedAt = &s
|
|
}
|
|
if op.ErrorMessage != "" {
|
|
out.Error = op.ErrorMessage
|
|
}
|
|
return out
|
|
}
|
|
|
|
// runtimePatchRequestWire mirrors `RuntimePatchRequest`.
|
|
type runtimePatchRequestWire struct {
|
|
TargetVersion string `json:"target_version"`
|
|
}
|