Files
Ilia Denisov 9f7c9099bc
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · Integration / integration (pull_request) Successful in 1m37s
diplomail (Stage E): LibreTranslate client + async translation worker
Synchronous translation on read (Stage D) blocks the HTTP handler on
translator I/O. Stage E switches to "send moments-fast, deliver
when translated": recipients whose preferred_language differs from
the detected body_lang are inserted with available_at=NULL, and an
async worker turns them on once a LibreTranslate call materialises
the cache row (or fails terminally after 5 retries).

Schema delta on diplomail_recipients: available_at,
translation_attempts, next_translation_attempt_at, plus a snapshot
recipient_preferred_language so the worker queries do not need a
join. Read paths (ListInbox, GetMessage, UnreadCount) filter on
available_at IS NOT NULL. Push fan-out is moved from Service to the
worker so the recipient only sees the toast when the inbox row is
actually visible.

Translator backend is now a configurable choice: empty
BACKEND_DIPLOMAIL_TRANSLATOR_URL → noop (deliver original);
populated → LibreTranslate HTTP client. Per-attempt timeout, max
attempts, and worker interval all live in DiplomailConfig. The HTTP
client itself is unit-tested via httptest (happy path, BCP47
normalisation, unsupported pair, 5xx, identical src/dst, missing
URL); worker delivery + fallback paths are covered by the
testcontainers-backed e2e tests in diplomail_e2e_test.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:15:28 +02:00

1045 lines
38 KiB
Go

