// 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) }