feat: backend service
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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)
|
||||
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 "/api/v1/admin/init":
|
||||
_ = 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user