Files
2026-05-06 10:14:55 +03:00

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