feat: backend service
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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"`
|
||||
}
|
||||
Reference in New Issue
Block a user