package config import ( "fmt" "os" "strconv" "strings" "time" "galaxy/postgres" "galaxy/redisconn" ) // LoadFromEnv builds Config from environment variables and validates the // resulting configuration. 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.WriteTimeout, err = durationEnv(internalHTTPWriteTimeoutEnvVar, cfg.InternalHTTP.WriteTimeout) if err != nil { return Config{}, err } cfg.InternalHTTP.IdleTimeout, err = durationEnv(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout) if err != nil { return Config{}, err } cfg.Docker.Host = stringEnv(dockerHostEnvVar, cfg.Docker.Host) cfg.Docker.APIVersion = stringEnv(dockerAPIVersionEnvVar, cfg.Docker.APIVersion) cfg.Docker.Network = stringEnv(dockerNetworkEnvVar, cfg.Docker.Network) cfg.Docker.LogDriver = stringEnv(dockerLogDriverEnvVar, cfg.Docker.LogDriver) cfg.Docker.LogOpts = stringEnv(dockerLogOptsEnvVar, cfg.Docker.LogOpts) if raw, ok := os.LookupEnv(imagePullPolicyEnvVar); ok { cfg.Docker.PullPolicy = ImagePullPolicy(strings.TrimSpace(raw)) } pgConn, err := postgres.LoadFromEnv(envPrefix) if err != nil { return Config{}, err } cfg.Postgres.Conn = pgConn redisConn, err := redisconn.LoadFromEnv(envPrefix) if err != nil { return Config{}, err } cfg.Redis.Conn = redisConn cfg.Streams.StartJobs = stringEnv(startJobsStreamEnvVar, cfg.Streams.StartJobs) cfg.Streams.StopJobs = stringEnv(stopJobsStreamEnvVar, cfg.Streams.StopJobs) cfg.Streams.JobResults = stringEnv(jobResultsStreamEnvVar, cfg.Streams.JobResults) cfg.Streams.HealthEvents = stringEnv(healthEventsStreamEnvVar, cfg.Streams.HealthEvents) cfg.Streams.NotificationIntents = stringEnv(notificationIntentsStreamEnv, cfg.Streams.NotificationIntents) cfg.Streams.BlockTimeout, err = durationEnv(streamBlockTimeoutEnvVar, cfg.Streams.BlockTimeout) if err != nil { return Config{}, err } cfg.Container.DefaultCPUQuota, err = floatEnv(defaultCPUQuotaEnvVar, cfg.Container.DefaultCPUQuota) if err != nil { return Config{}, err } cfg.Container.DefaultMemory = stringEnv(defaultMemoryEnvVar, cfg.Container.DefaultMemory) cfg.Container.DefaultPIDsLimit, err = intEnv(defaultPIDsLimitEnvVar, cfg.Container.DefaultPIDsLimit) if err != nil { return Config{}, err } cfg.Container.StopTimeout, err = secondsEnv(containerStopTimeoutSecondsEnvVar, cfg.Container.StopTimeout) if err != nil { return Config{}, err } cfg.Container.Retention, err = daysEnv(containerRetentionDaysEnvVar, cfg.Container.Retention) if err != nil { return Config{}, err } cfg.Container.EngineStateMountPath = stringEnv(engineStateMountPathEnvVar, cfg.Container.EngineStateMountPath) cfg.Container.EngineStateEnvName = stringEnv(engineStateEnvNameEnvVar, cfg.Container.EngineStateEnvName) cfg.Container.GameStateDirMode, err = octalUint32Env(gameStateDirModeEnvVar, cfg.Container.GameStateDirMode) if err != nil { return Config{}, err } cfg.Container.GameStateOwnerUID, err = intEnv(gameStateOwnerUIDEnvVar, cfg.Container.GameStateOwnerUID) if err != nil { return Config{}, err } cfg.Container.GameStateOwnerGID, err = intEnv(gameStateOwnerGIDEnvVar, cfg.Container.GameStateOwnerGID) if err != nil { return Config{}, err } root, ok := os.LookupEnv(gameStateRootEnvVar) if !ok || strings.TrimSpace(root) == "" { return Config{}, fmt.Errorf("%s must be set", gameStateRootEnvVar) } cfg.Container.GameStateRoot = strings.TrimSpace(root) cfg.Health.InspectInterval, err = durationEnv(inspectIntervalEnvVar, cfg.Health.InspectInterval) if err != nil { return Config{}, err } cfg.Health.ProbeInterval, err = durationEnv(probeIntervalEnvVar, cfg.Health.ProbeInterval) if err != nil { return Config{}, err } cfg.Health.ProbeTimeout, err = durationEnv(probeTimeoutEnvVar, cfg.Health.ProbeTimeout) if err != nil { return Config{}, err } cfg.Health.ProbeFailuresThreshold, err = intEnv(probeFailuresThresholdEnvVar, cfg.Health.ProbeFailuresThreshold) if err != nil { return Config{}, err } cfg.Cleanup.ReconcileInterval, err = durationEnv(reconcileIntervalEnvVar, cfg.Cleanup.ReconcileInterval) if err != nil { return Config{}, err } cfg.Cleanup.CleanupInterval, err = durationEnv(cleanupIntervalEnvVar, cfg.Cleanup.CleanupInterval) if err != nil { return Config{}, err } cfg.Coordination.GameLeaseTTL, err = secondsEnv(gameLeaseTTLSecondsEnvVar, cfg.Coordination.GameLeaseTTL) if err != nil { return Config{}, err } lobbyURL, ok := os.LookupEnv(lobbyInternalBaseURLEnvVar) if !ok || strings.TrimSpace(lobbyURL) == "" { return Config{}, fmt.Errorf("%s must be set", lobbyInternalBaseURLEnvVar) } cfg.Lobby.BaseURL = strings.TrimSpace(lobbyURL) cfg.Lobby.Timeout, err = durationEnv(lobbyInternalTimeoutEnvVar, cfg.Lobby.Timeout) 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 = normalizeProtocolValue( os.Getenv(otelExporterOTLPTracesProtocolEnvVar), os.Getenv(otelExporterOTLPProtocolEnvVar), cfg.Telemetry.TracesProtocol, ) cfg.Telemetry.MetricsProtocol = normalizeProtocolValue( os.Getenv(otelExporterOTLPMetricsProtocolEnvVar), os.Getenv(otelExporterOTLPProtocolEnvVar), cfg.Telemetry.MetricsProtocol, ) 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 := 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: parse duration: %w", name, err) } return parsed, nil } func secondsEnv(name string, fallback time.Duration) (time.Duration, 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: parse seconds: %w", name, err) } if parsed <= 0 { return 0, fmt.Errorf("%s: must be positive", name) } return time.Duration(parsed) * time.Second, nil } func daysEnv(name string, fallback time.Duration) (time.Duration, 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: parse days: %w", name, err) } if parsed <= 0 { return 0, fmt.Errorf("%s: must be positive", name) } return time.Duration(parsed) * 24 * time.Hour, 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: parse int: %w", name, err) } return parsed, nil } func floatEnv(name string, fallback float64) (float64, error) { value, ok := os.LookupEnv(name) if !ok { return fallback, nil } parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) if err != nil { return 0, fmt.Errorf("%s: parse float: %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: parse bool: %w", name, err) } return parsed, nil } func octalUint32Env(name string, fallback uint32) (uint32, error) { value, ok := os.LookupEnv(name) if !ok { return fallback, nil } parsed, err := strconv.ParseUint(strings.TrimSpace(value), 8, 32) if err != nil { return 0, fmt.Errorf("%s: parse octal: %w", name, err) } return uint32(parsed), nil } func normalizeExporterValue(value string) string { trimmed := strings.TrimSpace(value) switch trimmed { case "", "none": return "none" default: return trimmed } } func normalizeProtocolValue(primary string, fallback string, defaultValue string) string { primary = strings.TrimSpace(primary) if primary != "" { return primary } fallback = strings.TrimSpace(fallback) if fallback != "" { return fallback } return strings.TrimSpace(defaultValue) }