package config import ( "fmt" "log/slog" "os" "strconv" "strings" "time" "galaxy/postgres" "galaxy/redisconn" ) // LoadFromEnv builds Config from environment variables and validates the // resulting configuration. Connection topology for Redis and PostgreSQL is // delegated to the shared `pkg/redisconn` and `pkg/postgres` LoadFromEnv // helpers — the Redis loader hard-fails on the deprecated // `NOTIFICATION_REDIS_TLS_ENABLED` / `NOTIFICATION_REDIS_USERNAME` env vars; // the Postgres loader requires a primary DSN. func LoadFromEnv() (Config, error) { cfg := DefaultConfig() var err error cfg.ShutdownTimeout, err = durationEnv(shutdownTimeoutEnvVar, cfg.ShutdownTimeout) if err != nil { return Config{}, err } cfg.Logging.Level = stringEnv(logLevelEnvVar, cfg.Logging.Level) cfg.InternalHTTP.Addr = stringEnv(internalHTTPAddrEnvVar, cfg.InternalHTTP.Addr) cfg.InternalHTTP.ReadHeaderTimeout, err = durationEnv(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout) if err != nil { return Config{}, err } cfg.InternalHTTP.ReadTimeout, err = durationEnv(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout) if err != nil { return Config{}, err } cfg.InternalHTTP.IdleTimeout, err = durationEnv(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout) if err != nil { return Config{}, err } redisConn, err := redisconn.LoadFromEnv(envPrefix) if err != nil { return Config{}, err } cfg.Redis.Conn = redisConn pgConn, err := postgres.LoadFromEnv(envPrefix) if err != nil { return Config{}, err } cfg.Postgres.Conn = pgConn cfg.Streams.Intents = stringEnv(intentsStreamEnvVar, cfg.Streams.Intents) cfg.Streams.GatewayClientEvents = stringEnv(gatewayClientEventsStreamEnvVar, cfg.Streams.GatewayClientEvents) cfg.Streams.GatewayClientEventsStreamMaxLen, err = int64Env(gatewayClientEventsStreamMaxEnvVar, cfg.Streams.GatewayClientEventsStreamMaxLen) if err != nil { return Config{}, err } cfg.Streams.MailDeliveryCommands = stringEnv(mailDeliveryCommandsStreamEnvVar, cfg.Streams.MailDeliveryCommands) cfg.IntentsReadBlockTimeout, err = durationEnv(intentsReadBlockTimeoutEnvVar, cfg.IntentsReadBlockTimeout) if err != nil { return Config{}, err } cfg.Retry.PushMaxAttempts, err = intEnv(pushRetryMaxAttemptsEnvVar, cfg.Retry.PushMaxAttempts) if err != nil { return Config{}, err } cfg.Retry.EmailMaxAttempts, err = intEnv(emailRetryMaxAttemptsEnvVar, cfg.Retry.EmailMaxAttempts) if err != nil { return Config{}, err } cfg.Retry.RouteLeaseTTL, err = durationEnv(routeLeaseTTLEnvVar, cfg.Retry.RouteLeaseTTL) if err != nil { return Config{}, err } cfg.Retry.RouteBackoffMin, err = durationEnv(routeBackoffMinEnvVar, cfg.Retry.RouteBackoffMin) if err != nil { return Config{}, err } cfg.Retry.RouteBackoffMax, err = durationEnv(routeBackoffMaxEnvVar, cfg.Retry.RouteBackoffMax) if err != nil { return Config{}, err } cfg.Retry.IdempotencyTTL, err = durationEnv(idempotencyTTLEnvVar, cfg.Retry.IdempotencyTTL) if err != nil { return Config{}, err } cfg.Retention.RecordRetention, err = durationEnv(recordRetentionEnvVar, cfg.Retention.RecordRetention) if err != nil { return Config{}, err } cfg.Retention.MalformedIntentRetention, err = durationEnv(malformedIntentRetentionEnvVar, cfg.Retention.MalformedIntentRetention) if err != nil { return Config{}, err } cfg.Retention.CleanupInterval, err = durationEnv(cleanupIntervalEnvVar, cfg.Retention.CleanupInterval) if err != nil { return Config{}, err } cfg.UserService.BaseURL = normalizeBaseURL(stringEnv(userServiceBaseURLEnvVar, cfg.UserService.BaseURL)) cfg.UserService.Timeout, err = durationEnv(userServiceTimeoutEnvVar, cfg.UserService.Timeout) if err != nil { return Config{}, err } cfg.AdminRouting.GeoReviewRecommended, err = emailListEnv(adminEmailsGeoReviewRecommendedEnvVar, cfg.AdminRouting.GeoReviewRecommended) if err != nil { return Config{}, err } cfg.AdminRouting.GameGenerationFailed, err = emailListEnv(adminEmailsGameGenerationFailedEnvVar, cfg.AdminRouting.GameGenerationFailed) if err != nil { return Config{}, err } cfg.AdminRouting.LobbyRuntimePausedAfterStart, err = emailListEnv(adminEmailsLobbyRuntimePausedAfterEnvVar, cfg.AdminRouting.LobbyRuntimePausedAfterStart) if err != nil { return Config{}, err } cfg.AdminRouting.LobbyApplicationSubmitted, err = emailListEnv(adminEmailsLobbyApplicationSubmittedEnvVar, cfg.AdminRouting.LobbyApplicationSubmitted) if err != nil { return Config{}, err } cfg.Telemetry.ServiceName = stringEnv(otelServiceNameEnvVar, cfg.Telemetry.ServiceName) cfg.Telemetry.TracesExporter = normalizeExporterValue(stringEnv(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter)) cfg.Telemetry.MetricsExporter = normalizeExporterValue(stringEnv(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter)) cfg.Telemetry.TracesProtocol = loadOTLPProtocol( os.Getenv(otelExporterOTLPTracesProtocolEnvVar), os.Getenv(otelExporterOTLPProtocolEnvVar), cfg.Telemetry.TracesExporter, ) cfg.Telemetry.MetricsProtocol = loadOTLPProtocol( os.Getenv(otelExporterOTLPMetricsProtocolEnvVar), os.Getenv(otelExporterOTLPProtocolEnvVar), cfg.Telemetry.MetricsExporter, ) cfg.Telemetry.StdoutTracesEnabled, err = boolEnv(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled) if err != nil { return Config{}, err } cfg.Telemetry.StdoutMetricsEnabled, err = boolEnv(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled) if err != nil { return Config{}, err } if err := validateLogLevel(cfg.Logging.Level); err != nil { return Config{}, fmt.Errorf("load notification config: %s: %w", logLevelEnvVar, err) } if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } func stringEnv(name string, fallback string) string { value, ok := os.LookupEnv(name) if !ok { return fallback } return strings.TrimSpace(value) } func durationEnv(name string, fallback time.Duration) (time.Duration, error) { value, ok := os.LookupEnv(name) if !ok { return fallback, nil } parsed, err := time.ParseDuration(strings.TrimSpace(value)) if err != nil { return 0, fmt.Errorf("%s: %w", name, err) } return parsed, nil } func intEnv(name string, fallback int) (int, error) { value, ok := os.LookupEnv(name) if !ok { return fallback, nil } parsed, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil { return 0, fmt.Errorf("%s: %w", name, err) } return parsed, nil } func int64Env(name string, fallback int64) (int64, error) { value, ok := os.LookupEnv(name) if !ok { return fallback, nil } parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err != nil { return 0, fmt.Errorf("%s: %w", name, err) } return parsed, nil } func boolEnv(name string, fallback bool) (bool, error) { value, ok := os.LookupEnv(name) if !ok { return fallback, nil } parsed, err := strconv.ParseBool(strings.TrimSpace(value)) if err != nil { return false, fmt.Errorf("%s: %w", name, err) } return parsed, nil } func emailListEnv(name string, fallback []string) ([]string, error) { raw, ok := os.LookupEnv(name) if !ok { return append([]string(nil), fallback...), nil } return parseEmailList(name, raw) } func validateLogLevel(value string) error { var level slog.Level return level.UnmarshalText([]byte(strings.TrimSpace(value))) } func normalizeExporterValue(value string) string { switch strings.TrimSpace(value) { case "", otelExporterNone: return otelExporterNone default: return strings.TrimSpace(value) } } func loadOTLPProtocol(primary string, fallback string, exporter string) string { protocol := strings.TrimSpace(primary) if protocol == "" { protocol = strings.TrimSpace(fallback) } if protocol == "" && exporter == otelExporterOTLP { return otelProtocolHTTPProtobuf } return protocol }