// Package config loads and validates the backend's runtime configuration from // the process environment. package config import ( "fmt" "os" "strconv" "time" "scrabble/backend/internal/account" "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" "scrabble/backend/internal/postgres" "scrabble/backend/internal/robot" "scrabble/backend/internal/telemetry" ) // Config holds the backend's runtime configuration. type Config struct { // HTTPAddr is the listen address of the HTTP listener (host:port). HTTPAddr string // GRPCAddr is the listen address of the gRPC push listener (host:port) that // streams live events to the gateway. GRPCAddr string // LogLevel is the zap log level: "debug", "info", "warn" or "error". LogLevel string // Postgres configures the primary database pool. Postgres postgres.Config // Telemetry configures the OpenTelemetry providers. Telemetry telemetry.Config // Game configures the game subsystem (dictionaries, sweeper, live-game cache). Game game.Config // Lobby configures matchmaking robot substitution (wait window, reaper cadence). Lobby lobby.Config // Robot configures the robot opponent driver (scan cadence). Robot robot.Config // SMTP configures the email relay used for confirm-codes. An empty Host // selects the development log mailer (the code is logged, not sent). SMTP account.SMTPConfig } // Defaults applied when the corresponding environment variable is unset. const ( defaultHTTPAddr = ":8080" defaultGRPCAddr = ":9090" defaultLogLevel = "info" ) // Load reads the configuration from the environment, applies defaults for unset // variables, and validates the result. func Load() (Config, error) { pg := postgres.DefaultConfig() pg.DSN = os.Getenv("BACKEND_POSTGRES_DSN") var err error if pg.MaxOpenConns, err = envInt("BACKEND_POSTGRES_MAX_OPEN_CONNS", pg.MaxOpenConns); err != nil { return Config{}, err } if pg.MaxIdleConns, err = envInt("BACKEND_POSTGRES_MAX_IDLE_CONNS", pg.MaxIdleConns); err != nil { return Config{}, err } if pg.ConnMaxLifetime, err = envDuration("BACKEND_POSTGRES_CONN_MAX_LIFETIME", pg.ConnMaxLifetime); err != nil { return Config{}, err } if pg.OperationTimeout, err = envDuration("BACKEND_POSTGRES_OPERATION_TIMEOUT", pg.OperationTimeout); err != nil { return Config{}, err } tel := telemetry.DefaultConfig() tel.ServiceName = envOr("BACKEND_SERVICE_NAME", tel.ServiceName) tel.TracesExporter = envOr("BACKEND_OTEL_TRACES_EXPORTER", tel.TracesExporter) tel.MetricsExporter = envOr("BACKEND_OTEL_METRICS_EXPORTER", tel.MetricsExporter) gm := game.DefaultConfig() gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir) gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion) if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil { return Config{}, err } if gm.CacheTTL, err = envDuration("BACKEND_GAME_CACHE_TTL", gm.CacheTTL); err != nil { return Config{}, err } lb := lobby.DefaultConfig() if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil { return Config{}, err } if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil { return Config{}, err } rb := robot.DefaultConfig() if rb.DriveInterval, err = envDuration("BACKEND_ROBOT_DRIVE_INTERVAL", rb.DriveInterval); err != nil { return Config{}, err } smtp := account.SMTPConfig{ Host: os.Getenv("BACKEND_SMTP_HOST"), Port: envOr("BACKEND_SMTP_PORT", "587"), Username: os.Getenv("BACKEND_SMTP_USERNAME"), Password: os.Getenv("BACKEND_SMTP_PASSWORD"), From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"), } c := Config{ HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr), LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel), Postgres: pg, Telemetry: tel, Game: gm, Lobby: lb, Robot: rb, SMTP: smtp, } if err := c.validate(); err != nil { return Config{}, err } return c, nil } // validate reports whether the configuration values are acceptable. func (c Config) validate() error { switch c.LogLevel { case "debug", "info", "warn", "error": default: return fmt.Errorf("config: invalid BACKEND_LOG_LEVEL %q", c.LogLevel) } if c.HTTPAddr == "" { return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty") } if c.GRPCAddr == "" { return fmt.Errorf("config: BACKEND_GRPC_ADDR must not be empty") } if err := c.Postgres.Validate(); err != nil { return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err) } if err := c.Telemetry.Validate(); err != nil { return fmt.Errorf("config: %w", err) } if err := c.Game.Validate(); err != nil { return fmt.Errorf("config: %w (set BACKEND_DICT_DIR)", err) } if err := c.Lobby.Validate(); err != nil { return fmt.Errorf("config: %w", err) } if err := c.Robot.Validate(); err != nil { return fmt.Errorf("config: %w", err) } return nil } // envOr returns the value of the environment variable named key, or fallback // when the variable is unset or empty. func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // envInt parses the environment variable named key as an int, returning // fallback when it is unset and an error when it is set but malformed. func envInt(key string, fallback int) (int, error) { v := os.Getenv(key) if v == "" { return fallback, nil } n, err := strconv.Atoi(v) if err != nil { return 0, fmt.Errorf("config: %s: %w", key, err) } return n, nil } // envDuration parses the environment variable named key as a Go duration, // returning fallback when it is unset and an error when it is set but malformed. func envDuration(key string, fallback time.Duration) (time.Duration, error) { v := os.Getenv(key) if v == "" { return fallback, nil } d, err := time.ParseDuration(v) if err != nil { return 0, fmt.Errorf("config: %s: %w", key, err) } return d, nil }