// Package config loads the user-service process configuration from environment // variables. package config import ( "crypto/tls" "fmt" "net" "os" "strconv" "strings" "time" ) const ( shutdownTimeoutEnvVar = "USERSERVICE_SHUTDOWN_TIMEOUT" logLevelEnvVar = "USERSERVICE_LOG_LEVEL" internalHTTPAddrEnvVar = "USERSERVICE_INTERNAL_HTTP_ADDR" internalHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_HEADER_TIMEOUT" internalHTTPReadTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_TIMEOUT" internalHTTPIdleTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_IDLE_TIMEOUT" internalHTTPRequestTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT" adminHTTPAddrEnvVar = "USERSERVICE_ADMIN_HTTP_ADDR" adminHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_HEADER_TIMEOUT" adminHTTPReadTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_TIMEOUT" adminHTTPIdleTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_IDLE_TIMEOUT" redisAddrEnvVar = "USERSERVICE_REDIS_ADDR" redisUsernameEnvVar = "USERSERVICE_REDIS_USERNAME" redisPasswordEnvVar = "USERSERVICE_REDIS_PASSWORD" redisDBEnvVar = "USERSERVICE_REDIS_DB" redisTLSEnabledEnvVar = "USERSERVICE_REDIS_TLS_ENABLED" redisOperationTimeoutEnvVar = "USERSERVICE_REDIS_OPERATION_TIMEOUT" redisKeyspacePrefixEnvVar = "USERSERVICE_REDIS_KEYSPACE_PREFIX" redisDomainEventsStreamEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM" redisDomainEventsStreamMaxLenEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN" otelServiceNameEnvVar = "OTEL_SERVICE_NAME" otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER" otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER" otelExporterOTLPProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL" otelExporterOTLPTracesProtocolEnvVar = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL" otelExporterOTLPMetricsProtocolEnvVar = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL" otelStdoutTracesEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_TRACES_ENABLED" otelStdoutMetricsEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_METRICS_ENABLED" defaultShutdownTimeout = 5 * time.Second defaultLogLevel = "info" defaultInternalHTTPAddr = ":8091" defaultAdminHTTPAddr = "" defaultReadHeaderTimeout = 2 * time.Second defaultReadTimeout = 10 * time.Second defaultIdleTimeout = time.Minute defaultRequestTimeout = 3 * time.Second defaultRedisDB = 0 defaultRedisOperationTimeout = 250 * time.Millisecond defaultRedisKeyspacePrefix = "user:" defaultDomainEventsStream = "user:domain_events" defaultDomainEventsStreamMaxLen = 1024 defaultOTelServiceName = "galaxy-user" otelExporterNone = "none" otelExporterOTLP = "otlp" otelProtocolHTTPProtobuf = "http/protobuf" otelProtocolGRPC = "grpc" ) // Config stores the full user-service process configuration. type Config struct { // ShutdownTimeout bounds graceful shutdown of the long-lived listeners and // runtime resources. ShutdownTimeout time.Duration // Logging configures the process-wide logger. Logging LoggingConfig // InternalHTTP configures the trusted internal HTTP listener. InternalHTTP InternalHTTPConfig // AdminHTTP configures the optional private admin HTTP listener. AdminHTTP AdminHTTPConfig // Redis configures the Redis-backed user store and domain-event publisher. Redis RedisConfig // Telemetry configures the process-wide OpenTelemetry runtime. Telemetry TelemetryConfig } // LoggingConfig configures the process-wide logger. type LoggingConfig struct { // Level stores the process log level. Level string } // InternalHTTPConfig configures the internal HTTP listener. type InternalHTTPConfig struct { // Addr stores the TCP listen address. Addr string // ReadHeaderTimeout bounds request-header reading. ReadHeaderTimeout time.Duration // ReadTimeout bounds reading one request. ReadTimeout time.Duration // IdleTimeout bounds how long keep-alive connections stay open. IdleTimeout time.Duration // RequestTimeout bounds one application-layer request execution. RequestTimeout time.Duration } // Validate reports whether cfg stores a usable internal HTTP listener // configuration. func (cfg InternalHTTPConfig) Validate() error { switch { case strings.TrimSpace(cfg.Addr) == "": return fmt.Errorf("internal HTTP addr must not be empty") case cfg.ReadHeaderTimeout <= 0: return fmt.Errorf("internal HTTP read header timeout must be positive") case cfg.ReadTimeout <= 0: return fmt.Errorf("internal HTTP read timeout must be positive") case cfg.IdleTimeout <= 0: return fmt.Errorf("internal HTTP idle timeout must be positive") case cfg.RequestTimeout <= 0: return fmt.Errorf("internal HTTP request timeout must be positive") default: return nil } } // AdminHTTPConfig describes the private operational HTTP listener used for // Prometheus metrics exposure. The listener remains disabled when Addr is // empty. type AdminHTTPConfig struct { // Addr stores the TCP listen address used by the admin HTTP server. Addr string // ReadHeaderTimeout bounds request-header reading. ReadHeaderTimeout time.Duration // ReadTimeout bounds reading one request. ReadTimeout time.Duration // IdleTimeout bounds how long keep-alive connections stay open. IdleTimeout time.Duration } // Validate reports whether cfg stores a usable optional admin HTTP listener // configuration. func (cfg AdminHTTPConfig) Validate() error { if strings.TrimSpace(cfg.Addr) == "" { return nil } switch { case cfg.ReadHeaderTimeout <= 0: return fmt.Errorf("admin HTTP read header timeout must be positive") case cfg.ReadTimeout <= 0: return fmt.Errorf("admin HTTP read timeout must be positive") case cfg.IdleTimeout <= 0: return fmt.Errorf("admin HTTP idle timeout must be positive") default: return nil } } // RedisConfig configures the Redis-backed store and domain-event publisher. type RedisConfig struct { // Addr stores the Redis network address. Addr string // Username stores the optional Redis ACL username. Username string // Password stores the optional Redis ACL password. Password string // DB stores the Redis logical database index. DB int // TLSEnabled reports whether TLS must be used for Redis connections. TLSEnabled bool // OperationTimeout bounds one Redis round trip. OperationTimeout time.Duration // KeyspacePrefix stores the root prefix of the service-owned Redis keyspace. KeyspacePrefix string // DomainEventsStream stores the Redis Stream key used for auxiliary // post-commit domain events. DomainEventsStream string // DomainEventsStreamMaxLen bounds the domain-events Redis Stream with // approximate trimming. DomainEventsStreamMaxLen int64 } // TLSConfig returns the conservative TLS configuration used by Redis adapters // when TLSEnabled is true. func (cfg RedisConfig) TLSConfig() *tls.Config { if !cfg.TLSEnabled { return nil } return &tls.Config{MinVersion: tls.VersionTLS12} } // Validate reports whether cfg stores a usable Redis configuration. func (cfg RedisConfig) Validate() error { switch { case strings.TrimSpace(cfg.Addr) == "": return fmt.Errorf("redis addr must not be empty") case cfg.DB < 0: return fmt.Errorf("redis db must not be negative") case cfg.OperationTimeout <= 0: return fmt.Errorf("redis operation timeout must be positive") case strings.TrimSpace(cfg.KeyspacePrefix) == "": return fmt.Errorf("redis keyspace prefix must not be empty") case strings.TrimSpace(cfg.DomainEventsStream) == "": return fmt.Errorf("redis domain events stream must not be empty") case cfg.DomainEventsStreamMaxLen <= 0: return fmt.Errorf("redis domain events stream max len must be positive") default: return nil } } // TelemetryConfig configures the user-service OpenTelemetry runtime. type TelemetryConfig struct { // ServiceName overrides the default OpenTelemetry service name. ServiceName string // TracesExporter selects the external traces exporter. Supported values are // `none` and `otlp`. TracesExporter string // MetricsExporter selects the external metrics exporter. Supported values // are `none` and `otlp`. MetricsExporter string // TracesProtocol selects the OTLP traces protocol when TracesExporter is // `otlp`. TracesProtocol string // MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is // `otlp`. MetricsProtocol string // StdoutTracesEnabled enables the additional stdout trace exporter used for // local development and debugging. StdoutTracesEnabled bool // StdoutMetricsEnabled enables the additional stdout metric exporter used // for local development and debugging. StdoutMetricsEnabled bool } // Validate reports whether cfg contains a supported OpenTelemetry exporter // configuration. func (cfg TelemetryConfig) Validate() error { switch cfg.TracesExporter { case otelExporterNone, otelExporterOTLP: default: return fmt.Errorf("%s %q is unsupported", otelTracesExporterEnvVar, cfg.TracesExporter) } switch cfg.MetricsExporter { case otelExporterNone, otelExporterOTLP: default: return fmt.Errorf("%s %q is unsupported", otelMetricsExporterEnvVar, cfg.MetricsExporter) } if cfg.TracesProtocol != "" && cfg.TracesProtocol != otelProtocolHTTPProtobuf && cfg.TracesProtocol != otelProtocolGRPC { return fmt.Errorf("%s %q is unsupported", otelExporterOTLPTracesProtocolEnvVar, cfg.TracesProtocol) } if cfg.MetricsProtocol != "" && cfg.MetricsProtocol != otelProtocolHTTPProtobuf && cfg.MetricsProtocol != otelProtocolGRPC { return fmt.Errorf("%s %q is unsupported", otelExporterOTLPMetricsProtocolEnvVar, cfg.MetricsProtocol) } return nil } // DefaultAdminHTTPConfig returns the default settings for the optional private // admin HTTP listener. func DefaultAdminHTTPConfig() AdminHTTPConfig { return AdminHTTPConfig{ Addr: defaultAdminHTTPAddr, ReadHeaderTimeout: defaultReadHeaderTimeout, ReadTimeout: defaultReadTimeout, IdleTimeout: defaultIdleTimeout, } } // DefaultConfig returns the default process configuration with all optional // values filled. func DefaultConfig() Config { return Config{ ShutdownTimeout: defaultShutdownTimeout, Logging: LoggingConfig{ Level: defaultLogLevel, }, InternalHTTP: InternalHTTPConfig{ Addr: defaultInternalHTTPAddr, ReadHeaderTimeout: defaultReadHeaderTimeout, ReadTimeout: defaultReadTimeout, IdleTimeout: defaultIdleTimeout, RequestTimeout: defaultRequestTimeout, }, AdminHTTP: DefaultAdminHTTPConfig(), Redis: RedisConfig{ DB: defaultRedisDB, OperationTimeout: defaultRedisOperationTimeout, KeyspacePrefix: defaultRedisKeyspacePrefix, DomainEventsStream: defaultDomainEventsStream, DomainEventsStreamMaxLen: defaultDomainEventsStreamMaxLen, }, Telemetry: TelemetryConfig{ ServiceName: defaultOTelServiceName, TracesExporter: otelExporterNone, MetricsExporter: otelExporterNone, }, } } // Validate reports whether cfg is process-ready. func (cfg Config) Validate() error { switch { case cfg.ShutdownTimeout <= 0: return fmt.Errorf("shutdown timeout must be positive") } if err := cfg.InternalHTTP.Validate(); err != nil { return fmt.Errorf("internal HTTP config: %w", err) } if err := cfg.AdminHTTP.Validate(); err != nil { return fmt.Errorf("admin HTTP config: %w", err) } if err := cfg.Redis.Validate(); err != nil { return fmt.Errorf("redis config: %w", err) } if _, err := parseLogLevel(cfg.Logging.Level); err != nil { return fmt.Errorf("logging config: %w", err) } if err := cfg.Telemetry.Validate(); err != nil { return fmt.Errorf("telemetry config: %w", err) } return nil } // LoadFromEnv loads Config from the process environment. func LoadFromEnv() (Config, error) { cfg := DefaultConfig() var err error cfg.ShutdownTimeout, err = loadDuration(shutdownTimeoutEnvVar, cfg.ShutdownTimeout) if err != nil { return Config{}, err } cfg.Logging.Level = loadString(logLevelEnvVar, cfg.Logging.Level) cfg.InternalHTTP.Addr = loadString(internalHTTPAddrEnvVar, cfg.InternalHTTP.Addr) cfg.InternalHTTP.ReadHeaderTimeout, err = loadDuration(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout) if err != nil { return Config{}, err } cfg.InternalHTTP.ReadTimeout, err = loadDuration(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout) if err != nil { return Config{}, err } cfg.InternalHTTP.IdleTimeout, err = loadDuration(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout) if err != nil { return Config{}, err } cfg.InternalHTTP.RequestTimeout, err = loadDuration(internalHTTPRequestTimeoutEnvVar, cfg.InternalHTTP.RequestTimeout) if err != nil { return Config{}, err } cfg.AdminHTTP.Addr = loadString(adminHTTPAddrEnvVar, cfg.AdminHTTP.Addr) cfg.AdminHTTP.ReadHeaderTimeout, err = loadDuration(adminHTTPReadHeaderTimeoutEnvVar, cfg.AdminHTTP.ReadHeaderTimeout) if err != nil { return Config{}, err } cfg.AdminHTTP.ReadTimeout, err = loadDuration(adminHTTPReadTimeoutEnvVar, cfg.AdminHTTP.ReadTimeout) if err != nil { return Config{}, err } cfg.AdminHTTP.IdleTimeout, err = loadDuration(adminHTTPIdleTimeoutEnvVar, cfg.AdminHTTP.IdleTimeout) if err != nil { return Config{}, err } cfg.Redis.Addr = loadString(redisAddrEnvVar, cfg.Redis.Addr) cfg.Redis.Username = loadString(redisUsernameEnvVar, cfg.Redis.Username) cfg.Redis.Password = loadString(redisPasswordEnvVar, cfg.Redis.Password) cfg.Redis.DB, err = loadInt(redisDBEnvVar, cfg.Redis.DB) if err != nil { return Config{}, err } cfg.Redis.TLSEnabled, err = loadBool(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled) if err != nil { return Config{}, err } cfg.Redis.OperationTimeout, err = loadDuration(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout) if err != nil { return Config{}, err } cfg.Redis.KeyspacePrefix = loadString(redisKeyspacePrefixEnvVar, cfg.Redis.KeyspacePrefix) cfg.Redis.DomainEventsStream = loadString(redisDomainEventsStreamEnvVar, cfg.Redis.DomainEventsStream) cfg.Redis.DomainEventsStreamMaxLen, err = loadInt64(redisDomainEventsStreamMaxLenEnvVar, cfg.Redis.DomainEventsStreamMaxLen) if err != nil { return Config{}, err } cfg.Telemetry.ServiceName = loadString(otelServiceNameEnvVar, cfg.Telemetry.ServiceName) cfg.Telemetry.TracesExporter = normalizeExporterValue(loadString(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter)) cfg.Telemetry.MetricsExporter = normalizeExporterValue(loadString(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 = loadBool(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled) if err != nil { return Config{}, err } cfg.Telemetry.StdoutMetricsEnabled, err = loadBool(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled) if err != nil { return Config{}, err } if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } func loadString(envName string, defaultValue string) string { value, ok := os.LookupEnv(envName) if !ok { return defaultValue } return strings.TrimSpace(value) } func loadDuration(envName string, defaultValue time.Duration) (time.Duration, error) { value, ok := os.LookupEnv(envName) if !ok { return defaultValue, nil } duration, err := time.ParseDuration(strings.TrimSpace(value)) if err != nil { return 0, fmt.Errorf("%s: parse duration: %w", envName, err) } return duration, nil } func loadInt(envName string, defaultValue int) (int, error) { value, ok := os.LookupEnv(envName) if !ok { return defaultValue, nil } parsedValue, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil { return 0, fmt.Errorf("%s: parse int: %w", envName, err) } return parsedValue, nil } func loadInt64(envName string, defaultValue int64) (int64, error) { value, ok := os.LookupEnv(envName) if !ok { return defaultValue, nil } parsedValue, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err != nil { return 0, fmt.Errorf("%s: parse int64: %w", envName, err) } return parsedValue, nil } func loadBool(envName string, defaultValue bool) (bool, error) { value, ok := os.LookupEnv(envName) if !ok { return defaultValue, nil } parsedValue, err := strconv.ParseBool(strings.TrimSpace(value)) if err != nil { return false, fmt.Errorf("%s: parse bool: %w", envName, err) } return parsedValue, nil } func parseLogLevel(value string) (string, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "debug", "info", "warn", "error": return value, nil default: return "", fmt.Errorf("unsupported log level %q", 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 } // ListenAddress returns the resolved listen address used by tests and process // startup. func (cfg InternalHTTPConfig) ListenAddress() string { if strings.HasPrefix(cfg.Addr, ":") { return net.JoinHostPort("", strings.TrimPrefix(cfg.Addr, ":")) } return cfg.Addr }