feat: backend service
This commit is contained in:
@@ -0,0 +1,874 @@
|
||||
// Package config loads process-level backend configuration from environment
|
||||
// variables.
|
||||
//
|
||||
// The variable set is the canonical inventory documented in
|
||||
// `backend/README.md` §4. LoadFromEnv populates a Config from environment,
|
||||
// applies the documented defaults, then runs Validate. Validate fails fast on
|
||||
// any required-but-missing variable so the process never starts in a partially
|
||||
// configured state.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
netmail "net/mail"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Environment variable names. The exhaustive set follows README §4.
|
||||
const (
|
||||
envShutdownTimeout = "BACKEND_SHUTDOWN_TIMEOUT"
|
||||
|
||||
envLoggingLevel = "BACKEND_LOGGING_LEVEL"
|
||||
|
||||
envHTTPListenAddr = "BACKEND_HTTP_LISTEN_ADDR"
|
||||
envHTTPReadTimeout = "BACKEND_HTTP_READ_TIMEOUT"
|
||||
envHTTPWriteTimeout = "BACKEND_HTTP_WRITE_TIMEOUT"
|
||||
envHTTPShutdownTimeout = "BACKEND_HTTP_SHUTDOWN_TIMEOUT"
|
||||
|
||||
envGRPCPushListenAddr = "BACKEND_GRPC_PUSH_LISTEN_ADDR"
|
||||
envGRPCPushShutdownTimeout = "BACKEND_GRPC_PUSH_SHUTDOWN_TIMEOUT"
|
||||
|
||||
envPostgresDSN = "BACKEND_POSTGRES_DSN"
|
||||
envPostgresMaxConns = "BACKEND_POSTGRES_MAX_CONNS"
|
||||
envPostgresMinConns = "BACKEND_POSTGRES_MIN_CONNS"
|
||||
envPostgresOperationTimeout = "BACKEND_POSTGRES_OPERATION_TIMEOUT"
|
||||
|
||||
envSMTPHost = "BACKEND_SMTP_HOST"
|
||||
envSMTPPort = "BACKEND_SMTP_PORT"
|
||||
envSMTPUsername = "BACKEND_SMTP_USERNAME"
|
||||
envSMTPPassword = "BACKEND_SMTP_PASSWORD"
|
||||
envSMTPFrom = "BACKEND_SMTP_FROM"
|
||||
envSMTPTLSMode = "BACKEND_SMTP_TLS_MODE"
|
||||
|
||||
envMailWorkerInterval = "BACKEND_MAIL_WORKER_INTERVAL"
|
||||
envMailMaxAttempts = "BACKEND_MAIL_MAX_ATTEMPTS"
|
||||
|
||||
envDockerHost = "BACKEND_DOCKER_HOST"
|
||||
envDockerNetwork = "BACKEND_DOCKER_NETWORK"
|
||||
|
||||
envGameStateRoot = "BACKEND_GAME_STATE_ROOT"
|
||||
|
||||
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
|
||||
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
|
||||
|
||||
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
|
||||
|
||||
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
|
||||
envOTelMetricsExporter = "BACKEND_OTEL_METRICS_EXPORTER"
|
||||
envOTelProtocol = "BACKEND_OTEL_PROTOCOL"
|
||||
envOTelEndpoint = "BACKEND_OTEL_ENDPOINT"
|
||||
envOTelPrometheusListenAddr = "BACKEND_OTEL_PROMETHEUS_LISTEN_ADDR"
|
||||
envServiceName = "BACKEND_SERVICE_NAME"
|
||||
|
||||
envFreshnessWindow = "BACKEND_FRESHNESS_WINDOW"
|
||||
|
||||
envAuthChallengeTTL = "BACKEND_AUTH_CHALLENGE_TTL"
|
||||
envAuthChallengeMaxAttempts = "BACKEND_AUTH_CHALLENGE_MAX_ATTEMPTS"
|
||||
envAuthChallengeThrottleWindow = "BACKEND_AUTH_CHALLENGE_THROTTLE_WINDOW"
|
||||
envAuthChallengeThrottleMax = "BACKEND_AUTH_CHALLENGE_THROTTLE_MAX"
|
||||
envAuthUserNameMaxRetries = "BACKEND_AUTH_USERNAME_MAX_RETRIES"
|
||||
|
||||
envLobbySweeperInterval = "BACKEND_LOBBY_SWEEPER_INTERVAL"
|
||||
envLobbyPendingRegistrationTTL = "BACKEND_LOBBY_PENDING_REGISTRATION_TTL"
|
||||
envLobbyInviteDefaultTTL = "BACKEND_LOBBY_INVITE_DEFAULT_TTL"
|
||||
|
||||
envEngineCallTimeout = "BACKEND_ENGINE_CALL_TIMEOUT"
|
||||
envEngineProbeTimeout = "BACKEND_ENGINE_PROBE_TIMEOUT"
|
||||
|
||||
envRuntimeWorkerPoolSize = "BACKEND_RUNTIME_WORKER_POOL_SIZE"
|
||||
envRuntimeJobQueueSize = "BACKEND_RUNTIME_JOB_QUEUE_SIZE"
|
||||
envRuntimeReconcileInterval = "BACKEND_RUNTIME_RECONCILE_INTERVAL"
|
||||
envRuntimeImagePullPolicy = "BACKEND_RUNTIME_IMAGE_PULL_POLICY"
|
||||
envRuntimeContainerLogDriver = "BACKEND_RUNTIME_CONTAINER_LOG_DRIVER"
|
||||
envRuntimeContainerLogOpts = "BACKEND_RUNTIME_CONTAINER_LOG_OPTS"
|
||||
envRuntimeContainerCPUQuota = "BACKEND_RUNTIME_CONTAINER_CPU_QUOTA"
|
||||
envRuntimeContainerMemory = "BACKEND_RUNTIME_CONTAINER_MEMORY"
|
||||
envRuntimeContainerPIDsLimit = "BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT"
|
||||
envRuntimeContainerStateMount = "BACKEND_RUNTIME_CONTAINER_STATE_MOUNT"
|
||||
envRuntimeStopGracePeriod = "BACKEND_RUNTIME_STOP_GRACE_PERIOD"
|
||||
|
||||
envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL"
|
||||
envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
|
||||
envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS"
|
||||
)
|
||||
|
||||
// Default values applied when an environment variable is absent.
|
||||
const (
|
||||
defaultShutdownTimeout = 30 * time.Second
|
||||
|
||||
defaultLoggingLevel = "info"
|
||||
|
||||
defaultHTTPListenAddr = ":8080"
|
||||
defaultHTTPReadTimeout = 30 * time.Second
|
||||
defaultHTTPWriteTimeout = 30 * time.Second
|
||||
defaultHTTPShutdownTimeout = 15 * time.Second
|
||||
|
||||
defaultGRPCPushListenAddr = ":8081"
|
||||
defaultGRPCPushShutdownTimeout = 10 * time.Second
|
||||
|
||||
defaultPostgresMaxConns = 25
|
||||
defaultPostgresMinConns = 2
|
||||
defaultPostgresOperationTimeout = 5 * time.Second
|
||||
|
||||
defaultSMTPPort = 587
|
||||
defaultSMTPTLSMode = "starttls"
|
||||
|
||||
defaultMailWorkerInterval = 2 * time.Second
|
||||
defaultMailMaxAttempts = 8
|
||||
|
||||
defaultDockerHost = "unix:///var/run/docker.sock"
|
||||
|
||||
defaultOTelTracesExporter = "otlp"
|
||||
defaultOTelMetricsExporter = "otlp"
|
||||
defaultOTelProtocol = "grpc"
|
||||
defaultOTelPrometheusListenAddr = ":9100"
|
||||
defaultServiceName = "galaxy-backend"
|
||||
|
||||
defaultFreshnessWindow = 5 * time.Minute
|
||||
|
||||
defaultAuthChallengeTTL = 10 * time.Minute
|
||||
defaultAuthChallengeMaxAttempts = 5
|
||||
defaultAuthChallengeThrottleWindow = 60 * time.Second
|
||||
defaultAuthChallengeThrottleMax = 3
|
||||
defaultAuthUserNameMaxRetries = 10
|
||||
|
||||
defaultLobbySweeperInterval = 60 * time.Second
|
||||
defaultLobbyPendingRegistrationTTL = 30 * 24 * time.Hour
|
||||
defaultLobbyInviteDefaultTTL = 7 * 24 * time.Hour
|
||||
|
||||
defaultEngineCallTimeout = 60 * time.Second
|
||||
defaultEngineProbeTimeout = 5 * time.Second
|
||||
|
||||
defaultRuntimeWorkerPoolSize = 4
|
||||
defaultRuntimeJobQueueSize = 64
|
||||
defaultRuntimeReconcileInterval = 60 * time.Second
|
||||
defaultRuntimeImagePullPolicy = "if_missing"
|
||||
defaultRuntimeContainerLogDriver = "json-file"
|
||||
defaultRuntimeContainerCPUQuota = 2.0
|
||||
defaultRuntimeContainerMemory = "512m"
|
||||
defaultRuntimeContainerPIDsLimit = 256
|
||||
defaultRuntimeContainerStateMount = "/var/lib/galaxy-game"
|
||||
defaultRuntimeStopGracePeriod = 10 * time.Second
|
||||
|
||||
defaultNotificationWorkerInterval = 5 * time.Second
|
||||
defaultNotificationMaxAttempts = 8
|
||||
)
|
||||
|
||||
// Allowed values for the closed-set string options.
|
||||
var (
|
||||
allowedTracesExporters = []string{"none", "otlp", "stdout"}
|
||||
allowedMetricsExporters = []string{"none", "otlp", "stdout", "prometheus"}
|
||||
allowedOTelProtocols = []string{"grpc", "http/protobuf"}
|
||||
allowedSMTPTLSModes = []string{"none", "starttls", "tls"}
|
||||
allowedPullPolicies = []string{"if_missing", "always", "never"}
|
||||
)
|
||||
|
||||
// Config is the top-level backend configuration assembled from environment
|
||||
// variables. The zero value is not usable; callers must obtain a Config via
|
||||
// DefaultConfig or LoadFromEnv.
|
||||
type Config struct {
|
||||
// ShutdownTimeout bounds each component's Shutdown call coordinated by
|
||||
// the process App lifecycle. Per-listener timeouts (HTTP, gRPC) bound the
|
||||
// inner server stop and may be smaller than ShutdownTimeout.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
Logging LoggingConfig
|
||||
HTTP HTTPConfig
|
||||
GRPCPush GRPCPushConfig
|
||||
Postgres PostgresConfig
|
||||
SMTP SMTPConfig
|
||||
Mail MailConfig
|
||||
Docker DockerConfig
|
||||
Game GameConfig
|
||||
Admin AdminBootstrapConfig
|
||||
GeoIP GeoIPConfig
|
||||
Telemetry TelemetryConfig
|
||||
Auth AuthConfig
|
||||
Lobby LobbyConfig
|
||||
Engine EngineConfig
|
||||
Runtime RuntimeConfig
|
||||
Notification NotificationConfig
|
||||
|
||||
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
||||
// push server to bound the cursor TTL.
|
||||
FreshnessWindow time.Duration
|
||||
}
|
||||
|
||||
// LoggingConfig stores the parameters used by the structured logger.
|
||||
type LoggingConfig struct {
|
||||
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
||||
Level string
|
||||
}
|
||||
|
||||
// HTTPConfig configures the public HTTP listener.
|
||||
type HTTPConfig struct {
|
||||
Addr string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
// GRPCPushConfig configures the gRPC push listener.
|
||||
type GRPCPushConfig struct {
|
||||
Addr string
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
// PostgresConfig configures the primary Postgres pool.
|
||||
//
|
||||
// MinConns mirrors README §4 BACKEND_POSTGRES_MIN_CONNS and is interpreted as
|
||||
// the maximum number of idle connections kept warm in the pool — database/sql
|
||||
// has no real minimum-pool concept, so this is the closest equivalent. The
|
||||
// mapping is documented in `backend/README.md` and `backend/docs/`.
|
||||
type PostgresConfig struct {
|
||||
DSN string
|
||||
MaxConns int
|
||||
MinConns int
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// SMTPConfig configures the SMTP relay used by the mail outbox.
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
TLSMode string
|
||||
}
|
||||
|
||||
// MailConfig configures the mail outbox worker.
|
||||
type MailConfig struct {
|
||||
WorkerInterval time.Duration
|
||||
MaxAttempts int
|
||||
}
|
||||
|
||||
// DockerConfig configures the Docker client used by the runtime module.
|
||||
type DockerConfig struct {
|
||||
Host string
|
||||
Network string
|
||||
}
|
||||
|
||||
// GameConfig configures the runtime engine container layout.
|
||||
type GameConfig struct {
|
||||
StateRoot string
|
||||
}
|
||||
|
||||
// AdminBootstrapConfig configures the optional first-admin bootstrap.
|
||||
// At startup the admin module inserts a row in `backend.admin_accounts`
|
||||
// when User is non-empty and no row with that username exists yet; the
|
||||
// insert is idempotent across restarts.
|
||||
type AdminBootstrapConfig struct {
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
|
||||
type GeoIPConfig struct {
|
||||
DBPath string
|
||||
}
|
||||
|
||||
// TelemetryConfig configures the OpenTelemetry runtime.
|
||||
type TelemetryConfig struct {
|
||||
ServiceName string
|
||||
TracesExporter string
|
||||
MetricsExporter string
|
||||
Protocol string
|
||||
Endpoint string
|
||||
PrometheusListenAddr string
|
||||
}
|
||||
|
||||
// AuthConfig configures the email-code authentication flow implemented in
|
||||
// `backend/internal/auth`. ChallengeTTL bounds the lifetime of an issued
|
||||
// `auth_challenges` row, ChallengeMaxAttempts caps confirm-email-code
|
||||
// attempts per challenge, ChallengeThrottle bounds new-challenge issuance
|
||||
// per email, and UserNameMaxRetries caps the retry budget for synthesising
|
||||
// a unique `accounts.user_name` at registration.
|
||||
type AuthConfig struct {
|
||||
ChallengeTTL time.Duration
|
||||
ChallengeMaxAttempts int
|
||||
ChallengeThrottle AuthChallengeThrottleConfig
|
||||
UserNameMaxRetries int
|
||||
}
|
||||
|
||||
// AuthChallengeThrottleConfig bounds how many un-consumed, non-expired
|
||||
// challenges a single email may hold inside a sliding window before the
|
||||
// auth service starts reusing the most recent existing challenge instead
|
||||
// of issuing a new one.
|
||||
type AuthChallengeThrottleConfig struct {
|
||||
Window time.Duration
|
||||
Max int
|
||||
}
|
||||
|
||||
// EngineConfig configures the per-call timeouts of `engineclient` against
|
||||
// running game-engine containers. CallTimeout bounds turn-generation-class
|
||||
// operations (init, turn, banish, command, order); ProbeTimeout bounds
|
||||
// inspect-style reads (status, report, healthz).
|
||||
type EngineConfig struct {
|
||||
CallTimeout time.Duration
|
||||
ProbeTimeout time.Duration
|
||||
}
|
||||
|
||||
// RuntimeConfig configures the runtime module: worker pool, reconciliation
|
||||
// cadence, image-pull policy, and per-container resource defaults applied
|
||||
// at engine container creation time.
|
||||
type RuntimeConfig struct {
|
||||
// WorkerPoolSize bounds the number of concurrent long-running runtime
|
||||
// jobs (image pull, container start, restart, patch).
|
||||
WorkerPoolSize int
|
||||
|
||||
// JobQueueSize is the buffered job channel capacity. Once full, new
|
||||
// runtime requests block briefly until a worker frees a slot.
|
||||
JobQueueSize int
|
||||
|
||||
// ReconcileInterval bounds how often the runtime reconciler reads the
|
||||
// Docker daemon's labelled containers and reconciles them against
|
||||
// `runtime_records`.
|
||||
ReconcileInterval time.Duration
|
||||
|
||||
// ImagePullPolicy selects the dockerclient pull behaviour:
|
||||
// `if_missing`, `always`, or `never`.
|
||||
ImagePullPolicy string
|
||||
|
||||
// ContainerLogDriver is the Docker log driver applied to every engine
|
||||
// container created by the runtime (e.g., `json-file`).
|
||||
ContainerLogDriver string
|
||||
|
||||
// ContainerLogOpts is the comma-separated `key=value` list passed to
|
||||
// the log driver. May be empty.
|
||||
ContainerLogOpts string
|
||||
|
||||
// ContainerCPUQuota is the `--cpus` value applied as a resource limit
|
||||
// on each engine container.
|
||||
ContainerCPUQuota float64
|
||||
|
||||
// ContainerMemory is the `--memory` value (e.g. `512m`).
|
||||
ContainerMemory string
|
||||
|
||||
// ContainerPIDsLimit is the `--pids-limit` value.
|
||||
ContainerPIDsLimit int
|
||||
|
||||
// ContainerStateMount is the absolute in-container path the per-game
|
||||
// state directory is bind-mounted at.
|
||||
ContainerStateMount string
|
||||
|
||||
// StopGracePeriod is the docker stop SIGTERM-to-SIGKILL grace period
|
||||
// applied during stop / cancel / restart / patch.
|
||||
StopGracePeriod time.Duration
|
||||
}
|
||||
|
||||
// NotificationConfig configures the notification fan-out module
|
||||
// implemented in `backend/internal/notification`. AdminEmail receives
|
||||
// admin-channel kinds (the `runtime.*` set in `backend/README.md` §10);
|
||||
// when empty, admin-email routes are recorded as `skipped`. WorkerInterval
|
||||
// bounds how often the route worker scans for due rows; MaxAttempts caps
|
||||
// route delivery retries before dead-lettering.
|
||||
type NotificationConfig struct {
|
||||
AdminEmail string
|
||||
WorkerInterval time.Duration
|
||||
MaxAttempts int
|
||||
}
|
||||
|
||||
// LobbyConfig configures the lobby module: the periodic sweeper interval,
|
||||
// the lifetime of `pending_registration` Race Name Directory entries, and
|
||||
// the default expiry applied to invites that omit `expires_at`.
|
||||
type LobbyConfig struct {
|
||||
// SweeperInterval bounds how often the lobby sweeper goroutine wakes
|
||||
// up to release expired pending_registration rows and to auto-close
|
||||
// enrollment-expired games.
|
||||
SweeperInterval time.Duration
|
||||
|
||||
// PendingRegistrationTTL bounds how long a `pending_registration`
|
||||
// Race Name Directory row stays available for promotion via
|
||||
// `lobby.race_name.register` before the sweeper releases it.
|
||||
PendingRegistrationTTL time.Duration
|
||||
|
||||
// InviteDefaultTTL is the expiry applied to invites whose request body
|
||||
// omits an explicit `expires_at`.
|
||||
InviteDefaultTTL time.Duration
|
||||
}
|
||||
|
||||
// DefaultConfig returns a Config pre-filled with the defaults documented in
|
||||
// README §4. The required string fields (Postgres.DSN, SMTP.Host, SMTP.From,
|
||||
// Docker.Network, Game.StateRoot, GeoIP.DBPath) remain zero-valued and must be
|
||||
// supplied by callers (or by LoadFromEnv).
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
ShutdownTimeout: defaultShutdownTimeout,
|
||||
Logging: LoggingConfig{
|
||||
Level: defaultLoggingLevel,
|
||||
},
|
||||
HTTP: HTTPConfig{
|
||||
Addr: defaultHTTPListenAddr,
|
||||
ReadTimeout: defaultHTTPReadTimeout,
|
||||
WriteTimeout: defaultHTTPWriteTimeout,
|
||||
ShutdownTimeout: defaultHTTPShutdownTimeout,
|
||||
},
|
||||
GRPCPush: GRPCPushConfig{
|
||||
Addr: defaultGRPCPushListenAddr,
|
||||
ShutdownTimeout: defaultGRPCPushShutdownTimeout,
|
||||
},
|
||||
Postgres: PostgresConfig{
|
||||
MaxConns: defaultPostgresMaxConns,
|
||||
MinConns: defaultPostgresMinConns,
|
||||
OperationTimeout: defaultPostgresOperationTimeout,
|
||||
},
|
||||
SMTP: SMTPConfig{
|
||||
Port: defaultSMTPPort,
|
||||
TLSMode: defaultSMTPTLSMode,
|
||||
},
|
||||
Mail: MailConfig{
|
||||
WorkerInterval: defaultMailWorkerInterval,
|
||||
MaxAttempts: defaultMailMaxAttempts,
|
||||
},
|
||||
Docker: DockerConfig{
|
||||
Host: defaultDockerHost,
|
||||
},
|
||||
Telemetry: TelemetryConfig{
|
||||
ServiceName: defaultServiceName,
|
||||
TracesExporter: defaultOTelTracesExporter,
|
||||
MetricsExporter: defaultOTelMetricsExporter,
|
||||
Protocol: defaultOTelProtocol,
|
||||
PrometheusListenAddr: defaultOTelPrometheusListenAddr,
|
||||
},
|
||||
FreshnessWindow: defaultFreshnessWindow,
|
||||
Auth: AuthConfig{
|
||||
ChallengeTTL: defaultAuthChallengeTTL,
|
||||
ChallengeMaxAttempts: defaultAuthChallengeMaxAttempts,
|
||||
ChallengeThrottle: AuthChallengeThrottleConfig{
|
||||
Window: defaultAuthChallengeThrottleWindow,
|
||||
Max: defaultAuthChallengeThrottleMax,
|
||||
},
|
||||
UserNameMaxRetries: defaultAuthUserNameMaxRetries,
|
||||
},
|
||||
Lobby: LobbyConfig{
|
||||
SweeperInterval: defaultLobbySweeperInterval,
|
||||
PendingRegistrationTTL: defaultLobbyPendingRegistrationTTL,
|
||||
InviteDefaultTTL: defaultLobbyInviteDefaultTTL,
|
||||
},
|
||||
Engine: EngineConfig{
|
||||
CallTimeout: defaultEngineCallTimeout,
|
||||
ProbeTimeout: defaultEngineProbeTimeout,
|
||||
},
|
||||
Notification: NotificationConfig{
|
||||
WorkerInterval: defaultNotificationWorkerInterval,
|
||||
MaxAttempts: defaultNotificationMaxAttempts,
|
||||
},
|
||||
Runtime: RuntimeConfig{
|
||||
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
||||
JobQueueSize: defaultRuntimeJobQueueSize,
|
||||
ReconcileInterval: defaultRuntimeReconcileInterval,
|
||||
ImagePullPolicy: defaultRuntimeImagePullPolicy,
|
||||
ContainerLogDriver: defaultRuntimeContainerLogDriver,
|
||||
ContainerCPUQuota: defaultRuntimeContainerCPUQuota,
|
||||
ContainerMemory: defaultRuntimeContainerMemory,
|
||||
ContainerPIDsLimit: defaultRuntimeContainerPIDsLimit,
|
||||
ContainerStateMount: defaultRuntimeContainerStateMount,
|
||||
StopGracePeriod: defaultRuntimeStopGracePeriod,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFromEnv loads Config from environment variables, applying the
|
||||
// DefaultConfig values for any variable that is not set, and validates the
|
||||
// result. The returned Config is safe to use without further modification.
|
||||
func LoadFromEnv() (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
shutdownTimeout, err := loadDuration(envShutdownTimeout, cfg.ShutdownTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.ShutdownTimeout = shutdownTimeout
|
||||
|
||||
cfg.Logging.Level = loadString(envLoggingLevel, cfg.Logging.Level)
|
||||
|
||||
cfg.HTTP.Addr = loadString(envHTTPListenAddr, cfg.HTTP.Addr)
|
||||
if cfg.HTTP.ReadTimeout, err = loadDuration(envHTTPReadTimeout, cfg.HTTP.ReadTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.HTTP.WriteTimeout, err = loadDuration(envHTTPWriteTimeout, cfg.HTTP.WriteTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.HTTP.ShutdownTimeout, err = loadDuration(envHTTPShutdownTimeout, cfg.HTTP.ShutdownTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.GRPCPush.Addr = loadString(envGRPCPushListenAddr, cfg.GRPCPush.Addr)
|
||||
if cfg.GRPCPush.ShutdownTimeout, err = loadDuration(envGRPCPushShutdownTimeout, cfg.GRPCPush.ShutdownTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Postgres.DSN = loadString(envPostgresDSN, cfg.Postgres.DSN)
|
||||
if cfg.Postgres.MaxConns, err = loadInt(envPostgresMaxConns, cfg.Postgres.MaxConns); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Postgres.MinConns, err = loadInt(envPostgresMinConns, cfg.Postgres.MinConns); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Postgres.OperationTimeout, err = loadDuration(envPostgresOperationTimeout, cfg.Postgres.OperationTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.SMTP.Host = loadString(envSMTPHost, cfg.SMTP.Host)
|
||||
if cfg.SMTP.Port, err = loadInt(envSMTPPort, cfg.SMTP.Port); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.SMTP.Username = loadString(envSMTPUsername, cfg.SMTP.Username)
|
||||
cfg.SMTP.Password = loadString(envSMTPPassword, cfg.SMTP.Password)
|
||||
cfg.SMTP.From = loadString(envSMTPFrom, cfg.SMTP.From)
|
||||
cfg.SMTP.TLSMode = loadString(envSMTPTLSMode, cfg.SMTP.TLSMode)
|
||||
|
||||
if cfg.Mail.WorkerInterval, err = loadDuration(envMailWorkerInterval, cfg.Mail.WorkerInterval); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Mail.MaxAttempts, err = loadInt(envMailMaxAttempts, cfg.Mail.MaxAttempts); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Docker.Host = loadString(envDockerHost, cfg.Docker.Host)
|
||||
cfg.Docker.Network = loadString(envDockerNetwork, cfg.Docker.Network)
|
||||
|
||||
cfg.Game.StateRoot = loadString(envGameStateRoot, cfg.Game.StateRoot)
|
||||
|
||||
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
|
||||
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)
|
||||
|
||||
cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
|
||||
|
||||
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
|
||||
cfg.Telemetry.MetricsExporter = strings.ToLower(loadString(envOTelMetricsExporter, cfg.Telemetry.MetricsExporter))
|
||||
cfg.Telemetry.Protocol = strings.ToLower(loadString(envOTelProtocol, cfg.Telemetry.Protocol))
|
||||
cfg.Telemetry.Endpoint = loadString(envOTelEndpoint, cfg.Telemetry.Endpoint)
|
||||
cfg.Telemetry.PrometheusListenAddr = loadString(envOTelPrometheusListenAddr, cfg.Telemetry.PrometheusListenAddr)
|
||||
cfg.Telemetry.ServiceName = loadString(envServiceName, cfg.Telemetry.ServiceName)
|
||||
|
||||
if cfg.FreshnessWindow, err = loadDuration(envFreshnessWindow, cfg.FreshnessWindow); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if cfg.Auth.ChallengeTTL, err = loadDuration(envAuthChallengeTTL, cfg.Auth.ChallengeTTL); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Auth.ChallengeMaxAttempts, err = loadInt(envAuthChallengeMaxAttempts, cfg.Auth.ChallengeMaxAttempts); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Auth.ChallengeThrottle.Window, err = loadDuration(envAuthChallengeThrottleWindow, cfg.Auth.ChallengeThrottle.Window); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Auth.ChallengeThrottle.Max, err = loadInt(envAuthChallengeThrottleMax, cfg.Auth.ChallengeThrottle.Max); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Auth.UserNameMaxRetries, err = loadInt(envAuthUserNameMaxRetries, cfg.Auth.UserNameMaxRetries); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if cfg.Lobby.SweeperInterval, err = loadDuration(envLobbySweeperInterval, cfg.Lobby.SweeperInterval); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Lobby.PendingRegistrationTTL, err = loadDuration(envLobbyPendingRegistrationTTL, cfg.Lobby.PendingRegistrationTTL); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Lobby.InviteDefaultTTL, err = loadDuration(envLobbyInviteDefaultTTL, cfg.Lobby.InviteDefaultTTL); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if cfg.Engine.CallTimeout, err = loadDuration(envEngineCallTimeout, cfg.Engine.CallTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Engine.ProbeTimeout, err = loadDuration(envEngineProbeTimeout, cfg.Engine.ProbeTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if cfg.Runtime.WorkerPoolSize, err = loadInt(envRuntimeWorkerPoolSize, cfg.Runtime.WorkerPoolSize); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Runtime.JobQueueSize, err = loadInt(envRuntimeJobQueueSize, cfg.Runtime.JobQueueSize); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Runtime.ReconcileInterval, err = loadDuration(envRuntimeReconcileInterval, cfg.Runtime.ReconcileInterval); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Runtime.ImagePullPolicy = strings.ToLower(loadString(envRuntimeImagePullPolicy, cfg.Runtime.ImagePullPolicy))
|
||||
cfg.Runtime.ContainerLogDriver = loadString(envRuntimeContainerLogDriver, cfg.Runtime.ContainerLogDriver)
|
||||
cfg.Runtime.ContainerLogOpts = loadString(envRuntimeContainerLogOpts, cfg.Runtime.ContainerLogOpts)
|
||||
if cfg.Runtime.ContainerCPUQuota, err = loadFloat(envRuntimeContainerCPUQuota, cfg.Runtime.ContainerCPUQuota); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Runtime.ContainerMemory = loadString(envRuntimeContainerMemory, cfg.Runtime.ContainerMemory)
|
||||
if cfg.Runtime.ContainerPIDsLimit, err = loadInt(envRuntimeContainerPIDsLimit, cfg.Runtime.ContainerPIDsLimit); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Runtime.ContainerStateMount = loadString(envRuntimeContainerStateMount, cfg.Runtime.ContainerStateMount)
|
||||
if cfg.Runtime.StopGracePeriod, err = loadDuration(envRuntimeStopGracePeriod, cfg.Runtime.StopGracePeriod); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Notification.AdminEmail = loadString(envNotificationAdminEmail, cfg.Notification.AdminEmail)
|
||||
if cfg.Notification.WorkerInterval, err = loadDuration(envNotificationWorkerInterval, cfg.Notification.WorkerInterval); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Notification.MaxAttempts, err = loadInt(envNotificationMaxAttempts, cfg.Notification.MaxAttempts); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate enforces the documented invariants from README §4. Required string
|
||||
// fields must be non-empty; closed-set string options must match the allowed
|
||||
// values; numeric and duration fields must be positive.
|
||||
func (c Config) Validate() error {
|
||||
if c.ShutdownTimeout <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envShutdownTimeout)
|
||||
}
|
||||
if strings.TrimSpace(c.Logging.Level) == "" {
|
||||
return fmt.Errorf("%s must not be empty", envLoggingLevel)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.HTTP.Addr) == "" {
|
||||
return fmt.Errorf("%s must not be empty", envHTTPListenAddr)
|
||||
}
|
||||
if c.HTTP.ReadTimeout <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envHTTPReadTimeout)
|
||||
}
|
||||
if c.HTTP.WriteTimeout <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envHTTPWriteTimeout)
|
||||
}
|
||||
if c.HTTP.ShutdownTimeout <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envHTTPShutdownTimeout)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.GRPCPush.Addr) == "" {
|
||||
return fmt.Errorf("%s must not be empty", envGRPCPushListenAddr)
|
||||
}
|
||||
if c.GRPCPush.ShutdownTimeout <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envGRPCPushShutdownTimeout)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.Postgres.DSN) == "" {
|
||||
return fmt.Errorf("%s must be set", envPostgresDSN)
|
||||
}
|
||||
if c.Postgres.MaxConns <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envPostgresMaxConns)
|
||||
}
|
||||
if c.Postgres.MinConns < 0 {
|
||||
return fmt.Errorf("%s must not be negative", envPostgresMinConns)
|
||||
}
|
||||
if c.Postgres.MinConns > c.Postgres.MaxConns {
|
||||
return fmt.Errorf("%s must not exceed %s", envPostgresMinConns, envPostgresMaxConns)
|
||||
}
|
||||
if c.Postgres.OperationTimeout <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envPostgresOperationTimeout)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.SMTP.Host) == "" {
|
||||
return fmt.Errorf("%s must be set", envSMTPHost)
|
||||
}
|
||||
if c.SMTP.Port <= 0 || c.SMTP.Port > 65535 {
|
||||
return fmt.Errorf("%s must be a valid TCP port (got %d)", envSMTPPort, c.SMTP.Port)
|
||||
}
|
||||
if strings.TrimSpace(c.SMTP.From) == "" {
|
||||
return fmt.Errorf("%s must be set", envSMTPFrom)
|
||||
}
|
||||
if !containsString(allowedSMTPTLSModes, c.SMTP.TLSMode) {
|
||||
return fmt.Errorf("%s must be one of %v (got %q)", envSMTPTLSMode, allowedSMTPTLSModes, c.SMTP.TLSMode)
|
||||
}
|
||||
|
||||
if c.Mail.WorkerInterval <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envMailWorkerInterval)
|
||||
}
|
||||
if c.Mail.MaxAttempts <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envMailMaxAttempts)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.Docker.Host) == "" {
|
||||
return fmt.Errorf("%s must not be empty", envDockerHost)
|
||||
}
|
||||
if strings.TrimSpace(c.Docker.Network) == "" {
|
||||
return fmt.Errorf("%s must be set", envDockerNetwork)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.Game.StateRoot) == "" {
|
||||
return fmt.Errorf("%s must be set", envGameStateRoot)
|
||||
}
|
||||
|
||||
if c.Admin.User != "" && c.Admin.Password == "" {
|
||||
return fmt.Errorf("%s requires %s", envAdminBootstrapUser, envAdminBootstrapPassword)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.GeoIP.DBPath) == "" {
|
||||
return fmt.Errorf("%s must be set", envGeoIPDBPath)
|
||||
}
|
||||
|
||||
if !containsString(allowedTracesExporters, c.Telemetry.TracesExporter) {
|
||||
return fmt.Errorf("%s must be one of %v (got %q)", envOTelTracesExporter, allowedTracesExporters, c.Telemetry.TracesExporter)
|
||||
}
|
||||
if !containsString(allowedMetricsExporters, c.Telemetry.MetricsExporter) {
|
||||
return fmt.Errorf("%s must be one of %v (got %q)", envOTelMetricsExporter, allowedMetricsExporters, c.Telemetry.MetricsExporter)
|
||||
}
|
||||
if c.Telemetry.TracesExporter == "otlp" || c.Telemetry.MetricsExporter == "otlp" {
|
||||
if !containsString(allowedOTelProtocols, c.Telemetry.Protocol) {
|
||||
return fmt.Errorf("%s must be one of %v (got %q)", envOTelProtocol, allowedOTelProtocols, c.Telemetry.Protocol)
|
||||
}
|
||||
}
|
||||
if c.Telemetry.MetricsExporter == "prometheus" && strings.TrimSpace(c.Telemetry.PrometheusListenAddr) == "" {
|
||||
return fmt.Errorf("%s must be set when %s is %q", envOTelPrometheusListenAddr, envOTelMetricsExporter, "prometheus")
|
||||
}
|
||||
if strings.TrimSpace(c.Telemetry.ServiceName) == "" {
|
||||
return fmt.Errorf("%s must not be empty", envServiceName)
|
||||
}
|
||||
|
||||
if c.FreshnessWindow <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envFreshnessWindow)
|
||||
}
|
||||
|
||||
if c.Auth.ChallengeTTL <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envAuthChallengeTTL)
|
||||
}
|
||||
if c.Auth.ChallengeMaxAttempts <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envAuthChallengeMaxAttempts)
|
||||
}
|
||||
if c.Auth.ChallengeThrottle.Window <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envAuthChallengeThrottleWindow)
|
||||
}
|
||||
if c.Auth.ChallengeThrottle.Max <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envAuthChallengeThrottleMax)
|
||||
}
|
||||
if c.Auth.UserNameMaxRetries <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envAuthUserNameMaxRetries)
|
||||
}
|
||||
|
||||
if c.Lobby.SweeperInterval <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envLobbySweeperInterval)
|
||||
}
|
||||
if c.Lobby.PendingRegistrationTTL <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envLobbyPendingRegistrationTTL)
|
||||
}
|
||||
if c.Lobby.InviteDefaultTTL <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envLobbyInviteDefaultTTL)
|
||||
}
|
||||
|
||||
if c.Engine.CallTimeout <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envEngineCallTimeout)
|
||||
}
|
||||
if c.Engine.ProbeTimeout <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envEngineProbeTimeout)
|
||||
}
|
||||
|
||||
if c.Runtime.WorkerPoolSize <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envRuntimeWorkerPoolSize)
|
||||
}
|
||||
if c.Runtime.JobQueueSize <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envRuntimeJobQueueSize)
|
||||
}
|
||||
if c.Runtime.ReconcileInterval <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envRuntimeReconcileInterval)
|
||||
}
|
||||
if !containsString(allowedPullPolicies, c.Runtime.ImagePullPolicy) {
|
||||
return fmt.Errorf("%s must be one of %v (got %q)", envRuntimeImagePullPolicy, allowedPullPolicies, c.Runtime.ImagePullPolicy)
|
||||
}
|
||||
if strings.TrimSpace(c.Runtime.ContainerLogDriver) == "" {
|
||||
return fmt.Errorf("%s must not be empty", envRuntimeContainerLogDriver)
|
||||
}
|
||||
if c.Runtime.ContainerCPUQuota <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envRuntimeContainerCPUQuota)
|
||||
}
|
||||
if strings.TrimSpace(c.Runtime.ContainerMemory) == "" {
|
||||
return fmt.Errorf("%s must not be empty", envRuntimeContainerMemory)
|
||||
}
|
||||
if c.Runtime.ContainerPIDsLimit <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envRuntimeContainerPIDsLimit)
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(c.Runtime.ContainerStateMount), "/") {
|
||||
return fmt.Errorf("%s must be an absolute path (got %q)", envRuntimeContainerStateMount, c.Runtime.ContainerStateMount)
|
||||
}
|
||||
if c.Runtime.StopGracePeriod <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envRuntimeStopGracePeriod)
|
||||
}
|
||||
|
||||
if c.Notification.WorkerInterval <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envNotificationWorkerInterval)
|
||||
}
|
||||
if c.Notification.MaxAttempts <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envNotificationMaxAttempts)
|
||||
}
|
||||
if email := strings.TrimSpace(c.Notification.AdminEmail); email != "" {
|
||||
if _, err := netmail.ParseAddress(email); err != nil {
|
||||
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envNotificationAdminEmail, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadString(name, fallback string) string {
|
||||
raw, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return fallback
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func loadInt(name string, fallback int) (int, error) {
|
||||
raw, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func loadFloat(name string, fallback float64) (float64, error) {
|
||||
raw, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func loadDuration(name string, fallback time.Duration) (time.Duration, error) {
|
||||
raw, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func containsString(set []string, value string) bool {
|
||||
return slices.Contains(set, value)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// validEnv enumerates the minimum environment required by Validate after
|
||||
// LoadFromEnv. Tests start from this map and tweak individual entries.
|
||||
func validEnv() map[string]string {
|
||||
return map[string]string{
|
||||
"BACKEND_POSTGRES_DSN": "postgres://galaxy:galaxy@127.0.0.1:5432/galaxy?sslmode=disable",
|
||||
"BACKEND_SMTP_HOST": "smtp.example.test",
|
||||
"BACKEND_SMTP_FROM": "noreply@example.test",
|
||||
"BACKEND_DOCKER_NETWORK": "galaxy",
|
||||
"BACKEND_GAME_STATE_ROOT": "/tmp/galaxy",
|
||||
"BACKEND_GEOIP_DB_PATH": "/tmp/geoip.mmdb",
|
||||
}
|
||||
}
|
||||
|
||||
func setEnv(t *testing.T, env map[string]string) {
|
||||
t.Helper()
|
||||
for name, value := range env {
|
||||
t.Setenv(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvAcceptsValidEnv(t *testing.T) {
|
||||
setEnv(t, validEnv())
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromEnv returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.HTTP.Addr != defaultHTTPListenAddr {
|
||||
t.Fatalf("HTTP.Addr = %q, want %q", cfg.HTTP.Addr, defaultHTTPListenAddr)
|
||||
}
|
||||
if cfg.GRPCPush.Addr != defaultGRPCPushListenAddr {
|
||||
t.Fatalf("GRPCPush.Addr = %q, want %q", cfg.GRPCPush.Addr, defaultGRPCPushListenAddr)
|
||||
}
|
||||
if cfg.Postgres.DSN == "" {
|
||||
t.Fatalf("Postgres.DSN must be populated from env")
|
||||
}
|
||||
if cfg.Telemetry.TracesExporter != defaultOTelTracesExporter {
|
||||
t.Fatalf("Telemetry.TracesExporter = %q, want %q", cfg.Telemetry.TracesExporter, defaultOTelTracesExporter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvFailsWithoutPostgresDSN(t *testing.T) {
|
||||
env := validEnv()
|
||||
delete(env, "BACKEND_POSTGRES_DSN")
|
||||
setEnv(t, env)
|
||||
|
||||
if _, err := LoadFromEnv(); err == nil || !strings.Contains(err.Error(), "BACKEND_POSTGRES_DSN") {
|
||||
t.Fatalf("expected BACKEND_POSTGRES_DSN error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsAdminUserWithoutPassword(t *testing.T) {
|
||||
env := validEnv()
|
||||
env["BACKEND_ADMIN_BOOTSTRAP_USER"] = "root"
|
||||
setEnv(t, env)
|
||||
|
||||
if _, err := LoadFromEnv(); err == nil || !strings.Contains(err.Error(), "BACKEND_ADMIN_BOOTSTRAP_PASSWORD") {
|
||||
t.Fatalf("expected admin password requirement, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnknownTracesExporter(t *testing.T) {
|
||||
env := validEnv()
|
||||
env["BACKEND_OTEL_TRACES_EXPORTER"] = "kafka"
|
||||
setEnv(t, env)
|
||||
|
||||
if _, err := LoadFromEnv(); err == nil || !strings.Contains(err.Error(), "BACKEND_OTEL_TRACES_EXPORTER") {
|
||||
t.Fatalf("expected traces-exporter validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsPrometheusWithoutAddr(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Postgres.DSN = "postgres://x:y@127.0.0.1/galaxy"
|
||||
cfg.SMTP.Host = "smtp"
|
||||
cfg.SMTP.From = "from@x"
|
||||
cfg.Docker.Network = "galaxy"
|
||||
cfg.Game.StateRoot = "/tmp/galaxy"
|
||||
cfg.GeoIP.DBPath = "/tmp/geo"
|
||||
cfg.Telemetry.MetricsExporter = "prometheus"
|
||||
cfg.Telemetry.PrometheusListenAddr = ""
|
||||
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "BACKEND_OTEL_PROMETHEUS_LISTEN_ADDR") {
|
||||
t.Fatalf("expected prometheus address requirement, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user