15d35f6f1f
Engine no longer mints its own game UUID. The orchestrator (backend)
generates the game UUID at game-create time and passes it in the
admin/init request body as the required `gameId` field, so the value
that names the engine container and host bind-mount directory also
ends up inside the engine's state.json.
The engine rejects the zero UUID with 400 and any init that conflicts
with an existing state.json with 409 (a second init on the same gameId
is also a conflict; full idempotency is not part of the contract).
Updates rest.InitRequest, openapi.yaml (schema + 409 response),
controller.GenerateGame/NewGame/buildGameOnMap signatures, the engine
HTTP handler/executor, the backend runtime worker, and the relevant
unit and contract tests. Documentation in game/README.md,
docs/ARCHITECTURE.md, backend/README.md, and backend/docs/{runtime,flows}.md
is updated in the same patch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
308 lines
9.5 KiB
Go
308 lines
9.5 KiB
Go
package runtime_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/backend/internal/config"
|
|
"galaxy/backend/internal/dockerclient"
|
|
"galaxy/backend/internal/engineclient"
|
|
backendpg "galaxy/backend/internal/postgres"
|
|
"galaxy/backend/internal/runtime"
|
|
"galaxy/model/rest"
|
|
pgshared "galaxy/postgres"
|
|
|
|
"github.com/google/uuid"
|
|
testcontainers "github.com/testcontainers/testcontainers-go"
|
|
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
"github.com/testcontainers/testcontainers-go/wait"
|
|
"go.uber.org/zap/zaptest"
|
|
)
|
|
|
|
const (
|
|
pgImage = "postgres:16-alpine"
|
|
pgUser = "galaxy"
|
|
pgPassword = "galaxy"
|
|
pgDatabase = "galaxy_backend"
|
|
pgSchema = "backend"
|
|
pgStartup = 90 * time.Second
|
|
pgOpTO = 10 * time.Second
|
|
)
|
|
|
|
func dsnWithSearchPath(raw, schema string) (string, error) {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
q := parsed.Query()
|
|
q.Set("search_path", schema)
|
|
parsed.RawQuery = q.Encode()
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
func startPostgres(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
t.Cleanup(cancel)
|
|
|
|
container, err := tcpostgres.Run(ctx, pgImage,
|
|
tcpostgres.WithDatabase(pgDatabase),
|
|
tcpostgres.WithUsername(pgUser),
|
|
tcpostgres.WithPassword(pgPassword),
|
|
testcontainers.WithWaitStrategy(
|
|
wait.ForLog("database system is ready to accept connections").
|
|
WithOccurrence(2).
|
|
WithStartupTimeout(pgStartup),
|
|
),
|
|
)
|
|
if err != nil {
|
|
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if termErr := testcontainers.TerminateContainer(container); termErr != nil {
|
|
t.Errorf("terminate postgres container: %v", termErr)
|
|
}
|
|
})
|
|
|
|
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
|
|
if err != nil {
|
|
t.Fatalf("connection string: %v", err)
|
|
}
|
|
scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema)
|
|
if err != nil {
|
|
t.Fatalf("scope dsn: %v", err)
|
|
}
|
|
cfg := pgshared.DefaultConfig()
|
|
cfg.PrimaryDSN = scopedDSN
|
|
cfg.OperationTimeout = pgOpTO
|
|
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
|
if err != nil {
|
|
t.Fatalf("open primary: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
// fakeDocker implements dockerclient.Client for tests.
|
|
type fakeDocker struct {
|
|
mu sync.Mutex
|
|
runs []dockerclient.RunSpec
|
|
stoppedIDs []string
|
|
removedIDs []string
|
|
listResult []dockerclient.ContainerSummary
|
|
endpointFor func(spec dockerclient.RunSpec) string
|
|
}
|
|
|
|
func (f *fakeDocker) EnsureNetwork(_ context.Context, _ string) error { return nil }
|
|
func (f *fakeDocker) PullImage(_ context.Context, _ string, _ dockerclient.PullPolicy) error {
|
|
return nil
|
|
}
|
|
func (f *fakeDocker) InspectImage(_ context.Context, ref string) (dockerclient.ImageInspect, error) {
|
|
return dockerclient.ImageInspect{Ref: ref}, nil
|
|
}
|
|
func (f *fakeDocker) InspectContainer(_ context.Context, _ string) (dockerclient.ContainerInspect, error) {
|
|
return dockerclient.ContainerInspect{}, nil
|
|
}
|
|
func (f *fakeDocker) Run(_ context.Context, spec dockerclient.RunSpec) (dockerclient.RunResult, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.runs = append(f.runs, spec)
|
|
endpoint := "http://" + spec.Hostname + ":8080"
|
|
if f.endpointFor != nil {
|
|
endpoint = f.endpointFor(spec)
|
|
}
|
|
return dockerclient.RunResult{
|
|
ContainerID: "container-" + spec.Name,
|
|
EngineEndpoint: endpoint,
|
|
StartedAt: time.Now().UTC(),
|
|
}, nil
|
|
}
|
|
func (f *fakeDocker) Stop(_ context.Context, id string, _ int) error {
|
|
f.mu.Lock()
|
|
f.stoppedIDs = append(f.stoppedIDs, id)
|
|
f.mu.Unlock()
|
|
return nil
|
|
}
|
|
func (f *fakeDocker) Remove(_ context.Context, id string) error {
|
|
f.mu.Lock()
|
|
f.removedIDs = append(f.removedIDs, id)
|
|
f.mu.Unlock()
|
|
return nil
|
|
}
|
|
func (f *fakeDocker) List(_ context.Context, _ dockerclient.ListFilter) ([]dockerclient.ContainerSummary, error) {
|
|
return f.listResult, nil
|
|
}
|
|
|
|
// fakeLobbyConsumer captures runtime → lobby callbacks.
|
|
type fakeLobbyConsumer struct {
|
|
mu sync.Mutex
|
|
snapshots []runtime.LobbySnapshot
|
|
jobs []runtime.JobResult
|
|
}
|
|
|
|
func (f *fakeLobbyConsumer) OnRuntimeSnapshot(_ context.Context, _ uuid.UUID, snapshot runtime.LobbySnapshot) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.snapshots = append(f.snapshots, snapshot)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeLobbyConsumer) OnRuntimeJobResult(_ context.Context, _ uuid.UUID, result runtime.JobResult) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.jobs = append(f.jobs, result)
|
|
return nil
|
|
}
|
|
|
|
func TestServiceStartGameEndToEnd(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("postgres-backed test skipped in -short")
|
|
}
|
|
ctx := context.Background()
|
|
db := startPostgres(t)
|
|
|
|
gameID := uuid.New()
|
|
userID := uuid.New()
|
|
if _, err := db.ExecContext(ctx, `
|
|
INSERT INTO backend.games (
|
|
game_id, owner_user_id, visibility, status, game_name, description,
|
|
min_players, max_players, start_gap_hours, start_gap_players,
|
|
enrollment_ends_at, turn_schedule, target_engine_version,
|
|
runtime_snapshot
|
|
) VALUES ($1, NULL, 'public', 'starting', 'test-game', '',
|
|
1, 4, 0, 0, $2, '*/5 * * * *', '0.1.0', '{}'::jsonb)
|
|
`, gameID, time.Now().Add(time.Hour)); err != nil {
|
|
t.Fatalf("insert game: %v", err)
|
|
}
|
|
if _, err := db.ExecContext(ctx, `
|
|
INSERT INTO backend.memberships (membership_id, game_id, user_id, race_name, canonical_key, status)
|
|
VALUES ($1, $2, $3, 'Alpha', 'alpha', 'active')
|
|
`, uuid.New(), gameID, userID); err != nil {
|
|
t.Fatalf("insert membership: %v", err)
|
|
}
|
|
if _, err := db.ExecContext(ctx, `
|
|
INSERT INTO backend.engine_versions (version, image_ref, enabled)
|
|
VALUES ('0.1.0', 'galaxy-game:0.1.0', true)
|
|
`); err != nil {
|
|
t.Fatalf("insert engine version: %v", err)
|
|
}
|
|
|
|
engineSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/healthz":
|
|
w.WriteHeader(http.StatusOK)
|
|
case "/api/v1/admin/init":
|
|
var got rest.InitRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
|
t.Errorf("decode init request: %v", err)
|
|
}
|
|
if got.GameID != gameID {
|
|
t.Errorf("init request gameId = %s, want %s", got.GameID, gameID)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 0, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 3, Population: 10}}})
|
|
case "/api/v1/admin/status":
|
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 1, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 5, Population: 12}}})
|
|
case "/api/v1/admin/turn":
|
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 2, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 6, Population: 14}}, Finished: true})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(engineSrv.Close)
|
|
|
|
docker := &fakeDocker{endpointFor: func(_ dockerclient.RunSpec) string { return engineSrv.URL }}
|
|
engineCli, err := engineclient.NewClientWithHTTP(engineclient.Config{CallTimeout: time.Second, ProbeTimeout: time.Second}, engineSrv.Client())
|
|
if err != nil {
|
|
t.Fatalf("engineclient: %v", err)
|
|
}
|
|
store := runtime.NewStore(db)
|
|
cache := runtime.NewCache()
|
|
if err := cache.Warm(ctx, store); err != nil {
|
|
t.Fatalf("warm cache: %v", err)
|
|
}
|
|
versions := runtime.NewEngineVersionService(store, cache, nil)
|
|
consumer := &fakeLobbyConsumer{}
|
|
|
|
svc, err := runtime.NewService(runtime.Deps{
|
|
Store: store,
|
|
Cache: cache,
|
|
EngineVersions: versions,
|
|
Docker: docker,
|
|
Engine: engineCli,
|
|
Lobby: consumer,
|
|
DockerNetwork: "galaxy",
|
|
HostStateRoot: t.TempDir(),
|
|
Config: config.RuntimeConfig{
|
|
WorkerPoolSize: 1,
|
|
JobQueueSize: 4,
|
|
ReconcileInterval: time.Hour,
|
|
ImagePullPolicy: "if_missing",
|
|
ContainerLogDriver: "json-file",
|
|
ContainerCPUQuota: 1.0,
|
|
ContainerMemory: "128m",
|
|
ContainerPIDsLimit: 64,
|
|
ContainerStateMount: "/var/lib/galaxy-game",
|
|
StopGracePeriod: time.Second,
|
|
},
|
|
Logger: zaptest.NewLogger(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
|
|
// Drive StartGame; the worker pool is not running so we invoke
|
|
// the worker entry directly through the public API. StartGame
|
|
// enqueues; we drain by calling Workers().Run in a goroutine and
|
|
// shutting it down once we observe the side effects.
|
|
pool := svc.Workers()
|
|
runCtx, runCancel := context.WithCancel(ctx)
|
|
t.Cleanup(runCancel)
|
|
go func() { _ = pool.Run(runCtx) }()
|
|
|
|
if err := svc.StartGame(ctx, gameID); err != nil {
|
|
t.Fatalf("StartGame: %v", err)
|
|
}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
rec, err := svc.GetRuntime(ctx, gameID)
|
|
if err == nil && rec.Status == runtime.RuntimeStatusRunning {
|
|
break
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
rec, err := svc.GetRuntime(ctx, gameID)
|
|
if err != nil {
|
|
t.Fatalf("GetRuntime: %v", err)
|
|
}
|
|
if rec.Status != runtime.RuntimeStatusRunning {
|
|
t.Fatalf("runtime status = %s, want running", rec.Status)
|
|
}
|
|
if rec.CurrentImageRef != "galaxy-game:0.1.0" {
|
|
t.Fatalf("image_ref = %s", rec.CurrentImageRef)
|
|
}
|
|
consumer.mu.Lock()
|
|
snapshotCount := len(consumer.snapshots)
|
|
consumer.mu.Unlock()
|
|
if snapshotCount == 0 {
|
|
t.Fatalf("expected runtime snapshot")
|
|
}
|
|
mappings, err := store.ListPlayerMappingsForGame(ctx, gameID)
|
|
if err != nil {
|
|
t.Fatalf("ListPlayerMappingsForGame: %v", err)
|
|
}
|
|
if len(mappings) != 1 || mappings[0].UserID != userID {
|
|
t.Fatalf("unexpected mappings: %+v", mappings)
|
|
}
|
|
}
|