feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
+525
View File
@@ -0,0 +1,525 @@
// Package config loads the Game Lobby Service process configuration from
// environment variables.
package config
import (
"crypto/tls"
"fmt"
"strings"
"time"
"galaxy/lobby/internal/telemetry"
)
const (
shutdownTimeoutEnvVar = "LOBBY_SHUTDOWN_TIMEOUT"
logLevelEnvVar = "LOBBY_LOG_LEVEL"
publicHTTPAddrEnvVar = "LOBBY_PUBLIC_HTTP_ADDR"
publicHTTPReadHeaderTimeoutEnvVar = "LOBBY_PUBLIC_HTTP_READ_HEADER_TIMEOUT"
publicHTTPReadTimeoutEnvVar = "LOBBY_PUBLIC_HTTP_READ_TIMEOUT"
publicHTTPIdleTimeoutEnvVar = "LOBBY_PUBLIC_HTTP_IDLE_TIMEOUT"
internalHTTPAddrEnvVar = "LOBBY_INTERNAL_HTTP_ADDR"
internalHTTPReadHeaderTimeoutEnvVar = "LOBBY_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
internalHTTPReadTimeoutEnvVar = "LOBBY_INTERNAL_HTTP_READ_TIMEOUT"
internalHTTPIdleTimeoutEnvVar = "LOBBY_INTERNAL_HTTP_IDLE_TIMEOUT"
redisAddrEnvVar = "LOBBY_REDIS_ADDR"
redisUsernameEnvVar = "LOBBY_REDIS_USERNAME"
redisPasswordEnvVar = "LOBBY_REDIS_PASSWORD"
redisDBEnvVar = "LOBBY_REDIS_DB"
redisTLSEnabledEnvVar = "LOBBY_REDIS_TLS_ENABLED"
redisOperationTimeoutEnvVar = "LOBBY_REDIS_OPERATION_TIMEOUT"
gmEventsStreamEnvVar = "LOBBY_GM_EVENTS_STREAM"
gmEventsReadBlockTimeoutEnvVar = "LOBBY_GM_EVENTS_READ_BLOCK_TIMEOUT"
userLifecycleStreamEnvVar = "LOBBY_USER_LIFECYCLE_STREAM"
userLifecycleReadBlockTimeoutEnvVar = "LOBBY_USER_LIFECYCLE_READ_BLOCK_TIMEOUT"
runtimeStartJobsStreamEnvVar = "LOBBY_RUNTIME_START_JOBS_STREAM"
runtimeStopJobsStreamEnvVar = "LOBBY_RUNTIME_STOP_JOBS_STREAM"
runtimeJobResultsStreamEnvVar = "LOBBY_RUNTIME_JOB_RESULTS_STREAM"
runtimeJobResultsReadBlockTimeoutEnv = "LOBBY_RUNTIME_JOB_RESULTS_READ_BLOCK_TIMEOUT"
notificationIntentsStreamEnvVar = "LOBBY_NOTIFICATION_INTENTS_STREAM"
userServiceBaseURLEnvVar = "LOBBY_USER_SERVICE_BASE_URL"
userServiceTimeoutEnvVar = "LOBBY_USER_SERVICE_TIMEOUT"
gmBaseURLEnvVar = "LOBBY_GM_BASE_URL"
gmTimeoutEnvVar = "LOBBY_GM_TIMEOUT"
enrollmentAutomationIntervalEnvVar = "LOBBY_ENROLLMENT_AUTOMATION_INTERVAL"
raceNameDirectoryBackendEnvVar = "LOBBY_RACE_NAME_DIRECTORY_BACKEND"
raceNameExpirationIntervalEnvVar = "LOBBY_RACE_NAME_EXPIRATION_INTERVAL"
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 = "LOBBY_OTEL_STDOUT_TRACES_ENABLED"
otelStdoutMetricsEnabledEnvVar = "LOBBY_OTEL_STDOUT_METRICS_ENABLED"
defaultShutdownTimeout = 30 * time.Second
defaultLogLevel = "info"
defaultPublicHTTPAddr = ":8094"
defaultInternalHTTPAddr = ":8095"
defaultReadHeaderTimeout = 2 * time.Second
defaultReadTimeout = 10 * time.Second
defaultIdleTimeout = time.Minute
defaultRedisDB = 0
defaultRedisOperationTimeout = 2 * time.Second
defaultGMEventsStream = "gm:lobby_events"
defaultGMEventsReadBlockTimeout = 2 * time.Second
defaultUserLifecycleStream = "user:lifecycle_events"
defaultUserLifecycleReadBlockTimeout = 2 * time.Second
defaultRuntimeStartJobsStream = "runtime:start_jobs"
defaultRuntimeStopJobsStream = "runtime:stop_jobs"
defaultRuntimeJobResultsStream = "runtime:job_results"
defaultRuntimeJobResultsReadBlockTimeout = 2 * time.Second
defaultNotificationIntentsStream = "notification:intents"
defaultUserServiceTimeout = time.Second
defaultGMTimeout = 5 * time.Second
defaultEnrollmentAutomationInterval = 30 * time.Second
defaultRaceNameExpirationInterval = time.Hour
defaultOTelServiceName = "galaxy-lobby"
// RaceNameDirectoryBackendRedis selects the Redis-backed Race Name
// Directory adapter. It is the default production backend.
RaceNameDirectoryBackendRedis = "redis"
// RaceNameDirectoryBackendStub selects the in-process Race Name
// Directory stub used by unit tests that do not need Redis.
RaceNameDirectoryBackendStub = "stub"
)
// Config stores the full Game Lobby Service 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
// PublicHTTP configures the public authenticated HTTP listener that serves
// gateway-forwarded player commands.
PublicHTTP PublicHTTPConfig
// InternalHTTP configures the trusted internal HTTP listener that serves
// Game Master registration and admin operations.
InternalHTTP InternalHTTPConfig
// Redis configures the shared Redis client and the Redis Streams keys
// consumed by the runnable service skeleton and its future workers.
Redis RedisConfig
// UserService configures the synchronous User Service eligibility client.
UserService UserServiceConfig
// GM configures the synchronous Game Master registration client.
GM GMConfig
// EnrollmentAutomation configures the periodic enrollment automation
// worker that will be added in the
EnrollmentAutomation EnrollmentAutomationConfig
// RaceNameDirectory configures the Race Name Directory backend
// selector. It governs which adapter is wired by the runtime:
// Redis-backed persistence in production, an in-process stub for
// tests that do not need Redis.
RaceNameDirectory RaceNameDirectoryConfig
// PendingRegistration configures the periodic worker that releases
// every pending_registration whose eligible_until has passed.
PendingRegistration PendingRegistrationConfig
// Telemetry configures the process-wide OpenTelemetry runtime.
Telemetry TelemetryConfig
}
// RaceNameDirectoryConfig configures which Race Name Directory adapter
// is wired into the runtime.
type RaceNameDirectoryConfig struct {
// Backend selects the Race Name Directory adapter. Accepted values
// are RaceNameDirectoryBackendRedis and RaceNameDirectoryBackendStub.
Backend string
}
// Validate reports whether cfg stores a supported Race Name Directory
// backend selector.
func (cfg RaceNameDirectoryConfig) Validate() error {
switch cfg.Backend {
case RaceNameDirectoryBackendRedis, RaceNameDirectoryBackendStub:
return nil
case "":
return fmt.Errorf("race name directory backend must not be empty")
default:
return fmt.Errorf("race name directory backend %q must be one of %q or %q",
cfg.Backend,
RaceNameDirectoryBackendRedis,
RaceNameDirectoryBackendStub)
}
}
// LoggingConfig configures the process-wide structured logger.
type LoggingConfig struct {
// Level stores the process log level accepted by log/slog.
Level string
}
// PublicHTTPConfig configures the public authenticated HTTP listener.
type PublicHTTPConfig 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
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
}
// Validate reports whether cfg stores a usable public HTTP listener
// configuration.
func (cfg PublicHTTPConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("public HTTP addr must not be empty")
case !isTCPAddr(cfg.Addr):
return fmt.Errorf("public HTTP addr %q must use host:port form", cfg.Addr)
case cfg.ReadHeaderTimeout <= 0:
return fmt.Errorf("public HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return fmt.Errorf("public HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return fmt.Errorf("public HTTP idle timeout must be positive")
default:
return nil
}
}
// 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
// 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.IdleTimeout <= 0:
return fmt.Errorf("internal HTTP idle timeout must be positive")
default:
return nil
}
}
// RedisConfig configures the shared Redis client and the Redis-owned
// Streams keys consumed by the runnable service skeleton.
type RedisConfig struct {
// Addr stores the Redis network address.
Addr string
// Username stores the optional Redis ACL username.
Username string
// Password stores the optional Redis ACL password.
Password string
// DB stores the Redis logical database index.
DB int
// TLSEnabled reports whether TLS must be used for Redis connections.
TLSEnabled bool
// OperationTimeout bounds one Redis round trip including the startup PING.
OperationTimeout time.Duration
// GMEventsStream stores the Redis Streams key for Game Master runtime
// events consumed by Lobby.
GMEventsStream string
// GMEventsReadBlockTimeout bounds the maximum blocking read window on
// GMEventsStream.
GMEventsReadBlockTimeout time.Duration
// RuntimeStartJobsStream stores the Redis Streams key Lobby writes start
// jobs to.
RuntimeStartJobsStream string
// RuntimeStopJobsStream stores the Redis Streams key Lobby writes stop
// jobs to. publishes stop jobs only from the orphan-container
// path inside the runtime job result worker.
RuntimeStopJobsStream string
// RuntimeJobResultsStream stores the Redis Streams key Lobby reads
// runtime job results from.
RuntimeJobResultsStream string
// RuntimeJobResultsReadBlockTimeout bounds the maximum blocking read window
// on RuntimeJobResultsStream.
RuntimeJobResultsReadBlockTimeout time.Duration
// NotificationIntentsStream stores the Redis Streams key Lobby writes
// notification intents to.
NotificationIntentsStream string
// UserLifecycleStream stores the Redis Streams key Lobby reads
// User Service lifecycle events from. The stream is consumed by the
// cascade worker.
UserLifecycleStream string
// UserLifecycleReadBlockTimeout bounds the maximum blocking read
// window on UserLifecycleStream.
UserLifecycleReadBlockTimeout time.Duration
}
// TLSConfig returns the conservative TLS configuration used by the Redis
// client when TLSEnabled is true.
func (cfg RedisConfig) TLSConfig() *tls.Config {
if !cfg.TLSEnabled {
return nil
}
return &tls.Config{MinVersion: tls.VersionTLS12}
}
// Validate reports whether cfg stores a usable Redis configuration.
func (cfg RedisConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("redis addr must not be empty")
case !isTCPAddr(cfg.Addr):
return fmt.Errorf("redis addr %q must use host:port form", cfg.Addr)
case cfg.DB < 0:
return fmt.Errorf("redis db must not be negative")
case cfg.OperationTimeout <= 0:
return fmt.Errorf("redis operation timeout must be positive")
case strings.TrimSpace(cfg.GMEventsStream) == "":
return fmt.Errorf("redis gm events stream must not be empty")
case cfg.GMEventsReadBlockTimeout <= 0:
return fmt.Errorf("redis gm events read block timeout must be positive")
case strings.TrimSpace(cfg.RuntimeStartJobsStream) == "":
return fmt.Errorf("redis runtime start jobs stream must not be empty")
case strings.TrimSpace(cfg.RuntimeStopJobsStream) == "":
return fmt.Errorf("redis runtime stop jobs stream must not be empty")
case strings.TrimSpace(cfg.RuntimeJobResultsStream) == "":
return fmt.Errorf("redis runtime job results stream must not be empty")
case cfg.RuntimeJobResultsReadBlockTimeout <= 0:
return fmt.Errorf("redis runtime job results read block timeout must be positive")
case strings.TrimSpace(cfg.NotificationIntentsStream) == "":
return fmt.Errorf("redis notification intents stream must not be empty")
case strings.TrimSpace(cfg.UserLifecycleStream) == "":
return fmt.Errorf("redis user lifecycle stream must not be empty")
case cfg.UserLifecycleReadBlockTimeout <= 0:
return fmt.Errorf("redis user lifecycle read block timeout must be positive")
default:
return nil
}
}
// UserServiceConfig configures the synchronous User Service eligibility
// client used by the application flow.
type UserServiceConfig struct {
// BaseURL stores the User Service root URL.
BaseURL string
// Timeout bounds one User Service request.
Timeout time.Duration
}
// Validate reports whether cfg stores a usable User Service client
// configuration.
func (cfg UserServiceConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return fmt.Errorf("user service base url must not be empty")
case !isHTTPURL(cfg.BaseURL):
return fmt.Errorf("user service base url %q must be an absolute http(s) URL", cfg.BaseURL)
case cfg.Timeout <= 0:
return fmt.Errorf("user service timeout must be positive")
default:
return nil
}
}
// GMConfig configures the synchronous Game Master registration client.
type GMConfig struct {
// BaseURL stores the Game Master root URL.
BaseURL string
// Timeout bounds one Game Master request.
Timeout time.Duration
}
// Validate reports whether cfg stores a usable Game Master client
// configuration.
func (cfg GMConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return fmt.Errorf("gm base url must not be empty")
case !isHTTPURL(cfg.BaseURL):
return fmt.Errorf("gm base url %q must be an absolute http(s) URL", cfg.BaseURL)
case cfg.Timeout <= 0:
return fmt.Errorf("gm timeout must be positive")
default:
return nil
}
}
// EnrollmentAutomationConfig configures the periodic enrollment automation
// worker.
type EnrollmentAutomationConfig struct {
// Interval stores the enrollment automation tick interval.
Interval time.Duration
}
// Validate reports whether cfg stores a usable enrollment automation
// configuration.
func (cfg EnrollmentAutomationConfig) Validate() error {
if cfg.Interval <= 0 {
return fmt.Errorf("enrollment automation interval must be positive")
}
return nil
}
// PendingRegistrationConfig configures the periodic worker that
// releases expired Race Name Directory pending_registration entries.
type PendingRegistrationConfig struct {
// Interval stores the pending-registration expiration tick interval.
Interval time.Duration
}
// Validate reports whether cfg stores a usable pending-registration
// expiration worker configuration.
func (cfg PendingRegistrationConfig) Validate() error {
if cfg.Interval <= 0 {
return fmt.Errorf("race name expiration interval must be positive")
}
return nil
}
// TelemetryConfig configures the Game Lobby Service 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 Lobby Service process
// configuration.
func DefaultConfig() Config {
return Config{
ShutdownTimeout: defaultShutdownTimeout,
Logging: LoggingConfig{
Level: defaultLogLevel,
},
PublicHTTP: PublicHTTPConfig{
Addr: defaultPublicHTTPAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
IdleTimeout: defaultIdleTimeout,
},
InternalHTTP: InternalHTTPConfig{
Addr: defaultInternalHTTPAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
IdleTimeout: defaultIdleTimeout,
},
Redis: RedisConfig{
DB: defaultRedisDB,
OperationTimeout: defaultRedisOperationTimeout,
GMEventsStream: defaultGMEventsStream,
GMEventsReadBlockTimeout: defaultGMEventsReadBlockTimeout,
RuntimeStartJobsStream: defaultRuntimeStartJobsStream,
RuntimeStopJobsStream: defaultRuntimeStopJobsStream,
RuntimeJobResultsStream: defaultRuntimeJobResultsStream,
RuntimeJobResultsReadBlockTimeout: defaultRuntimeJobResultsReadBlockTimeout,
NotificationIntentsStream: defaultNotificationIntentsStream,
UserLifecycleStream: defaultUserLifecycleStream,
UserLifecycleReadBlockTimeout: defaultUserLifecycleReadBlockTimeout,
},
UserService: UserServiceConfig{
Timeout: defaultUserServiceTimeout,
},
GM: GMConfig{
Timeout: defaultGMTimeout,
},
EnrollmentAutomation: EnrollmentAutomationConfig{
Interval: defaultEnrollmentAutomationInterval,
},
RaceNameDirectory: RaceNameDirectoryConfig{
Backend: RaceNameDirectoryBackendRedis,
},
PendingRegistration: PendingRegistrationConfig{
Interval: defaultRaceNameExpirationInterval,
},
Telemetry: TelemetryConfig{
ServiceName: defaultOTelServiceName,
TracesExporter: "none",
MetricsExporter: "none",
},
}
}
+333
View File
@@ -0,0 +1,333 @@
package config
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDefaultConfig(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
assert.Equal(t, 30*time.Second, cfg.ShutdownTimeout)
assert.Equal(t, "info", cfg.Logging.Level)
assert.Equal(t, ":8094", cfg.PublicHTTP.Addr)
assert.Equal(t, ":8095", cfg.InternalHTTP.Addr)
assert.Equal(t, 2*time.Second, cfg.Redis.OperationTimeout)
assert.Equal(t, "gm:lobby_events", cfg.Redis.GMEventsStream)
assert.Equal(t, "runtime:start_jobs", cfg.Redis.RuntimeStartJobsStream)
assert.Equal(t, "runtime:stop_jobs", cfg.Redis.RuntimeStopJobsStream)
assert.Equal(t, "runtime:job_results", cfg.Redis.RuntimeJobResultsStream)
assert.Equal(t, "notification:intents", cfg.Redis.NotificationIntentsStream)
assert.Equal(t, time.Second, cfg.UserService.Timeout)
assert.Equal(t, 5*time.Second, cfg.GM.Timeout)
assert.Equal(t, 30*time.Second, cfg.EnrollmentAutomation.Interval)
assert.Equal(t, time.Hour, cfg.PendingRegistration.Interval)
assert.Equal(t, "galaxy-lobby", cfg.Telemetry.ServiceName)
assert.Equal(t, "none", cfg.Telemetry.TracesExporter)
assert.Equal(t, "none", cfg.Telemetry.MetricsExporter)
}
func TestLoadFromEnvAppliesRequiredFields(t *testing.T) {
clearAllEnv(t)
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://user.internal:8090")
t.Setenv("LOBBY_GM_BASE_URL", "http://gm.internal:8091")
cfg, err := LoadFromEnv()
require.NoError(t, err)
assert.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
assert.Equal(t, "http://user.internal:8090", cfg.UserService.BaseURL)
assert.Equal(t, "http://gm.internal:8091", cfg.GM.BaseURL)
}
func TestLoadFromEnvMissingRequiredFields(t *testing.T) {
clearAllEnv(t)
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "redis addr must not be empty")
}
func TestLoadFromEnvOverrides(t *testing.T) {
clearAllEnv(t)
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://user.internal:8090")
t.Setenv("LOBBY_GM_BASE_URL", "http://gm.internal:8091")
t.Setenv("LOBBY_SHUTDOWN_TIMEOUT", "12s")
t.Setenv("LOBBY_LOG_LEVEL", "debug")
t.Setenv("LOBBY_PUBLIC_HTTP_ADDR", "127.0.0.1:9001")
t.Setenv("LOBBY_INTERNAL_HTTP_ADDR", "127.0.0.1:9002")
t.Setenv("LOBBY_REDIS_DB", "5")
t.Setenv("LOBBY_REDIS_TLS_ENABLED", "true")
t.Setenv("LOBBY_GM_EVENTS_STREAM", "alt:gm_events")
t.Setenv("LOBBY_NOTIFICATION_INTENTS_STREAM", "alt:intents")
t.Setenv("LOBBY_ENROLLMENT_AUTOMATION_INTERVAL", "45s")
t.Setenv("LOBBY_RACE_NAME_EXPIRATION_INTERVAL", "15m")
t.Setenv("OTEL_SERVICE_NAME", "galaxy-lobby-test")
cfg, err := LoadFromEnv()
require.NoError(t, err)
assert.Equal(t, 12*time.Second, cfg.ShutdownTimeout)
assert.Equal(t, "debug", cfg.Logging.Level)
assert.Equal(t, "127.0.0.1:9001", cfg.PublicHTTP.Addr)
assert.Equal(t, "127.0.0.1:9002", cfg.InternalHTTP.Addr)
assert.Equal(t, 5, cfg.Redis.DB)
assert.True(t, cfg.Redis.TLSEnabled)
assert.Equal(t, "alt:gm_events", cfg.Redis.GMEventsStream)
assert.Equal(t, "alt:intents", cfg.Redis.NotificationIntentsStream)
assert.Equal(t, 45*time.Second, cfg.EnrollmentAutomation.Interval)
assert.Equal(t, 15*time.Minute, cfg.PendingRegistration.Interval)
assert.Equal(t, "galaxy-lobby-test", cfg.Telemetry.ServiceName)
assert.NotNil(t, cfg.Redis.TLSConfig())
}
func TestLoadFromEnvInvalidDuration(t *testing.T) {
clearAllEnv(t)
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://user.internal:8090")
t.Setenv("LOBBY_GM_BASE_URL", "http://gm.internal:8091")
t.Setenv("LOBBY_SHUTDOWN_TIMEOUT", "not-a-duration")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "LOBBY_SHUTDOWN_TIMEOUT")
}
func TestPublicHTTPConfigValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(*PublicHTTPConfig)
wantErr string
}{
{name: "ok", mutate: func(*PublicHTTPConfig) {}},
{name: "empty addr", mutate: func(cfg *PublicHTTPConfig) { cfg.Addr = "" }, wantErr: "addr must not be empty"},
{name: "malformed addr", mutate: func(cfg *PublicHTTPConfig) { cfg.Addr = "not-an-addr" }, wantErr: "must use host:port"},
{name: "zero read header", mutate: func(cfg *PublicHTTPConfig) { cfg.ReadHeaderTimeout = 0 }, wantErr: "read header timeout"},
{name: "zero read", mutate: func(cfg *PublicHTTPConfig) { cfg.ReadTimeout = 0 }, wantErr: "read timeout"},
{name: "zero idle", mutate: func(cfg *PublicHTTPConfig) { cfg.IdleTimeout = 0 }, wantErr: "idle timeout"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := DefaultConfig().PublicHTTP
tt.mutate(&cfg)
err := cfg.Validate()
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestInternalHTTPConfigValidate(t *testing.T) {
t.Parallel()
cfg := DefaultConfig().InternalHTTP
require.NoError(t, cfg.Validate())
cfg.Addr = "bogus"
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "must use host:port")
}
func TestRedisConfigValidate(t *testing.T) {
t.Parallel()
base := DefaultConfig().Redis
base.Addr = "127.0.0.1:6379"
require.NoError(t, base.Validate())
tests := []struct {
name string
mutate func(*RedisConfig)
wantErr string
}{
{name: "empty addr", mutate: func(cfg *RedisConfig) { cfg.Addr = "" }, wantErr: "addr must not be empty"},
{name: "bad addr", mutate: func(cfg *RedisConfig) { cfg.Addr = "weird" }, wantErr: "must use host:port"},
{name: "negative db", mutate: func(cfg *RedisConfig) { cfg.DB = -1 }, wantErr: "must not be negative"},
{name: "zero op timeout", mutate: func(cfg *RedisConfig) { cfg.OperationTimeout = 0 }, wantErr: "operation timeout"},
{name: "empty gm stream", mutate: func(cfg *RedisConfig) { cfg.GMEventsStream = "" }, wantErr: "gm events stream"},
{name: "zero gm block", mutate: func(cfg *RedisConfig) { cfg.GMEventsReadBlockTimeout = 0 }, wantErr: "gm events read block timeout"},
{name: "empty start jobs", mutate: func(cfg *RedisConfig) { cfg.RuntimeStartJobsStream = "" }, wantErr: "runtime start jobs"},
{name: "empty stop jobs", mutate: func(cfg *RedisConfig) { cfg.RuntimeStopJobsStream = "" }, wantErr: "runtime stop jobs"},
{name: "empty job results", mutate: func(cfg *RedisConfig) { cfg.RuntimeJobResultsStream = "" }, wantErr: "runtime job results"},
{name: "zero job block", mutate: func(cfg *RedisConfig) { cfg.RuntimeJobResultsReadBlockTimeout = 0 }, wantErr: "runtime job results read block"},
{name: "empty intents", mutate: func(cfg *RedisConfig) { cfg.NotificationIntentsStream = "" }, wantErr: "notification intents"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := base
tt.mutate(&cfg)
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestUserServiceConfigValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg UserServiceConfig
wantErr string
}{
{name: "ok", cfg: UserServiceConfig{BaseURL: "http://x:1", Timeout: time.Second}},
{name: "empty base url", cfg: UserServiceConfig{Timeout: time.Second}, wantErr: "base url must not be empty"},
{name: "ftp scheme", cfg: UserServiceConfig{BaseURL: "ftp://x", Timeout: time.Second}, wantErr: "absolute http"},
{name: "zero timeout", cfg: UserServiceConfig{BaseURL: "http://x:1"}, wantErr: "timeout must be positive"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.cfg.Validate()
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestGMConfigValidate(t *testing.T) {
t.Parallel()
require.NoError(t, GMConfig{BaseURL: "https://gm:443", Timeout: time.Second}.Validate())
require.ErrorContains(t, GMConfig{Timeout: time.Second}.Validate(), "base url must not be empty")
require.ErrorContains(t, GMConfig{BaseURL: "http://gm", Timeout: 0}.Validate(), "timeout must be positive")
}
func TestEnrollmentAutomationConfigValidate(t *testing.T) {
t.Parallel()
require.NoError(t, EnrollmentAutomationConfig{Interval: time.Second}.Validate())
require.ErrorContains(t, EnrollmentAutomationConfig{}.Validate(), "interval must be positive")
}
func TestPendingRegistrationConfigValidate(t *testing.T) {
t.Parallel()
require.NoError(t, PendingRegistrationConfig{Interval: time.Hour}.Validate())
require.ErrorContains(t, PendingRegistrationConfig{}.Validate(), "race name expiration interval must be positive")
}
func TestTelemetryConfigValidate(t *testing.T) {
t.Parallel()
require.NoError(t, TelemetryConfig{TracesExporter: "none", MetricsExporter: "none"}.Validate())
require.ErrorContains(t, TelemetryConfig{TracesExporter: "weird", MetricsExporter: "none"}.Validate(), "unsupported traces exporter")
require.ErrorContains(t, TelemetryConfig{TracesExporter: "none", MetricsExporter: "weird"}.Validate(), "unsupported metrics exporter")
require.ErrorContains(t, TelemetryConfig{TracesExporter: "none", MetricsExporter: "none", TracesProtocol: "ws"}.Validate(), "OTLP traces protocol")
require.ErrorContains(t, TelemetryConfig{TracesExporter: "none", MetricsExporter: "none", MetricsProtocol: "ws"}.Validate(), "OTLP metrics protocol")
}
func TestConfigValidateLogLevel(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Redis.Addr = "127.0.0.1:6379"
cfg.UserService.BaseURL = "http://u:1"
cfg.GM.BaseURL = "http://gm:1"
require.NoError(t, cfg.Validate())
cfg.Logging.Level = "bogus"
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "slog level")
}
func TestLoadFromEnvBoolParseError(t *testing.T) {
clearAllEnv(t)
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://u:1")
t.Setenv("LOBBY_GM_BASE_URL", "http://gm:1")
t.Setenv("LOBBY_REDIS_TLS_ENABLED", "not-bool")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "LOBBY_REDIS_TLS_ENABLED")
}
// clearAllEnv unsets every environment variable the config package reads so
// tests can configure their expected values explicitly.
func clearAllEnv(t *testing.T) {
t.Helper()
envVars := []string{
shutdownTimeoutEnvVar,
logLevelEnvVar,
publicHTTPAddrEnvVar,
publicHTTPReadHeaderTimeoutEnvVar,
publicHTTPReadTimeoutEnvVar,
publicHTTPIdleTimeoutEnvVar,
internalHTTPAddrEnvVar,
internalHTTPReadHeaderTimeoutEnvVar,
internalHTTPReadTimeoutEnvVar,
internalHTTPIdleTimeoutEnvVar,
redisAddrEnvVar,
redisUsernameEnvVar,
redisPasswordEnvVar,
redisDBEnvVar,
redisTLSEnabledEnvVar,
redisOperationTimeoutEnvVar,
gmEventsStreamEnvVar,
gmEventsReadBlockTimeoutEnvVar,
runtimeStartJobsStreamEnvVar,
runtimeJobResultsStreamEnvVar,
runtimeJobResultsReadBlockTimeoutEnv,
notificationIntentsStreamEnvVar,
userServiceBaseURLEnvVar,
userServiceTimeoutEnvVar,
gmBaseURLEnvVar,
gmTimeoutEnvVar,
enrollmentAutomationIntervalEnvVar,
raceNameDirectoryBackendEnvVar,
raceNameExpirationIntervalEnvVar,
otelServiceNameEnvVar,
otelTracesExporterEnvVar,
otelMetricsExporterEnvVar,
otelExporterOTLPProtocolEnvVar,
otelExporterOTLPTracesProtocolEnvVar,
otelExporterOTLPMetricsProtocolEnvVar,
otelStdoutTracesEnabledEnvVar,
otelStdoutMetricsEnabledEnvVar,
}
for _, name := range envVars {
// t.Setenv registers a Cleanup that restores the pre-test value.
// Unsetenv after enrolling the cleanup leaves the variable unset for
// the duration of the test while still restoring prior state on exit.
t.Setenv(name, "")
require.NoError(t, os.Unsetenv(name))
}
}
+213
View File
@@ -0,0 +1,213 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
// 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)
cfg.PublicHTTP.Addr = stringEnv(publicHTTPAddrEnvVar, cfg.PublicHTTP.Addr)
cfg.PublicHTTP.ReadHeaderTimeout, err = durationEnv(publicHTTPReadHeaderTimeoutEnvVar, cfg.PublicHTTP.ReadHeaderTimeout)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.ReadTimeout, err = durationEnv(publicHTTPReadTimeoutEnvVar, cfg.PublicHTTP.ReadTimeout)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.IdleTimeout, err = durationEnv(publicHTTPIdleTimeoutEnvVar, cfg.PublicHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.Addr = stringEnv(internalHTTPAddrEnvVar, cfg.InternalHTTP.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.IdleTimeout, err = durationEnv(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
cfg.Redis.Addr = stringEnv(redisAddrEnvVar, cfg.Redis.Addr)
cfg.Redis.Username = stringEnv(redisUsernameEnvVar, cfg.Redis.Username)
cfg.Redis.Password = stringEnv(redisPasswordEnvVar, cfg.Redis.Password)
cfg.Redis.DB, err = intEnv(redisDBEnvVar, cfg.Redis.DB)
if err != nil {
return Config{}, err
}
cfg.Redis.TLSEnabled, err = boolEnv(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
if err != nil {
return Config{}, err
}
cfg.Redis.OperationTimeout, err = durationEnv(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
if err != nil {
return Config{}, err
}
cfg.Redis.GMEventsStream = stringEnv(gmEventsStreamEnvVar, cfg.Redis.GMEventsStream)
cfg.Redis.GMEventsReadBlockTimeout, err = durationEnv(gmEventsReadBlockTimeoutEnvVar, cfg.Redis.GMEventsReadBlockTimeout)
if err != nil {
return Config{}, err
}
cfg.Redis.RuntimeStartJobsStream = stringEnv(runtimeStartJobsStreamEnvVar, cfg.Redis.RuntimeStartJobsStream)
cfg.Redis.RuntimeStopJobsStream = stringEnv(runtimeStopJobsStreamEnvVar, cfg.Redis.RuntimeStopJobsStream)
cfg.Redis.RuntimeJobResultsStream = stringEnv(runtimeJobResultsStreamEnvVar, cfg.Redis.RuntimeJobResultsStream)
cfg.Redis.RuntimeJobResultsReadBlockTimeout, err = durationEnv(runtimeJobResultsReadBlockTimeoutEnv, cfg.Redis.RuntimeJobResultsReadBlockTimeout)
if err != nil {
return Config{}, err
}
cfg.Redis.NotificationIntentsStream = stringEnv(notificationIntentsStreamEnvVar, cfg.Redis.NotificationIntentsStream)
cfg.Redis.UserLifecycleStream = stringEnv(userLifecycleStreamEnvVar, cfg.Redis.UserLifecycleStream)
cfg.Redis.UserLifecycleReadBlockTimeout, err = durationEnv(userLifecycleReadBlockTimeoutEnvVar, cfg.Redis.UserLifecycleReadBlockTimeout)
if err != nil {
return Config{}, err
}
cfg.UserService.BaseURL = stringEnv(userServiceBaseURLEnvVar, cfg.UserService.BaseURL)
cfg.UserService.Timeout, err = durationEnv(userServiceTimeoutEnvVar, cfg.UserService.Timeout)
if err != nil {
return Config{}, err
}
cfg.GM.BaseURL = stringEnv(gmBaseURLEnvVar, cfg.GM.BaseURL)
cfg.GM.Timeout, err = durationEnv(gmTimeoutEnvVar, cfg.GM.Timeout)
if err != nil {
return Config{}, err
}
cfg.EnrollmentAutomation.Interval, err = durationEnv(enrollmentAutomationIntervalEnvVar, cfg.EnrollmentAutomation.Interval)
if err != nil {
return Config{}, err
}
cfg.RaceNameDirectory.Backend = stringEnv(raceNameDirectoryBackendEnvVar, cfg.RaceNameDirectory.Backend)
cfg.PendingRegistration.Interval, err = durationEnv(raceNameExpirationIntervalEnvVar, cfg.PendingRegistration.Interval)
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)
}
+88
View File
@@ -0,0 +1,88 @@
package config
import (
"fmt"
"log/slog"
"net"
"net/url"
"strings"
)
// Validate reports whether cfg stores a usable Game Lobby Service 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.PublicHTTP.Validate(); err != nil {
return err
}
if err := cfg.InternalHTTP.Validate(); err != nil {
return err
}
if err := cfg.Redis.Validate(); err != nil {
return err
}
if err := cfg.UserService.Validate(); err != nil {
return err
}
if err := cfg.GM.Validate(); err != nil {
return err
}
if err := cfg.EnrollmentAutomation.Validate(); err != nil {
return err
}
if err := cfg.RaceNameDirectory.Validate(); err != nil {
return fmt.Errorf("%s: %w", raceNameDirectoryBackendEnvVar, err)
}
if err := cfg.PendingRegistration.Validate(); err != nil {
return fmt.Errorf("%s: %w", raceNameExpirationIntervalEnvVar, 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 != ""
}