feat: game lobby service
This commit is contained in:
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 != ""
|
||||
}
|
||||
Reference in New Issue
Block a user