feat: gamemaster
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// Package engineversion defines the engine version registry domain
|
||||
// model owned by Game Master.
|
||||
//
|
||||
// The registry mirrors the durable shape of the `engine_versions`
|
||||
// PostgreSQL table (see
|
||||
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`)
|
||||
// and the user-visible status enum frozen in
|
||||
// `galaxy/gamemaster/api/internal-openapi.yaml`.
|
||||
//
|
||||
// `Options` is intentionally kept opaque ([]byte holding raw JSON) so
|
||||
// the v1 service does not impose a Go-side schema on the engine-owned
|
||||
// document. Schema-aware handling lands when an engine version actually
|
||||
// requires it; until then the registry is a pass-through store.
|
||||
package engineversion
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status identifies one engine-version registry state.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusActive marks a version as deployable. Lobby's start flow
|
||||
// resolves image refs only against active versions.
|
||||
StatusActive Status = "active"
|
||||
|
||||
// StatusDeprecated marks a version as no longer offered for new
|
||||
// starts. Already-running games on a deprecated version are
|
||||
// unaffected; the runtime stays bound to the version it started on.
|
||||
StatusDeprecated Status = "deprecated"
|
||||
)
|
||||
|
||||
// IsKnown reports whether status belongs to the frozen engine-version
|
||||
// status vocabulary.
|
||||
func (status Status) IsKnown() bool {
|
||||
switch status {
|
||||
case StatusActive, StatusDeprecated:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AllStatuses returns the frozen list of every engine-version status
|
||||
// value. The slice order is stable across calls.
|
||||
func AllStatuses() []Status {
|
||||
return []Status{StatusActive, StatusDeprecated}
|
||||
}
|
||||
|
||||
// EngineVersion stores one row of the `engine_versions` registry table.
|
||||
// Options carries the raw `jsonb` document verbatim so the registry
|
||||
// stays decoupled from any engine-side schema.
|
||||
type EngineVersion struct {
|
||||
// Version stores the canonical semver string (primary key).
|
||||
Version string
|
||||
|
||||
// ImageRef stores the Docker reference of the engine image.
|
||||
ImageRef string
|
||||
|
||||
// Options stores the engine-side options document as raw JSON. Empty
|
||||
// is treated as `{}` by adapters that hydrate the column.
|
||||
Options []byte
|
||||
|
||||
// Status reports whether the version is deployable (`active`) or
|
||||
// no longer offered for new starts (`deprecated`).
|
||||
Status Status
|
||||
|
||||
// CreatedAt stores the wall-clock at which the row was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// UpdatedAt stores the wall-clock of the most recent mutation.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether record satisfies the engine-version
|
||||
// invariants implied by `engine_versions_status_chk` and the README
|
||||
// §Engine Version Registry surface.
|
||||
func (record EngineVersion) Validate() error {
|
||||
if strings.TrimSpace(record.Version) == "" {
|
||||
return fmt.Errorf("version must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(record.ImageRef) == "" {
|
||||
return fmt.Errorf("image ref must not be empty")
|
||||
}
|
||||
if !record.Status.IsKnown() {
|
||||
return fmt.Errorf("status %q is unsupported", record.Status)
|
||||
}
|
||||
if record.CreatedAt.IsZero() {
|
||||
return fmt.Errorf("created at must not be zero")
|
||||
}
|
||||
if record.UpdatedAt.IsZero() {
|
||||
return fmt.Errorf("updated at must not be zero")
|
||||
}
|
||||
if record.UpdatedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("updated at must not be before created at")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNotFound reports that an engine-version lookup failed because no
|
||||
// matching row exists.
|
||||
var ErrNotFound = errors.New("engine version not found")
|
||||
|
||||
// ErrInUse reports that a hard-delete or deprecate operation was
|
||||
// rejected because the version is still referenced by a non-finished
|
||||
// runtime record.
|
||||
var ErrInUse = errors.New("engine version in use")
|
||||
|
||||
// ErrConflict reports that an engine-version mutation could not be
|
||||
// applied because a row with the same primary key already exists.
|
||||
// Adapters surface a PostgreSQL unique-violation through this sentinel
|
||||
// so the service layer maps it to a `conflict` REST envelope.
|
||||
var ErrConflict = errors.New("engine version already exists")
|
||||
|
||||
// ErrInvalidSemver reports that a semver string did not parse against
|
||||
// `golang.org/x/mod/semver`'s grammar.
|
||||
var ErrInvalidSemver = errors.New("invalid semver")
|
||||
@@ -0,0 +1,63 @@
|
||||
package engineversion
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validVersion() EngineVersion {
|
||||
created := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
return EngineVersion{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
Options: []byte(`{"max_planets":120}`),
|
||||
Status: StatusActive,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsKnown(t *testing.T) {
|
||||
for _, status := range AllStatuses() {
|
||||
assert.True(t, status.IsKnown(), "want known: %q", status)
|
||||
}
|
||||
assert.False(t, Status("retired").IsKnown())
|
||||
assert.False(t, Status("").IsKnown())
|
||||
}
|
||||
|
||||
func TestEngineVersionValidateHappy(t *testing.T) {
|
||||
require.NoError(t, validVersion().Validate())
|
||||
}
|
||||
|
||||
func TestEngineVersionValidateAcceptsEmptyOptions(t *testing.T) {
|
||||
record := validVersion()
|
||||
record.Options = nil
|
||||
assert.NoError(t, record.Validate())
|
||||
}
|
||||
|
||||
func TestEngineVersionValidateRejects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*EngineVersion)
|
||||
}{
|
||||
{"empty version", func(v *EngineVersion) { v.Version = "" }},
|
||||
{"empty image ref", func(v *EngineVersion) { v.ImageRef = "" }},
|
||||
{"unknown status", func(v *EngineVersion) { v.Status = "exotic" }},
|
||||
{"zero created at", func(v *EngineVersion) { v.CreatedAt = time.Time{} }},
|
||||
{"zero updated at", func(v *EngineVersion) { v.UpdatedAt = time.Time{} }},
|
||||
{"updated before created", func(v *EngineVersion) {
|
||||
v.UpdatedAt = v.CreatedAt.Add(-time.Minute)
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
record := validVersion()
|
||||
tt.mutate(&record)
|
||||
assert.Error(t, record.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package engineversion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// ParseSemver normalises version into the canonical "vMAJOR.MINOR.PATCH"
|
||||
// form expected by `golang.org/x/mod/semver` and reports a wrapped
|
||||
// ErrInvalidSemver when the resulting string is not a valid full semver.
|
||||
//
|
||||
// Whitespace is trimmed; a missing leading "v" is added before the
|
||||
// validity check so callers may pass either "1.2.3" or "v1.2.3". The
|
||||
// stripped base must carry exactly three dot-separated numeric
|
||||
// components — `golang.org/x/mod/semver` accepts shortened forms such
|
||||
// as "v1" or "v1.2", but the engine-version registry requires the full
|
||||
// triple, so this function rejects anything narrower.
|
||||
func ParseSemver(version string) (string, error) {
|
||||
candidate := strings.TrimSpace(version)
|
||||
if candidate == "" {
|
||||
return "", fmt.Errorf("%w: empty", ErrInvalidSemver)
|
||||
}
|
||||
if !strings.HasPrefix(candidate, "v") {
|
||||
candidate = "v" + candidate
|
||||
}
|
||||
if !semver.IsValid(candidate) {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidSemver, version)
|
||||
}
|
||||
|
||||
base := candidate
|
||||
if i := strings.IndexAny(base, "-+"); i >= 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
if strings.Count(base, ".") != 2 {
|
||||
return "", fmt.Errorf(
|
||||
"%w: %q (need vMAJOR.MINOR.PATCH)",
|
||||
ErrInvalidSemver, version,
|
||||
)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
// IsPatchUpgrade reports whether next is a same-major.minor upgrade of
|
||||
// current. Both inputs are parsed through ParseSemver so callers may
|
||||
// pass either bare or `v`-prefixed forms. A wrapped ErrInvalidSemver is
|
||||
// returned when either argument fails to parse; the boolean result is
|
||||
// undefined in that case.
|
||||
func IsPatchUpgrade(current, next string) (bool, error) {
|
||||
curr, err := ParseSemver(current)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("current: %w", err)
|
||||
}
|
||||
nxt, err := ParseSemver(next)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("next: %w", err)
|
||||
}
|
||||
return semver.MajorMinor(curr) == semver.MajorMinor(nxt), nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package engineversion
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseSemverNormalises(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"1.2.3", "v1.2.3"},
|
||||
{"v1.2.3", "v1.2.3"},
|
||||
{" v0.4.0 ", "v0.4.0"},
|
||||
{"v2.0.0-rc.1", "v2.0.0-rc.1"},
|
||||
{"v2.0.0+build.7", "v2.0.0+build.7"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, err := ParseSemver(tt.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSemverRejects(t *testing.T) {
|
||||
tests := []string{
|
||||
"",
|
||||
" ",
|
||||
"latest",
|
||||
"1",
|
||||
"1.2",
|
||||
"v1.2",
|
||||
"1.2.3.4",
|
||||
"v1.2.x",
|
||||
}
|
||||
for _, input := range tests {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := ParseSemver(input)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrInvalidSemver))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatchUpgrade(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current string
|
||||
next string
|
||||
want bool
|
||||
}{
|
||||
{"same patch", "v1.2.3", "v1.2.3", true},
|
||||
{"patch bump", "v1.2.3", "v1.2.4", true},
|
||||
{"patch downgrade", "1.2.4", "1.2.0", true},
|
||||
{"prerelease patch", "v1.2.3", "v1.2.3-rc.1", true},
|
||||
{"minor bump", "v1.2.3", "v1.3.0", false},
|
||||
{"minor downgrade", "v1.2.3", "v1.1.9", false},
|
||||
{"major bump", "v1.2.3", "v2.0.0", false},
|
||||
{"major downgrade", "v2.0.0", "v1.9.9", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := IsPatchUpgrade(tt.current, tt.next)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatchUpgradeRejectsBadInputs(t *testing.T) {
|
||||
_, err := IsPatchUpgrade("garbage", "v1.2.3")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrInvalidSemver))
|
||||
|
||||
_, err = IsPatchUpgrade("v1.2.3", "")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrInvalidSemver))
|
||||
}
|
||||
Reference in New Issue
Block a user