feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
@@ -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)
}
}