feat: gamemaster
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package engineversion
|
||||
|
||||
// Stable error codes returned alongside service-level errors. The values
|
||||
// match the vocabulary frozen by `gamemaster/README.md §Error Model` and
|
||||
// `gamemaster/api/internal-openapi.yaml`. The handler layer (Stage 19)
|
||||
// maps the wrapped sentinel error to one of these codes; tests compare
|
||||
// against the constant.
|
||||
const (
|
||||
// ErrorCodeInvalidRequest reports that the request envelope failed
|
||||
// structural validation (empty required fields, malformed JSON
|
||||
// options, malformed semver, malformed Docker reference, partial
|
||||
// Update with no fields set, unsupported status enum).
|
||||
ErrorCodeInvalidRequest = "invalid_request"
|
||||
|
||||
// ErrorCodeConflict reports that an Insert was rejected because a
|
||||
// row with the same `version` already exists.
|
||||
ErrorCodeConflict = "conflict"
|
||||
|
||||
// ErrorCodeEngineVersionNotFound reports that the requested
|
||||
// version is not present in the registry. Returned by Get,
|
||||
// Update, Deprecate, Delete, and ResolveImageRef.
|
||||
ErrorCodeEngineVersionNotFound = "engine_version_not_found"
|
||||
|
||||
// ErrorCodeEngineVersionInUse reports that a hard-delete attempt
|
||||
// was rejected because the version is still referenced by a
|
||||
// non-finished `runtime_records` row.
|
||||
ErrorCodeEngineVersionInUse = "engine_version_in_use"
|
||||
|
||||
// ErrorCodeServiceUnavailable reports that a steady-state
|
||||
// dependency (PostgreSQL) was unreachable for this call.
|
||||
ErrorCodeServiceUnavailable = "service_unavailable"
|
||||
|
||||
// ErrorCodeInternal reports an unexpected error not classified by
|
||||
// the other codes.
|
||||
ErrorCodeInternal = "internal_error"
|
||||
)
|
||||
@@ -0,0 +1,752 @@
|
||||
// Package engineversion implements the engine version registry service
|
||||
// owned by Game Master. The service backs the
|
||||
// `/api/v1/internal/engine-versions/*` REST surface (Stage 19) and the
|
||||
// hot-path `image_ref` resolve called synchronously by Game Lobby's
|
||||
// start flow.
|
||||
//
|
||||
// Responsibilities and stable error codes are frozen by
|
||||
// `gamemaster/README.md §Engine Version Registry` and
|
||||
// `gamemaster/api/internal-openapi.yaml`. Design rationale for stage 14
|
||||
// is captured in `gamemaster/docs/stage14-engine-version-registry.md`.
|
||||
package engineversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/logging"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the service. Handlers translate these
|
||||
// into the stable `ErrorCode...` constants from `errors.go`. The
|
||||
// adapter-level sentinels (`engineversion.ErrNotFound`,
|
||||
// `engineversion.ErrConflict`, `engineversion.ErrInUse`,
|
||||
// `engineversion.ErrInvalidSemver`) are wrapped with one of the
|
||||
// service-level sentinels below before crossing the package boundary.
|
||||
var (
|
||||
// ErrInvalidRequest reports that the input envelope failed
|
||||
// structural validation.
|
||||
ErrInvalidRequest = errors.New("invalid request")
|
||||
|
||||
// ErrNotFound reports that the requested version does not exist
|
||||
// in the registry.
|
||||
ErrNotFound = errors.New("engine version not found")
|
||||
|
||||
// ErrConflict reports that an Insert was rejected because a row
|
||||
// with the same version already exists.
|
||||
ErrConflict = errors.New("engine version already exists")
|
||||
|
||||
// ErrInUse reports that a hard-delete attempt was rejected
|
||||
// because a non-finished runtime references the version.
|
||||
ErrInUse = errors.New("engine version in use")
|
||||
|
||||
// ErrServiceUnavailable reports that a steady-state dependency
|
||||
// was unreachable for this call.
|
||||
ErrServiceUnavailable = errors.New("service unavailable")
|
||||
)
|
||||
|
||||
// CreateInput stores the per-call arguments for one Create operation.
|
||||
// Mirrors `CreateEngineVersionRequest` plus the audit-only OpSource /
|
||||
// SourceRef pair.
|
||||
type CreateInput struct {
|
||||
// Version stores the canonical semver (with or without the leading
|
||||
// "v"; ParseSemver normalises it).
|
||||
Version string
|
||||
|
||||
// ImageRef stores the Docker reference of the engine image.
|
||||
// Validated against `github.com/distribution/reference` before
|
||||
// the row is persisted.
|
||||
ImageRef string
|
||||
|
||||
// Options stores the engine-side options document as raw JSON.
|
||||
// Empty means "use the schema default `{}`". When non-empty the
|
||||
// service validates the bytes parse as a JSON object.
|
||||
Options []byte
|
||||
|
||||
// OpSource classifies how the request entered Game Master.
|
||||
// Defaults to `admin_rest` when missing or unknown.
|
||||
OpSource operation.OpSource
|
||||
|
||||
// SourceRef stores the optional opaque per-source reference.
|
||||
SourceRef string
|
||||
}
|
||||
|
||||
// UpdateInput stores the per-call arguments for one Update operation.
|
||||
// Pointer fields communicate "leave alone" (nil) vs. "write the value"
|
||||
// (non-nil); at least one must be set.
|
||||
type UpdateInput struct {
|
||||
// Version identifies the row to mutate.
|
||||
Version string
|
||||
|
||||
// ImageRef is the new image reference. Nil leaves the column
|
||||
// unchanged; non-nil must be a valid Docker reference.
|
||||
ImageRef *string
|
||||
|
||||
// Options is the new options document. Nil leaves the column
|
||||
// unchanged; non-nil must be a JSON object (possibly the empty
|
||||
// object).
|
||||
Options *[]byte
|
||||
|
||||
// Status is the new registry status. Nil leaves the column
|
||||
// unchanged; non-nil must be a known status value.
|
||||
Status *engineversion.Status
|
||||
|
||||
// OpSource classifies how the request entered Game Master.
|
||||
OpSource operation.OpSource
|
||||
|
||||
// SourceRef stores the optional opaque per-source reference.
|
||||
SourceRef string
|
||||
}
|
||||
|
||||
// DeprecateInput stores the per-call arguments for one Deprecate
|
||||
// operation.
|
||||
type DeprecateInput struct {
|
||||
// Version identifies the row to deprecate.
|
||||
Version string
|
||||
|
||||
// OpSource classifies how the request entered Game Master.
|
||||
OpSource operation.OpSource
|
||||
|
||||
// SourceRef stores the optional opaque per-source reference.
|
||||
SourceRef string
|
||||
}
|
||||
|
||||
// DeleteInput stores the per-call arguments for one hard Delete
|
||||
// operation.
|
||||
type DeleteInput struct {
|
||||
// Version identifies the row to delete.
|
||||
Version string
|
||||
|
||||
// OpSource classifies how the request entered Game Master.
|
||||
OpSource operation.OpSource
|
||||
|
||||
// SourceRef stores the optional opaque per-source reference.
|
||||
SourceRef string
|
||||
}
|
||||
|
||||
// Dependencies groups the collaborators required by Service.
|
||||
type Dependencies struct {
|
||||
// EngineVersions persists the registry rows. Required.
|
||||
EngineVersions ports.EngineVersionStore
|
||||
|
||||
// OperationLogs records the audit entry for every mutation
|
||||
// (Create, Update, Deprecate, Delete). Required.
|
||||
OperationLogs ports.OperationLogStore
|
||||
|
||||
// Logger records structured service-level events. Defaults to
|
||||
// slog.Default when nil.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Clock supplies the wall-clock used for created_at / updated_at
|
||||
// and audit timestamps. Defaults to time.Now when nil.
|
||||
Clock func() time.Time
|
||||
}
|
||||
|
||||
// Service implements the engine version registry operations.
|
||||
type Service struct {
|
||||
versions ports.EngineVersionStore
|
||||
operationLogs ports.OperationLogStore
|
||||
|
||||
logger *slog.Logger
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// NewService constructs one Service from deps.
|
||||
func NewService(deps Dependencies) (*Service, error) {
|
||||
switch {
|
||||
case deps.EngineVersions == nil:
|
||||
return nil, errors.New("new engine version service: nil engine version store")
|
||||
case deps.OperationLogs == nil:
|
||||
return nil, errors.New("new engine version service: nil operation log store")
|
||||
}
|
||||
|
||||
clock := deps.Clock
|
||||
if clock == nil {
|
||||
clock = time.Now
|
||||
}
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
logger = logger.With("service", "gamemaster.engineversion")
|
||||
|
||||
return &Service{
|
||||
versions: deps.EngineVersions,
|
||||
operationLogs: deps.OperationLogs,
|
||||
logger: logger,
|
||||
clock: clock,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// List returns every registry row, optionally filtered by status. A
|
||||
// non-nil statusFilter must reference a known engineversion.Status.
|
||||
func (service *Service) List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error) {
|
||||
if service == nil {
|
||||
return nil, errors.New("engine version list: nil service")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("engine version list: nil context")
|
||||
}
|
||||
if statusFilter != nil && !statusFilter.IsKnown() {
|
||||
return nil, fmt.Errorf("%w: status %q is unsupported", ErrInvalidRequest, *statusFilter)
|
||||
}
|
||||
versions, err := service.versions.List(ctx, statusFilter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: list engine versions: %s", ErrServiceUnavailable, err.Error())
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// Get returns the registry row identified by version. Returns
|
||||
// ErrNotFound when no row matches.
|
||||
func (service *Service) Get(ctx context.Context, version string) (engineversion.EngineVersion, error) {
|
||||
if service == nil {
|
||||
return engineversion.EngineVersion{}, errors.New("engine version get: nil service")
|
||||
}
|
||||
if ctx == nil {
|
||||
return engineversion.EngineVersion{}, errors.New("engine version get: nil context")
|
||||
}
|
||||
if strings.TrimSpace(version) == "" {
|
||||
return engineversion.EngineVersion{}, fmt.Errorf("%w: version must not be empty", ErrInvalidRequest)
|
||||
}
|
||||
got, err := service.versions.Get(ctx, version)
|
||||
switch {
|
||||
case errors.Is(err, engineversion.ErrNotFound):
|
||||
return engineversion.EngineVersion{}, fmt.Errorf("%w: %q", ErrNotFound, version)
|
||||
case err != nil:
|
||||
return engineversion.EngineVersion{}, fmt.Errorf("%w: get engine version: %s", ErrServiceUnavailable, err.Error())
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// ResolveImageRef returns the image_ref of the requested version. This
|
||||
// is the hot path used by Game Lobby's start flow synchronously per
|
||||
// register-runtime envelope.
|
||||
func (service *Service) ResolveImageRef(ctx context.Context, version string) (string, error) {
|
||||
got, err := service.Get(ctx, version)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return got.ImageRef, nil
|
||||
}
|
||||
|
||||
// Create installs a fresh registry row. Validates the semver shape and
|
||||
// Docker reference before touching the store. On success appends a
|
||||
// success entry to operation_log; on classified failure (validation,
|
||||
// conflict, store error) appends a failure entry.
|
||||
func (service *Service) Create(ctx context.Context, input CreateInput) (engineversion.EngineVersion, error) {
|
||||
if service == nil {
|
||||
return engineversion.EngineVersion{}, errors.New("engine version create: nil service")
|
||||
}
|
||||
if ctx == nil {
|
||||
return engineversion.EngineVersion{}, errors.New("engine version create: nil context")
|
||||
}
|
||||
|
||||
startedAt := service.clock().UTC()
|
||||
|
||||
canonicalVersion, err := engineversion.ParseSemver(input.Version)
|
||||
if err != nil {
|
||||
return engineversion.EngineVersion{}, service.recordCreateFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeInvalidRequest, fmt.Sprintf("parse semver: %s", err.Error()),
|
||||
fmt.Errorf("%w: %s", ErrInvalidRequest, err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if err := validateImageRef(input.ImageRef); err != nil {
|
||||
return engineversion.EngineVersion{}, service.recordCreateFailure(
|
||||
ctx, startedAt, canonicalVersion, input.OpSource, input.SourceRef,
|
||||
ErrorCodeInvalidRequest, fmt.Sprintf("validate image_ref: %s", err.Error()),
|
||||
fmt.Errorf("%w: %s", ErrInvalidRequest, err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
options, err := normalizeOptions(input.Options)
|
||||
if err != nil {
|
||||
return engineversion.EngineVersion{}, service.recordCreateFailure(
|
||||
ctx, startedAt, canonicalVersion, input.OpSource, input.SourceRef,
|
||||
ErrorCodeInvalidRequest, fmt.Sprintf("validate options: %s", err.Error()),
|
||||
fmt.Errorf("%w: %s", ErrInvalidRequest, err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
record := engineversion.EngineVersion{
|
||||
Version: canonicalVersion,
|
||||
ImageRef: strings.TrimSpace(input.ImageRef),
|
||||
Options: options,
|
||||
Status: engineversion.StatusActive,
|
||||
CreatedAt: startedAt,
|
||||
UpdatedAt: startedAt,
|
||||
}
|
||||
|
||||
if err := service.versions.Insert(ctx, record); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, engineversion.ErrConflict):
|
||||
return engineversion.EngineVersion{}, service.recordCreateFailure(
|
||||
ctx, startedAt, canonicalVersion, input.OpSource, input.SourceRef,
|
||||
ErrorCodeConflict, "engine version already exists",
|
||||
fmt.Errorf("%w: %s", ErrConflict, canonicalVersion),
|
||||
)
|
||||
default:
|
||||
return engineversion.EngineVersion{}, service.recordCreateFailure(
|
||||
ctx, startedAt, canonicalVersion, input.OpSource, input.SourceRef,
|
||||
ErrorCodeServiceUnavailable, fmt.Sprintf("insert engine version: %s", err.Error()),
|
||||
fmt.Errorf("%w: insert engine version: %s", ErrServiceUnavailable, err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
service.appendSuccess(ctx, operation.OpKindEngineVersionCreate, canonicalVersion, input.OpSource, input.SourceRef, startedAt)
|
||||
|
||||
logArgs := []any{
|
||||
"version", canonicalVersion,
|
||||
"image_ref", record.ImageRef,
|
||||
"op_source", string(fallbackOpSource(input.OpSource)),
|
||||
}
|
||||
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
|
||||
service.logger.InfoContext(ctx, "engine version created", logArgs...)
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// Update applies a partial update to one registry row. At least one of
|
||||
// ImageRef, Options, Status must be non-nil.
|
||||
func (service *Service) Update(ctx context.Context, input UpdateInput) (engineversion.EngineVersion, error) {
|
||||
if service == nil {
|
||||
return engineversion.EngineVersion{}, errors.New("engine version update: nil service")
|
||||
}
|
||||
if ctx == nil {
|
||||
return engineversion.EngineVersion{}, errors.New("engine version update: nil context")
|
||||
}
|
||||
|
||||
startedAt := service.clock().UTC()
|
||||
|
||||
if strings.TrimSpace(input.Version) == "" {
|
||||
return engineversion.EngineVersion{}, service.recordUpdateFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeInvalidRequest, "version must not be empty",
|
||||
fmt.Errorf("%w: version must not be empty", ErrInvalidRequest),
|
||||
)
|
||||
}
|
||||
if input.ImageRef == nil && input.Options == nil && input.Status == nil {
|
||||
return engineversion.EngineVersion{}, service.recordUpdateFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeInvalidRequest, "at least one field must be set",
|
||||
fmt.Errorf("%w: at least one field must be set", ErrInvalidRequest),
|
||||
)
|
||||
}
|
||||
if input.ImageRef != nil {
|
||||
if err := validateImageRef(*input.ImageRef); err != nil {
|
||||
return engineversion.EngineVersion{}, service.recordUpdateFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeInvalidRequest, fmt.Sprintf("validate image_ref: %s", err.Error()),
|
||||
fmt.Errorf("%w: %s", ErrInvalidRequest, err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
if input.Status != nil && !input.Status.IsKnown() {
|
||||
return engineversion.EngineVersion{}, service.recordUpdateFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeInvalidRequest, fmt.Sprintf("status %q is unsupported", *input.Status),
|
||||
fmt.Errorf("%w: status %q is unsupported", ErrInvalidRequest, *input.Status),
|
||||
)
|
||||
}
|
||||
var normalizedOptions *[]byte
|
||||
if input.Options != nil {
|
||||
opts, err := normalizeOptions(*input.Options)
|
||||
if err != nil {
|
||||
return engineversion.EngineVersion{}, service.recordUpdateFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeInvalidRequest, fmt.Sprintf("validate options: %s", err.Error()),
|
||||
fmt.Errorf("%w: %s", ErrInvalidRequest, err.Error()),
|
||||
)
|
||||
}
|
||||
normalizedOptions = &opts
|
||||
}
|
||||
|
||||
storeInput := ports.UpdateEngineVersionInput{
|
||||
Version: input.Version,
|
||||
Options: normalizedOptions,
|
||||
Status: input.Status,
|
||||
Now: startedAt,
|
||||
}
|
||||
if input.ImageRef != nil {
|
||||
trimmed := strings.TrimSpace(*input.ImageRef)
|
||||
storeInput.ImageRef = &trimmed
|
||||
}
|
||||
|
||||
if err := service.versions.Update(ctx, storeInput); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, engineversion.ErrNotFound):
|
||||
return engineversion.EngineVersion{}, service.recordUpdateFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeEngineVersionNotFound, fmt.Sprintf("engine version %q not found", input.Version),
|
||||
fmt.Errorf("%w: %q", ErrNotFound, input.Version),
|
||||
)
|
||||
default:
|
||||
return engineversion.EngineVersion{}, service.recordUpdateFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeServiceUnavailable, fmt.Sprintf("update engine version: %s", err.Error()),
|
||||
fmt.Errorf("%w: update engine version: %s", ErrServiceUnavailable, err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
persisted, err := service.versions.Get(ctx, input.Version)
|
||||
if err != nil {
|
||||
// The Update succeeded but the post-read failed. Surface the
|
||||
// store error; the audit entry still records the successful
|
||||
// mutation against operation_log.
|
||||
service.appendSuccess(ctx, operation.OpKindEngineVersionUpdate, input.Version, input.OpSource, input.SourceRef, startedAt)
|
||||
return engineversion.EngineVersion{}, fmt.Errorf("%w: reload engine version: %s", ErrServiceUnavailable, err.Error())
|
||||
}
|
||||
|
||||
service.appendSuccess(ctx, operation.OpKindEngineVersionUpdate, input.Version, input.OpSource, input.SourceRef, startedAt)
|
||||
|
||||
logArgs := []any{
|
||||
"version", input.Version,
|
||||
"op_source", string(fallbackOpSource(input.OpSource)),
|
||||
}
|
||||
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
|
||||
service.logger.InfoContext(ctx, "engine version updated", logArgs...)
|
||||
|
||||
return persisted, nil
|
||||
}
|
||||
|
||||
// Deprecate marks one registry row as deprecated. Idempotent: the call
|
||||
// succeeds even when the row is already deprecated. Returns ErrNotFound
|
||||
// when no row matches.
|
||||
func (service *Service) Deprecate(ctx context.Context, input DeprecateInput) error {
|
||||
if service == nil {
|
||||
return errors.New("engine version deprecate: nil service")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("engine version deprecate: nil context")
|
||||
}
|
||||
|
||||
startedAt := service.clock().UTC()
|
||||
|
||||
if strings.TrimSpace(input.Version) == "" {
|
||||
return service.recordDeprecateFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeInvalidRequest, "version must not be empty",
|
||||
fmt.Errorf("%w: version must not be empty", ErrInvalidRequest),
|
||||
)
|
||||
}
|
||||
|
||||
if err := service.versions.Deprecate(ctx, input.Version, startedAt); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, engineversion.ErrNotFound):
|
||||
return service.recordDeprecateFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeEngineVersionNotFound, fmt.Sprintf("engine version %q not found", input.Version),
|
||||
fmt.Errorf("%w: %q", ErrNotFound, input.Version),
|
||||
)
|
||||
default:
|
||||
return service.recordDeprecateFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeServiceUnavailable, fmt.Sprintf("deprecate engine version: %s", err.Error()),
|
||||
fmt.Errorf("%w: deprecate engine version: %s", ErrServiceUnavailable, err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
service.appendSuccess(ctx, operation.OpKindEngineVersionDeprecate, input.Version, input.OpSource, input.SourceRef, startedAt)
|
||||
|
||||
logArgs := []any{
|
||||
"version", input.Version,
|
||||
"op_source", string(fallbackOpSource(input.OpSource)),
|
||||
}
|
||||
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
|
||||
service.logger.InfoContext(ctx, "engine version deprecated", logArgs...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete hard-deletes one registry row. Rejected with ErrInUse when any
|
||||
// non-finished runtime still references the version. The reference
|
||||
// probe runs first so the conflict is surfaced before the row is
|
||||
// removed.
|
||||
func (service *Service) Delete(ctx context.Context, input DeleteInput) error {
|
||||
if service == nil {
|
||||
return errors.New("engine version delete: nil service")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("engine version delete: nil context")
|
||||
}
|
||||
|
||||
startedAt := service.clock().UTC()
|
||||
|
||||
if strings.TrimSpace(input.Version) == "" {
|
||||
return service.recordDeleteFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeInvalidRequest, "version must not be empty",
|
||||
fmt.Errorf("%w: version must not be empty", ErrInvalidRequest),
|
||||
)
|
||||
}
|
||||
|
||||
referenced, err := service.versions.IsReferencedByActiveRuntime(ctx, input.Version)
|
||||
if err != nil {
|
||||
return service.recordDeleteFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeServiceUnavailable, fmt.Sprintf("is referenced by active runtime: %s", err.Error()),
|
||||
fmt.Errorf("%w: is referenced by active runtime: %s", ErrServiceUnavailable, err.Error()),
|
||||
)
|
||||
}
|
||||
if referenced {
|
||||
return service.recordDeleteFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeEngineVersionInUse, fmt.Sprintf("engine version %q is referenced by an active runtime", input.Version),
|
||||
fmt.Errorf("%w: %q", ErrInUse, input.Version),
|
||||
)
|
||||
}
|
||||
|
||||
if err := service.versions.Delete(ctx, input.Version); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, engineversion.ErrNotFound):
|
||||
return service.recordDeleteFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeEngineVersionNotFound, fmt.Sprintf("engine version %q not found", input.Version),
|
||||
fmt.Errorf("%w: %q", ErrNotFound, input.Version),
|
||||
)
|
||||
default:
|
||||
return service.recordDeleteFailure(
|
||||
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
|
||||
ErrorCodeServiceUnavailable, fmt.Sprintf("delete engine version: %s", err.Error()),
|
||||
fmt.Errorf("%w: delete engine version: %s", ErrServiceUnavailable, err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
service.appendSuccess(ctx, operation.OpKindEngineVersionDelete, input.Version, input.OpSource, input.SourceRef, startedAt)
|
||||
|
||||
logArgs := []any{
|
||||
"version", input.Version,
|
||||
"op_source", string(fallbackOpSource(input.OpSource)),
|
||||
}
|
||||
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
|
||||
service.logger.InfoContext(ctx, "engine version deleted", logArgs...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateImageRef enforces the Docker reference shape required by
|
||||
// `engine_versions.image_ref`: non-empty trimmed, parseable through
|
||||
// `distribution/reference.ParseNormalizedNamed`. The check is the same
|
||||
// one Runtime Manager applies in startruntime so the registry never
|
||||
// stores a value the runtime cannot pull.
|
||||
func validateImageRef(imageRef string) error {
|
||||
trimmed := strings.TrimSpace(imageRef)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("image_ref must not be empty")
|
||||
}
|
||||
if _, err := reference.ParseNormalizedNamed(trimmed); err != nil {
|
||||
return fmt.Errorf("parse image reference %q: %w", trimmed, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeOptions validates that raw is a JSON document encoding a
|
||||
// single object. Empty input is treated as `{}` and stored verbatim by
|
||||
// the adapter (see stage 11 D5).
|
||||
func normalizeOptions(raw []byte) ([]byte, error) {
|
||||
trimmed := bytesTrim(raw)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var probe map[string]any
|
||||
if err := json.Unmarshal(trimmed, &probe); err != nil {
|
||||
return nil, fmt.Errorf("options must be a JSON object: %w", err)
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// bytesTrim returns raw with surrounding ASCII whitespace removed. The
|
||||
// helper avoids the round-trip through `string` for raw JSON inputs.
|
||||
func bytesTrim(raw []byte) []byte {
|
||||
start, end := 0, len(raw)
|
||||
for start < end && isASCIISpace(raw[start]) {
|
||||
start++
|
||||
}
|
||||
for end > start && isASCIISpace(raw[end-1]) {
|
||||
end--
|
||||
}
|
||||
return raw[start:end]
|
||||
}
|
||||
|
||||
func isASCIISpace(b byte) bool {
|
||||
switch b {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// recordCreateFailure appends an audit failure entry for a Create call
|
||||
// and returns the original sentinel error wrapped with the failure
|
||||
// reason. The audit entry is written best-effort; storage failures are
|
||||
// logged and discarded.
|
||||
func (service *Service) recordCreateFailure(
|
||||
ctx context.Context,
|
||||
startedAt time.Time,
|
||||
subject string,
|
||||
source operation.OpSource,
|
||||
sourceRef string,
|
||||
errorCode string,
|
||||
errorMessage string,
|
||||
wrappedErr error,
|
||||
) error {
|
||||
service.appendFailure(ctx, operation.OpKindEngineVersionCreate, subject, source, sourceRef, startedAt, errorCode, errorMessage)
|
||||
service.logFailure(ctx, "engine version create failed", subject, source, errorCode, errorMessage)
|
||||
return wrappedErr
|
||||
}
|
||||
|
||||
func (service *Service) recordUpdateFailure(
|
||||
ctx context.Context,
|
||||
startedAt time.Time,
|
||||
subject string,
|
||||
source operation.OpSource,
|
||||
sourceRef string,
|
||||
errorCode string,
|
||||
errorMessage string,
|
||||
wrappedErr error,
|
||||
) error {
|
||||
service.appendFailure(ctx, operation.OpKindEngineVersionUpdate, subject, source, sourceRef, startedAt, errorCode, errorMessage)
|
||||
service.logFailure(ctx, "engine version update failed", subject, source, errorCode, errorMessage)
|
||||
return wrappedErr
|
||||
}
|
||||
|
||||
func (service *Service) recordDeprecateFailure(
|
||||
ctx context.Context,
|
||||
startedAt time.Time,
|
||||
subject string,
|
||||
source operation.OpSource,
|
||||
sourceRef string,
|
||||
errorCode string,
|
||||
errorMessage string,
|
||||
wrappedErr error,
|
||||
) error {
|
||||
service.appendFailure(ctx, operation.OpKindEngineVersionDeprecate, subject, source, sourceRef, startedAt, errorCode, errorMessage)
|
||||
service.logFailure(ctx, "engine version deprecate failed", subject, source, errorCode, errorMessage)
|
||||
return wrappedErr
|
||||
}
|
||||
|
||||
func (service *Service) recordDeleteFailure(
|
||||
ctx context.Context,
|
||||
startedAt time.Time,
|
||||
subject string,
|
||||
source operation.OpSource,
|
||||
sourceRef string,
|
||||
errorCode string,
|
||||
errorMessage string,
|
||||
wrappedErr error,
|
||||
) error {
|
||||
service.appendFailure(ctx, operation.OpKindEngineVersionDelete, subject, source, sourceRef, startedAt, errorCode, errorMessage)
|
||||
service.logFailure(ctx, "engine version delete failed", subject, source, errorCode, errorMessage)
|
||||
return wrappedErr
|
||||
}
|
||||
|
||||
// appendSuccess writes a success entry to operation_log. Subject is the
|
||||
// canonical version string; the entry's GameID column doubles as the
|
||||
// audit subject for engine-version operations (stage 14 decision —
|
||||
// the registry is global, not per-game).
|
||||
func (service *Service) appendSuccess(
|
||||
ctx context.Context,
|
||||
kind operation.OpKind,
|
||||
subject string,
|
||||
source operation.OpSource,
|
||||
sourceRef string,
|
||||
startedAt time.Time,
|
||||
) {
|
||||
finishedAt := service.clock().UTC()
|
||||
service.bestEffortAppend(ctx, operation.OperationEntry{
|
||||
GameID: subject,
|
||||
OpKind: kind,
|
||||
OpSource: fallbackOpSource(source),
|
||||
SourceRef: sourceRef,
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// appendFailure writes a failure entry to operation_log. Subject and
|
||||
// the GameID column overload follow the same rule as appendSuccess.
|
||||
func (service *Service) appendFailure(
|
||||
ctx context.Context,
|
||||
kind operation.OpKind,
|
||||
subject string,
|
||||
source operation.OpSource,
|
||||
sourceRef string,
|
||||
startedAt time.Time,
|
||||
errorCode string,
|
||||
errorMessage string,
|
||||
) {
|
||||
finishedAt := service.clock().UTC()
|
||||
service.bestEffortAppend(ctx, operation.OperationEntry{
|
||||
GameID: subject,
|
||||
OpKind: kind,
|
||||
OpSource: fallbackOpSource(source),
|
||||
SourceRef: sourceRef,
|
||||
Outcome: operation.OutcomeFailure,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMessage: errorMessage,
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// bestEffortAppend writes one operation_log entry. A failure is logged
|
||||
// and discarded; the registry mutation (or its absence) remains the
|
||||
// source of truth.
|
||||
func (service *Service) bestEffortAppend(ctx context.Context, entry operation.OperationEntry) {
|
||||
if _, err := service.operationLogs.Append(ctx, entry); err != nil {
|
||||
service.logger.ErrorContext(ctx, "append operation log",
|
||||
"subject", entry.GameID,
|
||||
"op_kind", string(entry.OpKind),
|
||||
"outcome", string(entry.Outcome),
|
||||
"error_code", entry.ErrorCode,
|
||||
"err", err.Error(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// logFailure emits one structured warn-level entry per service-level
|
||||
// failure, mirroring registerruntime's log shape.
|
||||
func (service *Service) logFailure(
|
||||
ctx context.Context,
|
||||
message string,
|
||||
subject string,
|
||||
source operation.OpSource,
|
||||
errorCode string,
|
||||
errorMessage string,
|
||||
) {
|
||||
logArgs := []any{
|
||||
"version", subject,
|
||||
"op_source", string(fallbackOpSource(source)),
|
||||
"error_code", errorCode,
|
||||
"error_message", errorMessage,
|
||||
}
|
||||
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
|
||||
service.logger.WarnContext(ctx, message, logArgs...)
|
||||
}
|
||||
|
||||
// fallbackOpSource defaults to admin_rest when source is missing or
|
||||
// unrecognised. Mirrors `gamemaster/README.md §Trusted Surfaces`.
|
||||
func fallbackOpSource(source operation.OpSource) operation.OpSource {
|
||||
if source.IsKnown() {
|
||||
return source
|
||||
}
|
||||
return operation.OpSourceAdminRest
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
package engineversion_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/mocks"
|
||||
domainengineversion "galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
"galaxy/gamemaster/internal/service/engineversion"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// fakeOperationLogs is a thread-safe stub recorder for the few
|
||||
// operation_log entries the engine-version service writes per call.
|
||||
// Using a stub keeps the operation_log assertions table-driven without
|
||||
// introducing the verbosity of a gomock recorder for every entry.
|
||||
type fakeOperationLogs struct {
|
||||
mu sync.Mutex
|
||||
entries []operation.OperationEntry
|
||||
err error
|
||||
}
|
||||
|
||||
func newFakeOperationLogs() *fakeOperationLogs {
|
||||
return &fakeOperationLogs{}
|
||||
}
|
||||
|
||||
func (s *fakeOperationLogs) Append(_ context.Context, entry operation.OperationEntry) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.err != nil {
|
||||
return 0, s.err
|
||||
}
|
||||
s.entries = append(s.entries, entry)
|
||||
return int64(len(s.entries)), nil
|
||||
}
|
||||
|
||||
func (s *fakeOperationLogs) ListByGame(_ context.Context, _ string, _ int) ([]operation.OperationEntry, error) {
|
||||
return nil, errors.New("not used in engineversion tests")
|
||||
}
|
||||
|
||||
func (s *fakeOperationLogs) snapshot() []operation.OperationEntry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]operation.OperationEntry, len(s.entries))
|
||||
copy(out, s.entries)
|
||||
return out
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
ctrl *gomock.Controller
|
||||
store *mocks.MockEngineVersionStore
|
||||
oplog *fakeOperationLogs
|
||||
clock time.Time
|
||||
service *engineversion.Service
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
ctrl := gomock.NewController(t)
|
||||
store := mocks.NewMockEngineVersionStore(ctrl)
|
||||
oplog := newFakeOperationLogs()
|
||||
clock := time.Date(2026, time.April, 30, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
service, err := engineversion.NewService(engineversion.Dependencies{
|
||||
EngineVersions: store,
|
||||
OperationLogs: oplog,
|
||||
Clock: func() time.Time { return clock },
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return &harness{
|
||||
ctrl: ctrl,
|
||||
store: store,
|
||||
oplog: oplog,
|
||||
clock: clock,
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
store := mocks.NewMockEngineVersionStore(ctrl)
|
||||
oplog := newFakeOperationLogs()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
deps engineversion.Dependencies
|
||||
}{
|
||||
{"nil store", engineversion.Dependencies{OperationLogs: oplog}},
|
||||
{"nil oplog", engineversion.Dependencies{EngineVersions: store}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, err := engineversion.NewService(tc.deps)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, s)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceDefaultsClockAndLogger(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
service, err := engineversion.NewService(engineversion.Dependencies{
|
||||
EngineVersions: mocks.NewMockEngineVersionStore(ctrl),
|
||||
OperationLogs: newFakeOperationLogs(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, service)
|
||||
}
|
||||
|
||||
// --- List ------------------------------------------------------------
|
||||
|
||||
func TestListNoFilter(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
rows := []domainengineversion.EngineVersion{
|
||||
{Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", Status: domainengineversion.StatusActive},
|
||||
{Version: "v1.3.0", ImageRef: "ghcr.io/galaxy/game:v1.3.0", Status: domainengineversion.StatusDeprecated},
|
||||
}
|
||||
h.store.EXPECT().List(gomock.Any(), nil).Return(rows, nil)
|
||||
|
||||
got, err := h.service.List(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rows, got)
|
||||
}
|
||||
|
||||
func TestListWithStatusFilter(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
active := domainengineversion.StatusActive
|
||||
expected := []domainengineversion.EngineVersion{
|
||||
{Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", Status: active},
|
||||
}
|
||||
h.store.EXPECT().List(gomock.Any(), &active).Return(expected, nil)
|
||||
|
||||
got, err := h.service.List(context.Background(), &active)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
func TestListRejectsUnknownStatusFilter(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
exotic := domainengineversion.Status("exotic")
|
||||
got, err := h.service.List(context.Background(), &exotic)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
assert.Nil(t, got)
|
||||
}
|
||||
|
||||
func TestListWrapsStoreErrorAsServiceUnavailable(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
storeErr := errors.New("pg down")
|
||||
h.store.EXPECT().List(gomock.Any(), nil).Return(nil, storeErr)
|
||||
|
||||
_, err := h.service.List(context.Background(), nil)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable))
|
||||
}
|
||||
|
||||
// --- Get -------------------------------------------------------------
|
||||
|
||||
func TestGetHappyPath(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
row := domainengineversion.EngineVersion{
|
||||
Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", Status: domainengineversion.StatusActive,
|
||||
}
|
||||
h.store.EXPECT().Get(gomock.Any(), "v1.2.3").Return(row, nil)
|
||||
|
||||
got, err := h.service.Get(context.Background(), "v1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, row, got)
|
||||
}
|
||||
|
||||
func TestGetNotFound(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Get(gomock.Any(), "v9.9.9").Return(domainengineversion.EngineVersion{}, domainengineversion.ErrNotFound)
|
||||
|
||||
_, err := h.service.Get(context.Background(), "v9.9.9")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestGetRejectsEmptyVersion(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
_, err := h.service.Get(context.Background(), " ")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
}
|
||||
|
||||
func TestGetWrapsStoreError(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Get(gomock.Any(), "v1.2.3").Return(domainengineversion.EngineVersion{}, errors.New("pg down"))
|
||||
|
||||
_, err := h.service.Get(context.Background(), "v1.2.3")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable))
|
||||
}
|
||||
|
||||
// --- ResolveImageRef -------------------------------------------------
|
||||
|
||||
func TestResolveImageRefHappyPath(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Get(gomock.Any(), "v1.2.3").Return(domainengineversion.EngineVersion{
|
||||
Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", Status: domainengineversion.StatusActive,
|
||||
}, nil)
|
||||
|
||||
got, err := h.service.ResolveImageRef(context.Background(), "v1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ghcr.io/galaxy/game:v1.2.3", got)
|
||||
}
|
||||
|
||||
func TestResolveImageRefSeededTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seedVersion string
|
||||
seedRef string
|
||||
}{
|
||||
{"v1.0.0", "v1.0.0", "ghcr.io/galaxy/game:v1.0.0"},
|
||||
{"v1.2.3 with prerelease metadata", "v1.2.3-rc1", "ghcr.io/galaxy/game:v1.2.3-rc1"},
|
||||
{"v2.0.0 fully-qualified", "v2.0.0", "registry.galaxy.local/game:v2.0.0"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Get(gomock.Any(), tc.seedVersion).Return(domainengineversion.EngineVersion{
|
||||
Version: tc.seedVersion, ImageRef: tc.seedRef, Status: domainengineversion.StatusActive,
|
||||
}, nil)
|
||||
got, err := h.service.ResolveImageRef(context.Background(), tc.seedVersion)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.seedRef, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveImageRefNotFound(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Get(gomock.Any(), "v9.9.9").Return(domainengineversion.EngineVersion{}, domainengineversion.ErrNotFound)
|
||||
|
||||
_, err := h.service.ResolveImageRef(context.Background(), "v9.9.9")
|
||||
require.True(t, errors.Is(err, engineversion.ErrNotFound))
|
||||
}
|
||||
|
||||
// --- Create ----------------------------------------------------------
|
||||
|
||||
func TestCreateHappyPath(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Insert(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(_ context.Context, record domainengineversion.EngineVersion) error {
|
||||
assert.Equal(t, "v1.2.3", record.Version)
|
||||
assert.Equal(t, "ghcr.io/galaxy/game:v1.2.3", record.ImageRef)
|
||||
assert.Equal(t, domainengineversion.StatusActive, record.Status)
|
||||
assert.Equal(t, h.clock, record.CreatedAt)
|
||||
assert.Equal(t, h.clock, record.UpdatedAt)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
got, err := h.service.Create(context.Background(), engineversion.CreateInput{
|
||||
Version: "1.2.3",
|
||||
ImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
Options: []byte(`{"max_planets":120}`),
|
||||
OpSource: operation.OpSourceAdminRest,
|
||||
SourceRef: "request-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v1.2.3", got.Version)
|
||||
|
||||
entries := h.oplog.snapshot()
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, operation.OpKindEngineVersionCreate, entries[0].OpKind)
|
||||
assert.Equal(t, "v1.2.3", entries[0].GameID)
|
||||
assert.Equal(t, operation.OutcomeSuccess, entries[0].Outcome)
|
||||
assert.Equal(t, operation.OpSourceAdminRest, entries[0].OpSource)
|
||||
assert.Equal(t, "request-1", entries[0].SourceRef)
|
||||
}
|
||||
|
||||
func TestCreateRejectsInvalidSemver(t *testing.T) {
|
||||
tests := []string{"", " ", "not-a-version", "v1.2", "1.2"}
|
||||
for _, version := range tests {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
_, err := h.service.Create(context.Background(), engineversion.CreateInput{
|
||||
Version: version,
|
||||
ImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAuditFailureForBadImageRef(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
_, err := h.service.Create(context.Background(), engineversion.CreateInput{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: " ",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
entries := h.oplog.snapshot()
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, operation.OpKindEngineVersionCreate, entries[0].OpKind)
|
||||
assert.Equal(t, "v1.2.3", entries[0].GameID)
|
||||
assert.Equal(t, operation.OutcomeFailure, entries[0].Outcome)
|
||||
assert.Equal(t, engineversion.ErrorCodeInvalidRequest, entries[0].ErrorCode)
|
||||
}
|
||||
|
||||
func TestCreateRejectsBadDockerReference(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
_, err := h.service.Create(context.Background(), engineversion.CreateInput{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: "BAD//Ref::",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
}
|
||||
|
||||
func TestCreateRejectsNonObjectOptions(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
_, err := h.service.Create(context.Background(), engineversion.CreateInput{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
Options: []byte(`[1,2,3]`),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
}
|
||||
|
||||
func TestCreateAcceptsEmptyOptionsAsNil(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Insert(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(_ context.Context, record domainengineversion.EngineVersion) error {
|
||||
assert.Empty(t, record.Options, "expected empty options pass-through (adapter writes default {})")
|
||||
return nil
|
||||
},
|
||||
)
|
||||
_, err := h.service.Create(context.Background(), engineversion.CreateInput{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
Options: nil,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateConflict(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(domainengineversion.ErrConflict)
|
||||
_, err := h.service.Create(context.Background(), engineversion.CreateInput{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrConflict))
|
||||
entries := h.oplog.snapshot()
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, operation.OutcomeFailure, entries[0].Outcome)
|
||||
assert.Equal(t, engineversion.ErrorCodeConflict, entries[0].ErrorCode)
|
||||
}
|
||||
|
||||
func TestCreateUnknownStoreError(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(errors.New("pg down"))
|
||||
_, err := h.service.Create(context.Background(), engineversion.CreateInput{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable))
|
||||
}
|
||||
|
||||
// --- Update ----------------------------------------------------------
|
||||
|
||||
func TestUpdateHappyPath(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
newRef := "ghcr.io/galaxy/game:v1.2.4"
|
||||
deprecated := domainengineversion.StatusDeprecated
|
||||
|
||||
gomock.InOrder(
|
||||
h.store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(_ context.Context, input ports.UpdateEngineVersionInput) error {
|
||||
require.NotNil(t, input.ImageRef)
|
||||
assert.Equal(t, newRef, *input.ImageRef)
|
||||
require.NotNil(t, input.Status)
|
||||
assert.Equal(t, deprecated, *input.Status)
|
||||
assert.Equal(t, h.clock, input.Now)
|
||||
return nil
|
||||
},
|
||||
),
|
||||
h.store.EXPECT().Get(gomock.Any(), "v1.2.3").Return(domainengineversion.EngineVersion{
|
||||
Version: "v1.2.3", ImageRef: newRef, Status: deprecated, UpdatedAt: h.clock,
|
||||
}, nil),
|
||||
)
|
||||
|
||||
got, err := h.service.Update(context.Background(), engineversion.UpdateInput{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: &newRef,
|
||||
Status: &deprecated,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, deprecated, got.Status)
|
||||
|
||||
entries := h.oplog.snapshot()
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, operation.OpKindEngineVersionUpdate, entries[0].OpKind)
|
||||
assert.Equal(t, operation.OutcomeSuccess, entries[0].Outcome)
|
||||
}
|
||||
|
||||
func TestUpdateRejectsEmptyVersion(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
newRef := "ghcr.io/galaxy/game:v1.2.4"
|
||||
_, err := h.service.Update(context.Background(), engineversion.UpdateInput{
|
||||
Version: " ",
|
||||
ImageRef: &newRef,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
}
|
||||
|
||||
func TestUpdateRejectsEmptyPatch(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
_, err := h.service.Update(context.Background(), engineversion.UpdateInput{Version: "v1.2.3"})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
}
|
||||
|
||||
func TestUpdateRejectsBadImageRef(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
bad := "BAD//Ref::"
|
||||
_, err := h.service.Update(context.Background(), engineversion.UpdateInput{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: &bad,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
}
|
||||
|
||||
func TestUpdateRejectsUnknownStatus(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
bad := domainengineversion.Status("exotic")
|
||||
_, err := h.service.Update(context.Background(), engineversion.UpdateInput{
|
||||
Version: "v1.2.3",
|
||||
Status: &bad,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
}
|
||||
|
||||
func TestUpdateRejectsBadOptions(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
bad := []byte(`"not-an-object"`)
|
||||
_, err := h.service.Update(context.Background(), engineversion.UpdateInput{
|
||||
Version: "v1.2.3",
|
||||
Options: &bad,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
}
|
||||
|
||||
func TestUpdateNotFound(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
newRef := "ghcr.io/galaxy/game:v1.2.4"
|
||||
h.store.EXPECT().Update(gomock.Any(), gomock.Any()).Return(domainengineversion.ErrNotFound)
|
||||
_, err := h.service.Update(context.Background(), engineversion.UpdateInput{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: &newRef,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrNotFound))
|
||||
entries := h.oplog.snapshot()
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, engineversion.ErrorCodeEngineVersionNotFound, entries[0].ErrorCode)
|
||||
}
|
||||
|
||||
// --- Deprecate -------------------------------------------------------
|
||||
|
||||
func TestDeprecateHappyPath(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Deprecate(gomock.Any(), "v1.2.3", h.clock).Return(nil)
|
||||
|
||||
err := h.service.Deprecate(context.Background(), engineversion.DeprecateInput{Version: "v1.2.3"})
|
||||
require.NoError(t, err)
|
||||
entries := h.oplog.snapshot()
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, operation.OpKindEngineVersionDeprecate, entries[0].OpKind)
|
||||
assert.Equal(t, operation.OutcomeSuccess, entries[0].Outcome)
|
||||
}
|
||||
|
||||
func TestDeprecateRejectsEmptyVersion(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
err := h.service.Deprecate(context.Background(), engineversion.DeprecateInput{})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
}
|
||||
|
||||
func TestDeprecateNotFound(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Deprecate(gomock.Any(), "v9.9.9", h.clock).Return(domainengineversion.ErrNotFound)
|
||||
err := h.service.Deprecate(context.Background(), engineversion.DeprecateInput{Version: "v9.9.9"})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrNotFound))
|
||||
entries := h.oplog.snapshot()
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, operation.OutcomeFailure, entries[0].Outcome)
|
||||
assert.Equal(t, engineversion.ErrorCodeEngineVersionNotFound, entries[0].ErrorCode)
|
||||
}
|
||||
|
||||
func TestDeprecateUnknownStoreError(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().Deprecate(gomock.Any(), "v1.2.3", h.clock).Return(errors.New("pg down"))
|
||||
err := h.service.Deprecate(context.Background(), engineversion.DeprecateInput{Version: "v1.2.3"})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable))
|
||||
}
|
||||
|
||||
// --- Delete ----------------------------------------------------------
|
||||
|
||||
func TestDeleteHappyPath(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
gomock.InOrder(
|
||||
h.store.EXPECT().IsReferencedByActiveRuntime(gomock.Any(), "v1.2.3").Return(false, nil),
|
||||
h.store.EXPECT().Delete(gomock.Any(), "v1.2.3").Return(nil),
|
||||
)
|
||||
err := h.service.Delete(context.Background(), engineversion.DeleteInput{
|
||||
Version: "v1.2.3",
|
||||
OpSource: operation.OpSourceAdminRest,
|
||||
SourceRef: "ticket-42",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
entries := h.oplog.snapshot()
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, operation.OpKindEngineVersionDelete, entries[0].OpKind)
|
||||
assert.Equal(t, operation.OutcomeSuccess, entries[0].Outcome)
|
||||
assert.Equal(t, "ticket-42", entries[0].SourceRef)
|
||||
}
|
||||
|
||||
func TestDeleteRejectsEmptyVersion(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
err := h.service.Delete(context.Background(), engineversion.DeleteInput{})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInvalidRequest))
|
||||
}
|
||||
|
||||
func TestDeleteRejectedWhenReferenced(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().IsReferencedByActiveRuntime(gomock.Any(), "v1.2.3").Return(true, nil)
|
||||
// Delete must not be called when the row is referenced.
|
||||
|
||||
err := h.service.Delete(context.Background(), engineversion.DeleteInput{Version: "v1.2.3"})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrInUse))
|
||||
entries := h.oplog.snapshot()
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, operation.OutcomeFailure, entries[0].Outcome)
|
||||
assert.Equal(t, engineversion.ErrorCodeEngineVersionInUse, entries[0].ErrorCode)
|
||||
}
|
||||
|
||||
func TestDeleteIsReferencedProbeError(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.store.EXPECT().IsReferencedByActiveRuntime(gomock.Any(), "v1.2.3").Return(false, errors.New("pg down"))
|
||||
|
||||
err := h.service.Delete(context.Background(), engineversion.DeleteInput{Version: "v1.2.3"})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable))
|
||||
}
|
||||
|
||||
func TestDeleteNotFound(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
gomock.InOrder(
|
||||
h.store.EXPECT().IsReferencedByActiveRuntime(gomock.Any(), "v9.9.9").Return(false, nil),
|
||||
h.store.EXPECT().Delete(gomock.Any(), "v9.9.9").Return(domainengineversion.ErrNotFound),
|
||||
)
|
||||
err := h.service.Delete(context.Background(), engineversion.DeleteInput{Version: "v9.9.9"})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestDeleteUnknownStoreError(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
gomock.InOrder(
|
||||
h.store.EXPECT().IsReferencedByActiveRuntime(gomock.Any(), "v1.2.3").Return(false, nil),
|
||||
h.store.EXPECT().Delete(gomock.Any(), "v1.2.3").Return(errors.New("pg down")),
|
||||
)
|
||||
err := h.service.Delete(context.Background(), engineversion.DeleteInput{Version: "v1.2.3"})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable))
|
||||
}
|
||||
|
||||
// --- guard rails -----------------------------------------------------
|
||||
|
||||
func TestNilContextReturnsError(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
t.Run("List", func(t *testing.T) {
|
||||
_, err := h.service.List(nil, nil) //nolint:staticcheck // intentional nil context
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("Get", func(t *testing.T) {
|
||||
_, err := h.service.Get(nil, "v1.2.3") //nolint:staticcheck // intentional nil context
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
_, err := h.service.Create(nil, engineversion.CreateInput{}) //nolint:staticcheck // intentional nil context
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
_, err := h.service.Update(nil, engineversion.UpdateInput{}) //nolint:staticcheck // intentional nil context
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("Deprecate", func(t *testing.T) {
|
||||
err := h.service.Deprecate(nil, engineversion.DeprecateInput{}) //nolint:staticcheck // intentional nil context
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
err := h.service.Delete(nil, engineversion.DeleteInput{}) //nolint:staticcheck // intentional nil context
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNilServiceReturnsError(t *testing.T) {
|
||||
var s *engineversion.Service
|
||||
_, err := s.Get(context.Background(), "v1.2.3")
|
||||
require.Error(t, err)
|
||||
_, err = s.Create(context.Background(), engineversion.CreateInput{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user