feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
+448
View File
@@ -0,0 +1,448 @@
// Package config loads the Game Master process configuration from
// environment variables.
package config
import (
"fmt"
"strings"
"time"
"galaxy/postgres"
"galaxy/redisconn"
"galaxy/gamemaster/internal/telemetry"
)
const (
envPrefix = "GAMEMASTER"
shutdownTimeoutEnvVar = "GAMEMASTER_SHUTDOWN_TIMEOUT"
logLevelEnvVar = "GAMEMASTER_LOG_LEVEL"
internalHTTPAddrEnvVar = "GAMEMASTER_INTERNAL_HTTP_ADDR"
internalHTTPReadHeaderTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
internalHTTPReadTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_READ_TIMEOUT"
internalHTTPWriteTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_WRITE_TIMEOUT"
internalHTTPIdleTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_IDLE_TIMEOUT"
lobbyEventsStreamEnvVar = "GAMEMASTER_REDIS_LOBBY_EVENTS_STREAM"
healthEventsStreamEnvVar = "GAMEMASTER_REDIS_HEALTH_EVENTS_STREAM"
notificationIntentsStreamEnvVar = "GAMEMASTER_REDIS_NOTIFICATION_INTENTS_STREAM"
streamBlockTimeoutEnvVar = "GAMEMASTER_STREAM_BLOCK_TIMEOUT"
engineCallTimeoutEnvVar = "GAMEMASTER_ENGINE_CALL_TIMEOUT"
engineProbeTimeoutEnvVar = "GAMEMASTER_ENGINE_PROBE_TIMEOUT"
lobbyInternalBaseURLEnvVar = "GAMEMASTER_LOBBY_INTERNAL_BASE_URL"
lobbyInternalTimeoutEnvVar = "GAMEMASTER_LOBBY_INTERNAL_TIMEOUT"
rtmInternalBaseURLEnvVar = "GAMEMASTER_RTM_INTERNAL_BASE_URL"
rtmInternalTimeoutEnvVar = "GAMEMASTER_RTM_INTERNAL_TIMEOUT"
schedulerTickIntervalEnvVar = "GAMEMASTER_SCHEDULER_TICK_INTERVAL"
turnGenerationTimeoutEnvVar = "GAMEMASTER_TURN_GENERATION_TIMEOUT"
membershipCacheTTLEnvVar = "GAMEMASTER_MEMBERSHIP_CACHE_TTL"
membershipCacheMaxGamesEnvVar = "GAMEMASTER_MEMBERSHIP_CACHE_MAX_GAMES"
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER"
otelExporterOTLPProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL"
otelExporterOTLPTracesProtocolEnvVar = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"
otelExporterOTLPMetricsProtocolEnvVar = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
otelStdoutTracesEnabledEnvVar = "GAMEMASTER_OTEL_STDOUT_TRACES_ENABLED"
otelStdoutMetricsEnabledEnvVar = "GAMEMASTER_OTEL_STDOUT_METRICS_ENABLED"
defaultShutdownTimeout = 30 * time.Second
defaultLogLevel = "info"
defaultInternalHTTPAddr = ":8097"
defaultReadHeaderTimeout = 2 * time.Second
defaultReadTimeout = 5 * time.Second
defaultWriteTimeout = 30 * time.Second
defaultIdleTimeout = 60 * time.Second
defaultLobbyEventsStream = "gm:lobby_events"
defaultHealthEventsStream = "runtime:health_events"
defaultNotificationIntentsStream = "notification:intents"
defaultStreamBlockTimeout = 5 * time.Second
defaultEngineCallTimeout = 30 * time.Second
defaultEngineProbeTimeout = 5 * time.Second
defaultLobbyInternalTimeout = 2 * time.Second
defaultRTMInternalTimeout = 5 * time.Second
defaultSchedulerTickInterval = time.Second
defaultTurnGenerationTimeout = 60 * time.Second
defaultMembershipCacheTTL = 30 * time.Second
defaultMembershipCacheMaxGames = 4096
defaultOTelServiceName = "galaxy-gamemaster"
)
// Config stores the full Game Master process configuration.
type Config struct {
// ShutdownTimeout bounds graceful shutdown of every long-lived
// component.
ShutdownTimeout time.Duration
// Logging configures the process-wide structured logger.
Logging LoggingConfig
// InternalHTTP configures the trusted internal HTTP listener.
InternalHTTP InternalHTTPConfig
// Postgres configures the PostgreSQL-backed durable store consumed
// via `pkg/postgres`.
Postgres PostgresConfig
// Redis configures the shared Redis connection topology consumed via
// `pkg/redisconn`.
Redis RedisConfig
// Streams stores the stable Redis Stream names GM reads from and
// writes to.
Streams StreamsConfig
// EngineClient configures per-call timeouts of the engine HTTP
// client.
EngineClient EngineClientConfig
// Lobby configures the synchronous Lobby internal REST client.
Lobby LobbyClientConfig
// RTM configures the synchronous Runtime Manager internal REST
// client.
RTM RTMClientConfig
// Scheduler configures the scheduler ticker worker and the per-turn
// generation deadline.
Scheduler SchedulerConfig
// MembershipCache configures the in-process membership cache.
MembershipCache MembershipCacheConfig
// Telemetry configures the process-wide OpenTelemetry runtime.
Telemetry TelemetryConfig
}
// LoggingConfig configures the process-wide structured logger.
type LoggingConfig struct {
// Level stores the process log level accepted by log/slog.
Level string
}
// InternalHTTPConfig configures the trusted internal HTTP listener.
type InternalHTTPConfig struct {
// Addr stores the TCP listen address.
Addr string
// ReadHeaderTimeout bounds request-header reading.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds reading one request.
ReadTimeout time.Duration
// WriteTimeout bounds writing one response.
WriteTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
}
// Validate reports whether cfg stores a usable internal HTTP listener
// configuration.
func (cfg InternalHTTPConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("internal HTTP addr must not be empty")
case !isTCPAddr(cfg.Addr):
return fmt.Errorf("internal HTTP addr %q must use host:port form", cfg.Addr)
case cfg.ReadHeaderTimeout <= 0:
return fmt.Errorf("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return fmt.Errorf("internal HTTP read timeout must be positive")
case cfg.WriteTimeout <= 0:
return fmt.Errorf("internal HTTP write timeout must be positive")
case cfg.IdleTimeout <= 0:
return fmt.Errorf("internal HTTP idle timeout must be positive")
default:
return nil
}
}
// PostgresConfig configures the PostgreSQL-backed durable store consumed
// via `pkg/postgres`.
type PostgresConfig struct {
// Conn carries the primary plus replica DSN topology and pool tuning.
Conn postgres.Config
}
// Validate reports whether cfg stores a usable PostgreSQL configuration.
func (cfg PostgresConfig) Validate() error {
return cfg.Conn.Validate()
}
// RedisConfig configures the Game Master Redis connection topology.
type RedisConfig struct {
// Conn carries the connection topology (master, replicas, password,
// db, per-call timeout).
Conn redisconn.Config
}
// Validate reports whether cfg stores a usable Redis configuration.
func (cfg RedisConfig) Validate() error {
return cfg.Conn.Validate()
}
// StreamsConfig stores the stable Redis Stream names used by Game Master.
type StreamsConfig struct {
// LobbyEvents stores the Redis Streams key GM publishes runtime
// snapshot updates and game-finished events to.
LobbyEvents string
// HealthEvents stores the Redis Streams key GM consumes runtime
// health events from.
HealthEvents string
// NotificationIntents stores the Redis Streams key GM publishes
// notification intents to.
NotificationIntents string
// BlockTimeout bounds the maximum blocking read window for stream
// consumers.
BlockTimeout time.Duration
}
// Validate reports whether cfg stores usable stream names.
func (cfg StreamsConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.LobbyEvents) == "":
return fmt.Errorf("redis lobby events stream must not be empty")
case strings.TrimSpace(cfg.HealthEvents) == "":
return fmt.Errorf("redis health events stream must not be empty")
case strings.TrimSpace(cfg.NotificationIntents) == "":
return fmt.Errorf("redis notification intents stream must not be empty")
case cfg.BlockTimeout <= 0:
return fmt.Errorf("redis stream block timeout must be positive")
default:
return nil
}
}
// EngineClientConfig configures per-call timeouts of the engine HTTP
// client.
type EngineClientConfig struct {
// CallTimeout bounds one full engine call (including turn generation
// for large games).
CallTimeout time.Duration
// ProbeTimeout bounds inspect-style reads against the engine.
ProbeTimeout time.Duration
}
// Validate reports whether cfg stores usable engine client timeouts.
func (cfg EngineClientConfig) Validate() error {
switch {
case cfg.CallTimeout <= 0:
return fmt.Errorf("engine call timeout must be positive")
case cfg.ProbeTimeout <= 0:
return fmt.Errorf("engine probe timeout must be positive")
default:
return nil
}
}
// LobbyClientConfig configures the synchronous Lobby internal REST
// client.
type LobbyClientConfig struct {
// BaseURL stores the trusted Lobby internal listener base URL.
BaseURL string
// Timeout bounds one Lobby internal request.
Timeout time.Duration
}
// Validate reports whether cfg stores a usable Lobby client
// configuration.
func (cfg LobbyClientConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return fmt.Errorf("lobby internal base url must not be empty")
case !isHTTPURL(cfg.BaseURL):
return fmt.Errorf("lobby internal base url %q must be an absolute http(s) URL", cfg.BaseURL)
case cfg.Timeout <= 0:
return fmt.Errorf("lobby internal timeout must be positive")
default:
return nil
}
}
// RTMClientConfig configures the synchronous Runtime Manager internal
// REST client.
type RTMClientConfig struct {
// BaseURL stores the trusted Runtime Manager internal listener base
// URL.
BaseURL string
// Timeout bounds one Runtime Manager internal request.
Timeout time.Duration
}
// Validate reports whether cfg stores a usable Runtime Manager client
// configuration.
func (cfg RTMClientConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return fmt.Errorf("rtm internal base url must not be empty")
case !isHTTPURL(cfg.BaseURL):
return fmt.Errorf("rtm internal base url %q must be an absolute http(s) URL", cfg.BaseURL)
case cfg.Timeout <= 0:
return fmt.Errorf("rtm internal timeout must be positive")
default:
return nil
}
}
// SchedulerConfig configures the scheduler ticker worker and the
// per-turn generation deadline.
type SchedulerConfig struct {
// TickInterval is the period between two scheduler scans for due
// runtime records.
TickInterval time.Duration
// TurnGenerationTimeout bounds one engine `/admin/turn` call from
// the scheduler's perspective.
TurnGenerationTimeout time.Duration
}
// Validate reports whether cfg stores usable scheduler timings.
func (cfg SchedulerConfig) Validate() error {
switch {
case cfg.TickInterval <= 0:
return fmt.Errorf("scheduler tick interval must be positive")
case cfg.TurnGenerationTimeout <= 0:
return fmt.Errorf("turn generation timeout must be positive")
default:
return nil
}
}
// MembershipCacheConfig configures the in-process membership cache.
type MembershipCacheConfig struct {
// TTL bounds how long an unobserved membership entry stays cached
// before a forced reload from Lobby.
TTL time.Duration
// MaxGames bounds how many games can populate the cache before
// LRU eviction kicks in.
MaxGames int
}
// Validate reports whether cfg stores usable membership cache settings.
func (cfg MembershipCacheConfig) Validate() error {
switch {
case cfg.TTL <= 0:
return fmt.Errorf("membership cache ttl must be positive")
case cfg.MaxGames <= 0:
return fmt.Errorf("membership cache max games must be positive")
default:
return nil
}
}
// TelemetryConfig configures the Game Master OpenTelemetry runtime.
type TelemetryConfig struct {
// ServiceName overrides the default OpenTelemetry service name.
ServiceName string
// TracesExporter selects the external traces exporter. Supported
// values are `none` and `otlp`.
TracesExporter string
// MetricsExporter selects the external metrics exporter. Supported
// values are `none` and `otlp`.
MetricsExporter string
// TracesProtocol selects the OTLP traces protocol when
// TracesExporter is `otlp`.
TracesProtocol string
// MetricsProtocol selects the OTLP metrics protocol when
// MetricsExporter is `otlp`.
MetricsProtocol string
// StdoutTracesEnabled enables the additional stdout trace exporter
// used for local development and debugging.
StdoutTracesEnabled bool
// StdoutMetricsEnabled enables the additional stdout metric
// exporter used for local development and debugging.
StdoutMetricsEnabled bool
}
// Validate reports whether cfg contains a supported OpenTelemetry
// configuration.
func (cfg TelemetryConfig) Validate() error {
return telemetry.ProcessConfig{
ServiceName: cfg.ServiceName,
TracesExporter: cfg.TracesExporter,
MetricsExporter: cfg.MetricsExporter,
TracesProtocol: cfg.TracesProtocol,
MetricsProtocol: cfg.MetricsProtocol,
StdoutTracesEnabled: cfg.StdoutTracesEnabled,
StdoutMetricsEnabled: cfg.StdoutMetricsEnabled,
}.Validate()
}
// DefaultConfig returns the default Game Master process configuration.
func DefaultConfig() Config {
return Config{
ShutdownTimeout: defaultShutdownTimeout,
Logging: LoggingConfig{
Level: defaultLogLevel,
},
InternalHTTP: InternalHTTPConfig{
Addr: defaultInternalHTTPAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
WriteTimeout: defaultWriteTimeout,
IdleTimeout: defaultIdleTimeout,
},
Postgres: PostgresConfig{
Conn: postgres.DefaultConfig(),
},
Redis: RedisConfig{
Conn: redisconn.DefaultConfig(),
},
Streams: StreamsConfig{
LobbyEvents: defaultLobbyEventsStream,
HealthEvents: defaultHealthEventsStream,
NotificationIntents: defaultNotificationIntentsStream,
BlockTimeout: defaultStreamBlockTimeout,
},
EngineClient: EngineClientConfig{
CallTimeout: defaultEngineCallTimeout,
ProbeTimeout: defaultEngineProbeTimeout,
},
Lobby: LobbyClientConfig{
Timeout: defaultLobbyInternalTimeout,
},
RTM: RTMClientConfig{
Timeout: defaultRTMInternalTimeout,
},
Scheduler: SchedulerConfig{
TickInterval: defaultSchedulerTickInterval,
TurnGenerationTimeout: defaultTurnGenerationTimeout,
},
MembershipCache: MembershipCacheConfig{
TTL: defaultMembershipCacheTTL,
MaxGames: defaultMembershipCacheMaxGames,
},
Telemetry: TelemetryConfig{
ServiceName: defaultOTelServiceName,
TracesExporter: "none",
MetricsExporter: "none",
},
}
}
+169
View File
@@ -0,0 +1,169 @@
package config
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func validEnv(t *testing.T) {
t.Helper()
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":8097")
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy?search_path=gamemaster&sslmode=disable")
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
t.Setenv("GAMEMASTER_LOBBY_INTERNAL_BASE_URL", "http://lobby:8095")
t.Setenv("GAMEMASTER_RTM_INTERNAL_BASE_URL", "http://rtmanager:8096")
}
func TestLoadFromEnvAcceptsDefaults(t *testing.T) {
validEnv(t)
cfg, err := LoadFromEnv()
require.NoError(t, err)
require.Equal(t, ":8097", cfg.InternalHTTP.Addr)
require.Equal(t, 30*time.Second, cfg.ShutdownTimeout)
require.Equal(t, "info", cfg.Logging.Level)
require.Equal(t, "gm:lobby_events", cfg.Streams.LobbyEvents)
require.Equal(t, "runtime:health_events", cfg.Streams.HealthEvents)
require.Equal(t, "notification:intents", cfg.Streams.NotificationIntents)
require.Equal(t, 5*time.Second, cfg.Streams.BlockTimeout)
require.Equal(t, 30*time.Second, cfg.EngineClient.CallTimeout)
require.Equal(t, 5*time.Second, cfg.EngineClient.ProbeTimeout)
require.Equal(t, "http://lobby:8095", cfg.Lobby.BaseURL)
require.Equal(t, 2*time.Second, cfg.Lobby.Timeout)
require.Equal(t, "http://rtmanager:8096", cfg.RTM.BaseURL)
require.Equal(t, 5*time.Second, cfg.RTM.Timeout)
require.Equal(t, time.Second, cfg.Scheduler.TickInterval)
require.Equal(t, 60*time.Second, cfg.Scheduler.TurnGenerationTimeout)
require.Equal(t, 30*time.Second, cfg.MembershipCache.TTL)
require.Equal(t, 4096, cfg.MembershipCache.MaxGames)
require.Equal(t, "galaxy-gamemaster", cfg.Telemetry.ServiceName)
}
func TestLoadFromEnvHonoursOverrides(t *testing.T) {
validEnv(t)
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":9097")
t.Setenv("GAMEMASTER_REDIS_LOBBY_EVENTS_STREAM", "custom:lobby_events")
t.Setenv("GAMEMASTER_ENGINE_CALL_TIMEOUT", "45s")
t.Setenv("GAMEMASTER_SCHEDULER_TICK_INTERVAL", "500ms")
t.Setenv("GAMEMASTER_MEMBERSHIP_CACHE_TTL", "60s")
t.Setenv("GAMEMASTER_MEMBERSHIP_CACHE_MAX_GAMES", "1024")
cfg, err := LoadFromEnv()
require.NoError(t, err)
require.Equal(t, ":9097", cfg.InternalHTTP.Addr)
require.Equal(t, "custom:lobby_events", cfg.Streams.LobbyEvents)
require.Equal(t, 45*time.Second, cfg.EngineClient.CallTimeout)
require.Equal(t, 500*time.Millisecond, cfg.Scheduler.TickInterval)
require.Equal(t, 60*time.Second, cfg.MembershipCache.TTL)
require.Equal(t, 1024, cfg.MembershipCache.MaxGames)
}
func TestLoadFromEnvRequiresInternalHTTPAddr(t *testing.T) {
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy")
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
t.Setenv("GAMEMASTER_LOBBY_INTERNAL_BASE_URL", "http://lobby:8095")
t.Setenv("GAMEMASTER_RTM_INTERNAL_BASE_URL", "http://rtmanager:8096")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "GAMEMASTER_INTERNAL_HTTP_ADDR")
}
func TestLoadFromEnvRequiresLobbyBaseURL(t *testing.T) {
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":8097")
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy")
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
t.Setenv("GAMEMASTER_RTM_INTERNAL_BASE_URL", "http://rtmanager:8096")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "GAMEMASTER_LOBBY_INTERNAL_BASE_URL")
}
func TestLoadFromEnvRequiresRTMBaseURL(t *testing.T) {
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":8097")
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy")
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
t.Setenv("GAMEMASTER_LOBBY_INTERNAL_BASE_URL", "http://lobby:8095")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "GAMEMASTER_RTM_INTERNAL_BASE_URL")
}
func TestLoadFromEnvRejectsBadLogLevel(t *testing.T) {
validEnv(t)
t.Setenv("GAMEMASTER_LOG_LEVEL", "verbose")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "GAMEMASTER_LOG_LEVEL")
}
func TestLoadFromEnvRejectsBadDuration(t *testing.T) {
validEnv(t)
t.Setenv("GAMEMASTER_ENGINE_CALL_TIMEOUT", "thirty seconds")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "GAMEMASTER_ENGINE_CALL_TIMEOUT")
}
func TestInternalHTTPValidateRejectsBadAddr(t *testing.T) {
cfg := DefaultConfig().InternalHTTP
cfg.Addr = "not-an-addr"
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "host:port")
}
func TestStreamsValidateRequiresAllNames(t *testing.T) {
cfg := DefaultConfig().Streams
cfg.LobbyEvents = " "
err := cfg.Validate()
require.Error(t, err)
require.True(t, strings.Contains(err.Error(), "lobby events"))
}
func TestLobbyClientValidateRejectsBadURL(t *testing.T) {
cfg := LobbyClientConfig{BaseURL: "ftp://lobby", Timeout: time.Second}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "http(s)")
}
func TestRTMClientValidateRejectsEmptyURL(t *testing.T) {
cfg := RTMClientConfig{BaseURL: " ", Timeout: time.Second}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "rtm internal base url")
}
func TestSchedulerValidateRejectsZeroInterval(t *testing.T) {
cfg := SchedulerConfig{TickInterval: 0, TurnGenerationTimeout: time.Second}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "scheduler tick interval")
}
func TestMembershipCacheValidateRejectsZero(t *testing.T) {
cfg := MembershipCacheConfig{TTL: 0, MaxGames: 1}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "ttl")
cfg = MembershipCacheConfig{TTL: time.Second, MaxGames: 0}
err = cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "max games")
}
+219
View File
@@ -0,0 +1,219 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"galaxy/postgres"
"galaxy/redisconn"
)
// LoadFromEnv builds Config from environment variables and validates the
// resulting configuration.
func LoadFromEnv() (Config, error) {
cfg := DefaultConfig()
var err error
cfg.ShutdownTimeout, err = durationEnv(shutdownTimeoutEnvVar, cfg.ShutdownTimeout)
if err != nil {
return Config{}, err
}
cfg.Logging.Level = stringEnv(logLevelEnvVar, cfg.Logging.Level)
addr, ok := os.LookupEnv(internalHTTPAddrEnvVar)
if !ok || strings.TrimSpace(addr) == "" {
return Config{}, fmt.Errorf("%s must be set", internalHTTPAddrEnvVar)
}
cfg.InternalHTTP.Addr = strings.TrimSpace(addr)
cfg.InternalHTTP.ReadHeaderTimeout, err = durationEnv(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.ReadTimeout, err = durationEnv(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.WriteTimeout, err = durationEnv(internalHTTPWriteTimeoutEnvVar, cfg.InternalHTTP.WriteTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.IdleTimeout, err = durationEnv(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
pgConn, err := postgres.LoadFromEnv(envPrefix)
if err != nil {
return Config{}, err
}
cfg.Postgres.Conn = pgConn
redisConn, err := redisconn.LoadFromEnv(envPrefix)
if err != nil {
return Config{}, err
}
cfg.Redis.Conn = redisConn
cfg.Streams.LobbyEvents = stringEnv(lobbyEventsStreamEnvVar, cfg.Streams.LobbyEvents)
cfg.Streams.HealthEvents = stringEnv(healthEventsStreamEnvVar, cfg.Streams.HealthEvents)
cfg.Streams.NotificationIntents = stringEnv(notificationIntentsStreamEnvVar, cfg.Streams.NotificationIntents)
cfg.Streams.BlockTimeout, err = durationEnv(streamBlockTimeoutEnvVar, cfg.Streams.BlockTimeout)
if err != nil {
return Config{}, err
}
cfg.EngineClient.CallTimeout, err = durationEnv(engineCallTimeoutEnvVar, cfg.EngineClient.CallTimeout)
if err != nil {
return Config{}, err
}
cfg.EngineClient.ProbeTimeout, err = durationEnv(engineProbeTimeoutEnvVar, cfg.EngineClient.ProbeTimeout)
if err != nil {
return Config{}, err
}
lobbyURL, ok := os.LookupEnv(lobbyInternalBaseURLEnvVar)
if !ok || strings.TrimSpace(lobbyURL) == "" {
return Config{}, fmt.Errorf("%s must be set", lobbyInternalBaseURLEnvVar)
}
cfg.Lobby.BaseURL = strings.TrimSpace(lobbyURL)
cfg.Lobby.Timeout, err = durationEnv(lobbyInternalTimeoutEnvVar, cfg.Lobby.Timeout)
if err != nil {
return Config{}, err
}
rtmURL, ok := os.LookupEnv(rtmInternalBaseURLEnvVar)
if !ok || strings.TrimSpace(rtmURL) == "" {
return Config{}, fmt.Errorf("%s must be set", rtmInternalBaseURLEnvVar)
}
cfg.RTM.BaseURL = strings.TrimSpace(rtmURL)
cfg.RTM.Timeout, err = durationEnv(rtmInternalTimeoutEnvVar, cfg.RTM.Timeout)
if err != nil {
return Config{}, err
}
cfg.Scheduler.TickInterval, err = durationEnv(schedulerTickIntervalEnvVar, cfg.Scheduler.TickInterval)
if err != nil {
return Config{}, err
}
cfg.Scheduler.TurnGenerationTimeout, err = durationEnv(turnGenerationTimeoutEnvVar, cfg.Scheduler.TurnGenerationTimeout)
if err != nil {
return Config{}, err
}
cfg.MembershipCache.TTL, err = durationEnv(membershipCacheTTLEnvVar, cfg.MembershipCache.TTL)
if err != nil {
return Config{}, err
}
cfg.MembershipCache.MaxGames, err = intEnv(membershipCacheMaxGamesEnvVar, cfg.MembershipCache.MaxGames)
if err != nil {
return Config{}, err
}
cfg.Telemetry.ServiceName = stringEnv(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
cfg.Telemetry.TracesExporter = normalizeExporterValue(stringEnv(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
cfg.Telemetry.MetricsExporter = normalizeExporterValue(stringEnv(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter))
cfg.Telemetry.TracesProtocol = normalizeProtocolValue(
os.Getenv(otelExporterOTLPTracesProtocolEnvVar),
os.Getenv(otelExporterOTLPProtocolEnvVar),
cfg.Telemetry.TracesProtocol,
)
cfg.Telemetry.MetricsProtocol = normalizeProtocolValue(
os.Getenv(otelExporterOTLPMetricsProtocolEnvVar),
os.Getenv(otelExporterOTLPProtocolEnvVar),
cfg.Telemetry.MetricsProtocol,
)
cfg.Telemetry.StdoutTracesEnabled, err = boolEnv(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled)
if err != nil {
return Config{}, err
}
cfg.Telemetry.StdoutMetricsEnabled, err = boolEnv(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled)
if err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func stringEnv(name string, fallback string) string {
value, ok := os.LookupEnv(name)
if !ok {
return fallback
}
return strings.TrimSpace(value)
}
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := time.ParseDuration(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s: parse duration: %w", name, err)
}
return parsed, nil
}
func intEnv(name string, fallback int) (int, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s: parse int: %w", name, err)
}
return parsed, nil
}
func boolEnv(name string, fallback bool) (bool, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
if err != nil {
return false, fmt.Errorf("%s: parse bool: %w", name, err)
}
return parsed, nil
}
func normalizeExporterValue(value string) string {
trimmed := strings.TrimSpace(value)
switch trimmed {
case "", "none":
return "none"
default:
return trimmed
}
}
func normalizeProtocolValue(primary string, fallback string, defaultValue string) string {
primary = strings.TrimSpace(primary)
if primary != "" {
return primary
}
fallback = strings.TrimSpace(fallback)
if fallback != "" {
return fallback
}
return strings.TrimSpace(defaultValue)
}
+90
View File
@@ -0,0 +1,90 @@
package config
import (
"fmt"
"log/slog"
"net"
"net/url"
"strings"
)
// Validate reports whether cfg stores a usable Game Master process
// configuration.
func (cfg Config) Validate() error {
if cfg.ShutdownTimeout <= 0 {
return fmt.Errorf("%s must be positive", shutdownTimeoutEnvVar)
}
if err := validateSlogLevel(cfg.Logging.Level); err != nil {
return fmt.Errorf("%s: %w", logLevelEnvVar, err)
}
if err := cfg.InternalHTTP.Validate(); err != nil {
return err
}
if err := cfg.Postgres.Validate(); err != nil {
return err
}
if err := cfg.Redis.Validate(); err != nil {
return err
}
if err := cfg.Streams.Validate(); err != nil {
return err
}
if err := cfg.EngineClient.Validate(); err != nil {
return err
}
if err := cfg.Lobby.Validate(); err != nil {
return err
}
if err := cfg.RTM.Validate(); err != nil {
return err
}
if err := cfg.Scheduler.Validate(); err != nil {
return err
}
if err := cfg.MembershipCache.Validate(); err != nil {
return err
}
if err := cfg.Telemetry.Validate(); err != nil {
return err
}
return nil
}
func validateSlogLevel(level string) error {
var slogLevel slog.Level
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
return fmt.Errorf("invalid slog level %q: %w", level, err)
}
return nil
}
func isTCPAddr(value string) bool {
host, port, err := net.SplitHostPort(strings.TrimSpace(value))
if err != nil {
return false
}
if port == "" {
return false
}
if host == "" {
return true
}
return !strings.Contains(host, " ")
}
func isHTTPURL(value string) bool {
parsed, err := url.Parse(strings.TrimSpace(value))
if err != nil {
return false
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false
}
return parsed.Host != ""
}