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"` }