feat: backend service
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/util"
|
||||
)
|
||||
|
||||
// EngineVersionService implements the engine-version registry CRUD
|
||||
// surface consumed by the admin endpoints under
|
||||
// `/api/v1/admin/engine-versions/*`. Mutations are write-through: a
|
||||
// successful Postgres write is followed by a cache update so warm
|
||||
// reads observe the new state immediately.
|
||||
type EngineVersionService struct {
|
||||
store *Store
|
||||
cache *Cache
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewEngineVersionService constructs the service. now defaults to
|
||||
// time.Now when nil.
|
||||
func NewEngineVersionService(store *Store, cache *Cache, now func() time.Time) *EngineVersionService {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &EngineVersionService{store: store, cache: cache, now: now}
|
||||
}
|
||||
|
||||
// List returns every engine_versions row ordered by created_at DESC.
|
||||
// Cache-first when warm; falls back to a Postgres read otherwise.
|
||||
func (s *EngineVersionService) List(ctx context.Context) ([]EngineVersion, error) {
|
||||
if s.cache != nil && s.cache.Ready() {
|
||||
out := s.cache.ListEngineVersions()
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||
return out[i].CreatedAt.After(out[j].CreatedAt)
|
||||
}
|
||||
return out[i].Version > out[j].Version
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
return s.store.ListEngineVersions(ctx)
|
||||
}
|
||||
|
||||
// Get returns the row for version. Returns ErrNotFound on miss.
|
||||
func (s *EngineVersionService) Get(ctx context.Context, version string) (EngineVersion, error) {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return EngineVersion{}, fmt.Errorf("%w: version must not be empty", ErrInvalidInput)
|
||||
}
|
||||
if s.cache != nil {
|
||||
if v, ok := s.cache.GetEngineVersion(version); ok {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
v, err := s.store.GetEngineVersion(ctx, version)
|
||||
if err != nil {
|
||||
return EngineVersion{}, err
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.PutEngineVersion(v)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// RegisterInput is the parameter struct for Register.
|
||||
type RegisterInput struct {
|
||||
Version string
|
||||
ImageRef string
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
// Validate normalises the request and rejects empty / malformed
|
||||
// fields. Semver is enforced via `pkg/util.ParseSemver`.
|
||||
func (in *RegisterInput) Validate() error {
|
||||
in.Version = strings.TrimSpace(in.Version)
|
||||
in.ImageRef = strings.TrimSpace(in.ImageRef)
|
||||
if in.Version == "" {
|
||||
return fmt.Errorf("%w: version must not be empty", ErrInvalidInput)
|
||||
}
|
||||
if _, err := util.ParseSemver(in.Version); err != nil {
|
||||
return fmt.Errorf("%w: version %q is not a valid semver: %v", ErrInvalidInput, in.Version, err)
|
||||
}
|
||||
if in.ImageRef == "" {
|
||||
return fmt.Errorf("%w: image_ref must not be empty", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register persists a fresh engine_versions row. Returns
|
||||
// ErrEngineVersionTaken on duplicate version.
|
||||
func (s *EngineVersionService) Register(ctx context.Context, in RegisterInput) (EngineVersion, error) {
|
||||
if err := (&in).Validate(); err != nil {
|
||||
return EngineVersion{}, err
|
||||
}
|
||||
enabled := true
|
||||
if in.Enabled != nil {
|
||||
enabled = *in.Enabled
|
||||
}
|
||||
now := s.now().UTC()
|
||||
v, err := s.store.InsertEngineVersion(ctx, in.Version, in.ImageRef, enabled, now)
|
||||
if err != nil {
|
||||
return EngineVersion{}, err
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.PutEngineVersion(v)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// UpdateInput is the parameter struct for Update. Nil pointers leave
|
||||
// the corresponding column alone.
|
||||
type UpdateInput struct {
|
||||
ImageRef *string
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
// Update patches mutable fields on an existing row.
|
||||
func (s *EngineVersionService) Update(ctx context.Context, version string, in UpdateInput) (EngineVersion, error) {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return EngineVersion{}, fmt.Errorf("%w: version must not be empty", ErrInvalidInput)
|
||||
}
|
||||
patch := engineVersionUpdate{Enabled: in.Enabled}
|
||||
if in.ImageRef != nil {
|
||||
trimmed := strings.TrimSpace(*in.ImageRef)
|
||||
if trimmed == "" {
|
||||
return EngineVersion{}, fmt.Errorf("%w: image_ref must not be empty", ErrInvalidInput)
|
||||
}
|
||||
patch.ImageRef = &trimmed
|
||||
}
|
||||
now := s.now().UTC()
|
||||
v, err := s.store.UpdateEngineVersion(ctx, version, patch, now)
|
||||
if err != nil {
|
||||
return EngineVersion{}, err
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.PutEngineVersion(v)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Disable flips the enabled flag to false. Idempotent.
|
||||
func (s *EngineVersionService) Disable(ctx context.Context, version string) (EngineVersion, error) {
|
||||
disabled := false
|
||||
return s.Update(ctx, version, UpdateInput{Enabled: &disabled})
|
||||
}
|
||||
|
||||
// Resolve returns the row for version, rejecting disabled rows with
|
||||
// ErrEngineVersionDisabled. Used by `Service.StartGame` /
|
||||
// `AdminPatch` / `AdminRestart` before the docker pull.
|
||||
func (s *EngineVersionService) Resolve(ctx context.Context, version string) (EngineVersion, error) {
|
||||
v, err := s.Get(ctx, version)
|
||||
if err != nil {
|
||||
return EngineVersion{}, err
|
||||
}
|
||||
if !v.Enabled {
|
||||
return EngineVersion{}, fmt.Errorf("%w: %s", ErrEngineVersionDisabled, v.Version)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// CheckPatchCompatible verifies the requested target version stays
|
||||
// inside the same major+minor line as `currentVersion`. Returns
|
||||
// ErrPatchSemverIncompatible otherwise.
|
||||
func CheckPatchCompatible(currentVersion, targetVersion string) error {
|
||||
current, err := util.ParseSemver(currentVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: current version %q: %v", ErrInvalidInput, currentVersion, err)
|
||||
}
|
||||
target, err := util.ParseSemver(targetVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: target version %q: %v", ErrInvalidInput, targetVersion, err)
|
||||
}
|
||||
if current.Major != target.Major || current.Minor != target.Minor {
|
||||
return fmt.Errorf("%w: %s -> %s", ErrPatchSemverIncompatible, currentVersion, targetVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsKnownEngineVersion is a small helper used by tests and handlers.
|
||||
func IsKnownEngineVersion(err error) bool {
|
||||
return errors.Is(err, ErrEngineVersionDisabled) || errors.Is(err, ErrPatchSemverIncompatible)
|
||||
}
|
||||
Reference in New Issue
Block a user