// 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"
envAuthDevFixedCode = "BACKEND_AUTH_DEV_FIXED_CODE"
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"
envDiplomailMaxBodyBytes = "BACKEND_DIPLOMAIL_MAX_BODY_BYTES"
envDiplomailMaxSubjectBytes = "BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES"
envDiplomailTranslatorURL = "BACKEND_DIPLOMAIL_TRANSLATOR_URL"
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT"
)
// 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
defaultDiplomailMaxBodyBytes = 4096
defaultDiplomailMaxSubjectBytes = 256
defaultDiplomailTranslatorTimeout = 10 * time.Second
defaultDiplomailTranslatorMaxAttempts = 5
defaultDiplomailWorkerInterval = 2 * time.Second
defaultDevSandboxEngineVersion = "0.1.0"
defaultDevSandboxPlayerCount = 20
)
// 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
Diplomail DiplomailConfig
DevSandbox DevSandboxConfig
// FreshnessWindow mirrors the gateway freshness window and is used by the
// push server to bound the cursor TTL.
FreshnessWindow time.Duration
}
// DevSandboxConfig configures the boot-time bootstrap implemented in
// `backend/internal/devsandbox`. When Email is empty the bootstrap
// is a no-op, which is the production posture. When Email is set —
// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack —
// the bootstrap idempotently provisions a real user, the configured
// number of dummy participants, a private "Dev Sandbox" game, the
// matching memberships, and drives the lifecycle to `running`. The
// engine image and engine version refer to a row that the bootstrap
// also seeds in `engine_versions`.
type DevSandboxConfig struct {
Email string
EngineImage string
EngineVersion string
PlayerCount int
}
// 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
// DevFixedCode, when non-empty, makes ConfirmEmailCode accept this
// literal as a valid code in addition to the bcrypt-verified one
// stored on the challenge row. The override is intended for the
// `tools/local-dev` stack so a developer can log in without
// reading codes out of Mailpit. The variable MUST stay unset in
// production: validation requires a six-digit decimal value, and
// the auth service emits a loud startup warning when it picks the
// override up.
DevFixedCode string
}
// 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
}
// DiplomailConfig bounds the diplomatic-mail subsystem. Both limits
// are enforced in the service layer, so they can be tuned at runtime
// without a schema migration. Body and subject are stored as plain
// UTF-8 text; HTML is neither parsed nor sanitised on the server.
type DiplomailConfig struct {
// MaxBodyBytes caps the length of `diplomail_messages.body` in
// bytes (not runes). A send whose body exceeds the limit is
// rejected with ErrInvalidInput.
MaxBodyBytes int
// MaxSubjectBytes caps the length of `diplomail_messages.subject`
// in bytes. Subjects are optional; the empty-string default
// passes the limit trivially.
MaxSubjectBytes int
// TranslatorURL is the base URL of the LibreTranslate-compatible
// instance the async translation worker calls. When empty, the
// worker still runs but falls through to "deliver original"
// (the noop translator returns engine=noop).
TranslatorURL string
// TranslatorTimeout bounds a single HTTP request to the
// translator. Worker retries (exponential backoff up to
// TranslatorMaxAttempts) layer on top.
TranslatorTimeout time.Duration
// TranslatorMaxAttempts is the number of times the worker tries
// to translate one (message, target_lang) pair before falling
// back to delivering the original body.
TranslatorMaxAttempts int
// WorkerInterval bounds how often the async translation worker
// scans for pending pairs. The worker handles one pair per tick.
WorkerInterval 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,
},
Diplomail: DiplomailConfig{
MaxBodyBytes: defaultDiplomailMaxBodyBytes,
MaxSubjectBytes: defaultDiplomailMaxSubjectBytes,
TranslatorTimeout: defaultDiplomailTranslatorTimeout,
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
WorkerInterval: defaultDiplomailWorkerInterval,
},
DevSandbox: DevSandboxConfig{
EngineVersion: defaultDevSandboxEngineVersion,
PlayerCount: defaultDevSandboxPlayerCount,
},
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
}
cfg.Auth.DevFixedCode = loadString(envAuthDevFixedCode, cfg.Auth.DevFixedCode)
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 cfg.Diplomail.MaxBodyBytes, err = loadInt(envDiplomailMaxBodyBytes, cfg.Diplomail.MaxBodyBytes); err != nil {
return Config{}, err
}
if cfg.Diplomail.MaxSubjectBytes, err = loadInt(envDiplomailMaxSubjectBytes, cfg.Diplomail.MaxSubjectBytes); err != nil {
return Config{}, err
}
cfg.Diplomail.TranslatorURL = loadString(envDiplomailTranslatorURL, cfg.Diplomail.TranslatorURL)
if cfg.Diplomail.TranslatorTimeout, err = loadDuration(envDiplomailTranslatorTimeout, cfg.Diplomail.TranslatorTimeout); err != nil {
return Config{}, err
}
if cfg.Diplomail.TranslatorMaxAttempts, err = loadInt(envDiplomailTranslatorMaxAttempts, cfg.Diplomail.TranslatorMaxAttempts); err != nil {
return Config{}, err
}
if cfg.Diplomail.WorkerInterval, err = loadDuration(envDiplomailWorkerInterval, cfg.Diplomail.WorkerInterval); err != nil {
return Config{}, err
}
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); 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.Auth.DevFixedCode != "" {
if !isDecimalString(c.Auth.DevFixedCode, 6) {
return fmt.Errorf("%s must be a six-digit decimal string when set", envAuthDevFixedCode)
}
}
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 c.Diplomail.MaxBodyBytes <= 0 {
return fmt.Errorf("%s must be positive", envDiplomailMaxBodyBytes)
}
if c.Diplomail.MaxSubjectBytes < 0 {
return fmt.Errorf("%s must not be negative", envDiplomailMaxSubjectBytes)
}
if c.Diplomail.TranslatorTimeout <= 0 {
return fmt.Errorf("%s must be positive", envDiplomailTranslatorTimeout)
}
if c.Diplomail.TranslatorMaxAttempts <= 0 {
return fmt.Errorf("%s must be positive", envDiplomailTranslatorMaxAttempts)
}
if c.Diplomail.WorkerInterval <= 0 {
return fmt.Errorf("%s must be positive", envDiplomailWorkerInterval)
}
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)
}
}
if email := strings.TrimSpace(c.DevSandbox.Email); email != "" {
if _, err := netmail.ParseAddress(email); err != nil {
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envDevSandboxEmail, err)
}
if strings.TrimSpace(c.DevSandbox.EngineImage) == "" {
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineImage, envDevSandboxEmail)
}
if strings.TrimSpace(c.DevSandbox.EngineVersion) == "" {
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineVersion, envDevSandboxEmail)
}
if c.DevSandbox.PlayerCount <= 0 {
return fmt.Errorf("%s must be positive when %s is set", envDevSandboxPlayerCount, envDevSandboxEmail)
}
}
return nil
}
func isDecimalString(value string, length int) bool {
if len(value) != length {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
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)
}