mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
2702e53068
Task 8.22. Tier bands lived in domain.DefaultTierPolicy(), compiled into every API binary, so retuning one meant building and rolling a new image -- least attractive exactly when it is most needed, as the rating distribution settles after launch. Bands now live in a tier_bands table, seeded by the migration with the exact policy the binaries hardcode, so this changes durable state without changing behaviour. Retuning is a rolling restart rather than a rebuild. Three properties the loader deliberately holds: - A malformed durable policy stops startup. Falling back on error would silently mis-tier every player, which is worse than not starting. - An empty table is supported and falls back to the compiled default, so an operator can truncate back to known-good without a deploy, and a fresh database works before the seed is reviewed. - PROVISIONAL is rejected as a band. It is derived from ranked game count, not rating, so a band claiming it would be unreachable at best and would shadow a real tier at worst. Bands stay backend-owned; clients still receive only the resulting label, per docs/MATCHMAKING.md. UNIQUE(min_rating) rejects two bands sharing a threshold, catching an ambiguous policy before NewTierPolicy does. testkit-api loads it too, so the control-plane integration scripts exercise the durable path rather than the compiled default. Integration tests cover the seeded policy matching the compiled one, retuning taking effect from the database alone, truncation falling back, and each invalid-policy shape being rejected. Verified they fail against a loader that ignores durable bands. The other two parts of 8.22 needed no work: the client UI already renders tier, provisional status, ranked games and the season countdown, and reconnect transport is 8.42's, dependent on live backend events.
143 lines
5.8 KiB
Go
143 lines
5.8 KiB
Go
// Package main is a TEST-ONLY control-plane binary, built solely to give
|
|
// scripts/verify_control_plane_integration.sh a real, running HTTP server --
|
|
// backed by real PostgreSQL, running the actual api.Service used in
|
|
// production -- for the Godot client to talk to over a real network
|
|
// connection. It is never referenced by any Dockerfile stage or Kubernetes
|
|
// manifest and must never be treated as a deployment target: fakeSteamLogin
|
|
// below accepts ANY non-empty ticket string as a valid identity instead of
|
|
// verifying it against the real Steam Web API, which is exactly the kind of
|
|
// bypass that must stay confined to a clearly-separate binary, never a flag
|
|
// on the real one (see cmd/control-plane, which has no such flag and never
|
|
// should). Every other adapter here is wired identically to cmd/control-plane.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/cosmic-clash/cosmic-clash/server/api"
|
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
|
"github.com/cosmic-clash/cosmic-clash/server/migrations"
|
|
"github.com/cosmic-clash/cosmic-clash/server/observability"
|
|
"github.com/cosmic-clash/cosmic-clash/server/store"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
func main() {
|
|
listen := flag.String("listen", "127.0.0.1:0", "HTTP listen address; port 0 picks a free port, printed on startup")
|
|
dsn := flag.String("dsn", os.Getenv("COSMIC_CLASH_POSTGRES_DSN"), "PostgreSQL connection string")
|
|
migrationDir := flag.String("migrations", "migrations", "directory containing numbered SQL migrations")
|
|
workloadSecret := flag.String("workload-secret", envOrDefault("COSMIC_CLASH_WORKLOAD_SECRET", "testkit-workload-secret"), "HMAC secret for signed workload tokens; defaults to a fixed test value since this binary is test-only")
|
|
flag.Parse()
|
|
if *dsn == "" {
|
|
fatalf("--dsn or COSMIC_CLASH_POSTGRES_DSN is required")
|
|
}
|
|
db, err := sql.Open("pgx", *dsn)
|
|
if err != nil {
|
|
fatalf("open PostgreSQL: %v", err)
|
|
}
|
|
defer db.Close()
|
|
startupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err := db.PingContext(startupCtx); err != nil {
|
|
fatalf("ping PostgreSQL: %v", err)
|
|
}
|
|
if err := migrations.Apply(startupCtx, db, *migrationDir); err != nil {
|
|
fatalf("apply migrations: %v", err)
|
|
}
|
|
service := &api.Service{
|
|
SessionBackend: store.PostgresSessions{DB: db},
|
|
SessionIssuer: store.PostgresSessions{DB: db},
|
|
SteamLogin: fakeSteamLogin{db: db},
|
|
QueueBackend: store.PostgresQueue{DB: db},
|
|
ProposalBackend: api.ProposalProviderFromStore(db),
|
|
ProposalPromoter: api.ProposalPromoterFromStore(db),
|
|
ServerRegistrar: api.ServerRegistrarFromStore(db),
|
|
ServerShutdowner: api.ServerShutdownerFromStore(db),
|
|
ServerConnections: api.ServerConnectionsFromStore(db),
|
|
ResultSubmitter: store.PostgresResults{DB: db},
|
|
RankedProfileProvider: store.PostgresRankedProfiles{DB: db},
|
|
TierPolicy: domain.DefaultTierPolicy(),
|
|
Assignment: api.AssignmentProviderFromStore(db),
|
|
Roster: func(ctx context.Context, binding domain.WorkloadBinding, now time.Time) ([][]byte, error) {
|
|
return store.GetAssignmentRoster(ctx, db, binding.MatchID, binding.ServerID, now)
|
|
},
|
|
ProbeRecorder: store.PostgresQueue{DB: db},
|
|
WorkloadVerify: api.WorkloadVerifierFromSignedToken([]byte(*workloadSecret), db),
|
|
Metrics: observability.NewMetrics(),
|
|
Now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
// Load the durable policy here too, so the control-plane integration
|
|
// scripts exercise the same path production takes rather than the
|
|
// compiled default.
|
|
tierPolicy, err := store.LoadTierPolicy(startupCtx, db)
|
|
if err != nil {
|
|
fatalf("load tier policy: %v", err)
|
|
}
|
|
service.TierPolicy = tierPolicy
|
|
handler := service.Handler()
|
|
listener, err := net.Listen("tcp", *listen)
|
|
if err != nil {
|
|
fatalf("listen: %v", err)
|
|
}
|
|
fmt.Printf("testkit-api listening on http://%s\n", listener.Addr())
|
|
server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
|
|
serveErr := make(chan error, 1)
|
|
go func() { serveErr <- server.Serve(listener) }()
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
go api.RunProposalOutboxDispatcher(ctx, db, service)
|
|
go api.RunResultOutboxDispatcher(ctx, db, service)
|
|
go api.RunStateOutboxDispatcher(ctx, db, service)
|
|
select {
|
|
case err := <-serveErr:
|
|
if err != nil && err != http.ErrServerClosed {
|
|
fatalf("serve: %v", err)
|
|
}
|
|
case <-ctx.Done():
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer shutdownCancel()
|
|
_ = server.Shutdown(shutdownCtx)
|
|
}
|
|
}
|
|
|
|
// fakeSteamLogin derives a deterministic identity from the ticket string
|
|
// itself (never a real Steam Web API ticket in this binary) and ensures its
|
|
// identities row exists so session issuance's foreign key is satisfied.
|
|
type fakeSteamLogin struct{ db *sql.DB }
|
|
|
|
func (f fakeSteamLogin) Authenticate(ctx context.Context, ticket string, _ time.Time) (domain.VerifiedIdentity, error) {
|
|
if ticket == "" {
|
|
return domain.VerifiedIdentity{}, fmt.Errorf("empty ticket")
|
|
}
|
|
digest := sha256.Sum256([]byte(ticket))
|
|
playerID := "testkit-" + hex.EncodeToString(digest[:8])
|
|
steamID := "testkit-steam-" + hex.EncodeToString(digest[8:16])
|
|
if _, err := f.db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ($1, $2) ON CONFLICT (player_id) DO NOTHING`, playerID, steamID); err != nil {
|
|
return domain.VerifiedIdentity{}, err
|
|
}
|
|
return domain.VerifiedIdentity{PlayerID: playerID, SteamID: steamID}, nil
|
|
}
|
|
|
|
func envOrDefault(name, fallback string) string {
|
|
if value := os.Getenv(name); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func fatalf(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "testkit-api: "+format+"\n", args...)
|
|
os.Exit(1)
|
|
}
|