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) }