feat: use postgres
This commit is contained in:
@@ -3,21 +3,21 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
netmail "net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/telemetry"
|
||||
"galaxy/postgres"
|
||||
"galaxy/redisconn"
|
||||
)
|
||||
|
||||
const (
|
||||
envPrefix = "NOTIFICATION"
|
||||
|
||||
shutdownTimeoutEnvVar = "NOTIFICATION_SHUTDOWN_TIMEOUT"
|
||||
logLevelEnvVar = "NOTIFICATION_LOG_LEVEL"
|
||||
|
||||
@@ -26,28 +26,23 @@ const (
|
||||
internalHTTPReadTimeoutEnvVar = "NOTIFICATION_INTERNAL_HTTP_READ_TIMEOUT"
|
||||
internalHTTPIdleTimeoutEnvVar = "NOTIFICATION_INTERNAL_HTTP_IDLE_TIMEOUT"
|
||||
|
||||
redisAddrEnvVar = "NOTIFICATION_REDIS_ADDR"
|
||||
redisUsernameEnvVar = "NOTIFICATION_REDIS_USERNAME"
|
||||
redisPasswordEnvVar = "NOTIFICATION_REDIS_PASSWORD"
|
||||
redisDBEnvVar = "NOTIFICATION_REDIS_DB"
|
||||
redisTLSEnabledEnvVar = "NOTIFICATION_REDIS_TLS_ENABLED"
|
||||
redisOperationTimeoutEnvVar = "NOTIFICATION_REDIS_OPERATION_TIMEOUT"
|
||||
|
||||
intentsStreamEnvVar = "NOTIFICATION_INTENTS_STREAM"
|
||||
intentsReadBlockTimeoutEnvVar = "NOTIFICATION_INTENTS_READ_BLOCK_TIMEOUT"
|
||||
gatewayClientEventsStreamEnvVar = "NOTIFICATION_GATEWAY_CLIENT_EVENTS_STREAM"
|
||||
intentsStreamEnvVar = "NOTIFICATION_INTENTS_STREAM"
|
||||
intentsReadBlockTimeoutEnvVar = "NOTIFICATION_INTENTS_READ_BLOCK_TIMEOUT"
|
||||
gatewayClientEventsStreamEnvVar = "NOTIFICATION_GATEWAY_CLIENT_EVENTS_STREAM"
|
||||
gatewayClientEventsStreamMaxEnvVar = "NOTIFICATION_GATEWAY_CLIENT_EVENTS_STREAM_MAX_LEN"
|
||||
mailDeliveryCommandsStreamEnvVar = "NOTIFICATION_MAIL_DELIVERY_COMMANDS_STREAM"
|
||||
mailDeliveryCommandsStreamEnvVar = "NOTIFICATION_MAIL_DELIVERY_COMMANDS_STREAM"
|
||||
|
||||
pushRetryMaxAttemptsEnvVar = "NOTIFICATION_PUSH_RETRY_MAX_ATTEMPTS"
|
||||
emailRetryMaxAttemptsEnvVar = "NOTIFICATION_EMAIL_RETRY_MAX_ATTEMPTS"
|
||||
routeLeaseTTLEnvVar = "NOTIFICATION_ROUTE_LEASE_TTL"
|
||||
routeBackoffMinEnvVar = "NOTIFICATION_ROUTE_BACKOFF_MIN"
|
||||
routeBackoffMaxEnvVar = "NOTIFICATION_ROUTE_BACKOFF_MAX"
|
||||
deadLetterTTLEnvVar = "NOTIFICATION_DEAD_LETTER_TTL"
|
||||
recordTTLEnvVar = "NOTIFICATION_RECORD_TTL"
|
||||
idempotencyTTLEnvVar = "NOTIFICATION_IDEMPOTENCY_TTL"
|
||||
|
||||
recordRetentionEnvVar = "NOTIFICATION_RECORD_RETENTION"
|
||||
malformedIntentRetentionEnvVar = "NOTIFICATION_MALFORMED_INTENT_RETENTION"
|
||||
cleanupIntervalEnvVar = "NOTIFICATION_CLEANUP_INTERVAL"
|
||||
|
||||
userServiceBaseURLEnvVar = "NOTIFICATION_USER_SERVICE_BASE_URL"
|
||||
userServiceTimeoutEnvVar = "NOTIFICATION_USER_SERVICE_TIMEOUT"
|
||||
|
||||
@@ -71,24 +66,24 @@ const (
|
||||
defaultReadHeaderTimeout = 2 * time.Second
|
||||
defaultReadTimeout = 10 * time.Second
|
||||
defaultIdleTimeout = time.Minute
|
||||
defaultRedisDB = 0
|
||||
defaultRedisOperationTimeout = 250 * time.Millisecond
|
||||
|
||||
defaultIntentsStream = "notification:intents"
|
||||
defaultIntentsReadBlockTimeout = 2 * time.Second
|
||||
defaultGatewayClientEventsStream = "gateway:client-events"
|
||||
defaultIntentsStream = "notification:intents"
|
||||
defaultIntentsReadBlockTimeout = 2 * time.Second
|
||||
defaultGatewayClientEventsStream = "gateway:client-events"
|
||||
defaultGatewayClientEventsStreamMaxLen int64 = 1024
|
||||
defaultMailDeliveryCommandsStream = "mail:delivery_commands"
|
||||
defaultMailDeliveryCommandsStream = "mail:delivery_commands"
|
||||
|
||||
defaultPushRetryMaxAttempts = 3
|
||||
defaultEmailRetryMaxAttempts = 7
|
||||
defaultRouteLeaseTTL = 5 * time.Second
|
||||
defaultRouteBackoffMin = time.Second
|
||||
defaultRouteBackoffMax = 5 * time.Minute
|
||||
defaultDeadLetterTTL = 720 * time.Hour
|
||||
defaultRecordTTL = 720 * time.Hour
|
||||
defaultIdempotencyTTL = 168 * time.Hour
|
||||
|
||||
defaultRecordRetention = 30 * 24 * time.Hour
|
||||
defaultMalformedIntentRetention = 90 * 24 * time.Hour
|
||||
defaultCleanupInterval = time.Hour
|
||||
|
||||
defaultUserServiceTimeout = time.Second
|
||||
defaultOTelServiceName = "galaxy-notification"
|
||||
|
||||
@@ -109,20 +104,29 @@ type Config struct {
|
||||
// InternalHTTP configures the private probe HTTP listener.
|
||||
InternalHTTP InternalHTTPConfig
|
||||
|
||||
// Redis configures the shared Redis client used by the process.
|
||||
// Redis configures the shared Redis connection topology and the inbound
|
||||
// `notification:intents` stream plus the outbound stream names. Durable
|
||||
// notification state lives in PostgreSQL after Stage 5 of `PG_PLAN.md`.
|
||||
Redis RedisConfig
|
||||
|
||||
// Streams stores the stable stream names reserved for notification ingress
|
||||
// and downstream publication.
|
||||
// Postgres configures the PostgreSQL-backed durable store consumed via
|
||||
// `pkg/postgres`.
|
||||
Postgres PostgresConfig
|
||||
|
||||
// Streams stores the stable Redis Stream names reserved for ingress and
|
||||
// downstream publication.
|
||||
Streams StreamsConfig
|
||||
|
||||
// IntentsReadBlockTimeout stores the maximum Redis Streams blocking read
|
||||
// window used by the intent consumer.
|
||||
IntentsReadBlockTimeout time.Duration
|
||||
|
||||
// Retry stores the frozen retry and retention settings.
|
||||
// Retry stores the frozen retry settings used by the route publishers.
|
||||
Retry RetryConfig
|
||||
|
||||
// Retention stores the periodic SQL retention worker configuration.
|
||||
Retention RetentionConfig
|
||||
|
||||
// UserService configures the trusted user-enrichment dependency.
|
||||
UserService UserServiceConfig
|
||||
|
||||
@@ -174,51 +178,29 @@ func (cfg InternalHTTPConfig) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// RedisConfig configures the shared Redis client and its connection settings.
|
||||
// RedisConfig configures the Notification Service Redis connection topology.
|
||||
// Per-call timeouts live in `Conn.OperationTimeout`.
|
||||
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 including the startup PING.
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// TLSConfig returns the conservative TLS configuration used by the Redis
|
||||
// client when TLSEnabled is true.
|
||||
func (cfg RedisConfig) TLSConfig() *tls.Config {
|
||||
if !cfg.TLSEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
// Conn carries the connection topology (master, replicas, password, db,
|
||||
// per-call timeout). Loaded via redisconn.LoadFromEnv("NOTIFICATION").
|
||||
Conn redisconn.Config
|
||||
}
|
||||
|
||||
// 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 !isTCPAddr(cfg.Addr):
|
||||
return fmt.Errorf("redis addr %q must use host:port form", cfg.Addr)
|
||||
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")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return cfg.Conn.Validate()
|
||||
}
|
||||
|
||||
// PostgresConfig configures the PostgreSQL-backed durable store.
|
||||
type PostgresConfig struct {
|
||||
// Conn stores the primary plus replica DSN topology and pool tuning.
|
||||
// Loaded via postgres.LoadFromEnv("NOTIFICATION").
|
||||
Conn postgres.Config
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable PostgreSQL configuration.
|
||||
func (cfg PostgresConfig) Validate() error {
|
||||
return cfg.Conn.Validate()
|
||||
}
|
||||
|
||||
// StreamsConfig stores the stable Redis Stream names used by Notification
|
||||
@@ -254,8 +236,8 @@ func (cfg StreamsConfig) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// RetryConfig stores the frozen retry budgets, backoff settings, and retention
|
||||
// periods used by the service.
|
||||
// RetryConfig stores the frozen retry budgets, backoff settings, and the
|
||||
// per-acceptance idempotency window.
|
||||
type RetryConfig struct {
|
||||
// PushMaxAttempts stores the route retry budget for the `push` channel.
|
||||
PushMaxAttempts int
|
||||
@@ -273,18 +255,13 @@ type RetryConfig struct {
|
||||
// RouteBackoffMax stores the maximum retry backoff.
|
||||
RouteBackoffMax time.Duration
|
||||
|
||||
// DeadLetterTTL stores the retention period for dead-letter and malformed
|
||||
// intent records.
|
||||
DeadLetterTTL time.Duration
|
||||
|
||||
// RecordTTL stores the retention period for notification and route records.
|
||||
RecordTTL time.Duration
|
||||
|
||||
// IdempotencyTTL stores the retention period for idempotency records.
|
||||
// IdempotencyTTL stores the per-acceptance idempotency window the service
|
||||
// layer applies to the durable `idempotency_expires_at` column on the
|
||||
// `records` table.
|
||||
IdempotencyTTL time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores usable retry and retention settings.
|
||||
// Validate reports whether cfg stores usable retry settings.
|
||||
func (cfg RetryConfig) Validate() error {
|
||||
switch {
|
||||
case cfg.PushMaxAttempts <= 0:
|
||||
@@ -299,10 +276,6 @@ func (cfg RetryConfig) Validate() error {
|
||||
return fmt.Errorf("route backoff max must be positive")
|
||||
case cfg.RouteBackoffMin > cfg.RouteBackoffMax:
|
||||
return fmt.Errorf("route backoff min must not exceed route backoff max")
|
||||
case cfg.DeadLetterTTL <= 0:
|
||||
return fmt.Errorf("dead-letter ttl must be positive")
|
||||
case cfg.RecordTTL <= 0:
|
||||
return fmt.Errorf("record ttl must be positive")
|
||||
case cfg.IdempotencyTTL <= 0:
|
||||
return fmt.Errorf("idempotency ttl must be positive")
|
||||
default:
|
||||
@@ -310,6 +283,36 @@ func (cfg RetryConfig) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// RetentionConfig stores the durable retention windows applied by the
|
||||
// periodic SQL retention worker.
|
||||
type RetentionConfig struct {
|
||||
// RecordRetention bounds how long records (and their cascaded routes and
|
||||
// dead_letters) survive after acceptance.
|
||||
RecordRetention time.Duration
|
||||
|
||||
// MalformedIntentRetention bounds how long malformed-intent rows survive
|
||||
// after their original `recorded_at`.
|
||||
MalformedIntentRetention time.Duration
|
||||
|
||||
// CleanupInterval stores the wall-clock period between two retention
|
||||
// passes.
|
||||
CleanupInterval time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable retention configuration.
|
||||
func (cfg RetentionConfig) Validate() error {
|
||||
switch {
|
||||
case cfg.RecordRetention <= 0:
|
||||
return fmt.Errorf("%s must be positive", recordRetentionEnvVar)
|
||||
case cfg.MalformedIntentRetention <= 0:
|
||||
return fmt.Errorf("%s must be positive", malformedIntentRetentionEnvVar)
|
||||
case cfg.CleanupInterval <= 0:
|
||||
return fmt.Errorf("%s must be positive", cleanupIntervalEnvVar)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UserServiceConfig configures the trusted user-enrichment dependency.
|
||||
type UserServiceConfig struct {
|
||||
// BaseURL stores the absolute base URL of the trusted User Service.
|
||||
@@ -336,12 +339,10 @@ func (cfg UserServiceConfig) Validate() error {
|
||||
// AdminRoutingConfig stores the type-specific configured administrator email
|
||||
// lists.
|
||||
type AdminRoutingConfig struct {
|
||||
// GeoReviewRecommended stores recipients for
|
||||
// `geo.review_recommended`.
|
||||
// GeoReviewRecommended stores recipients for `geo.review_recommended`.
|
||||
GeoReviewRecommended []string
|
||||
|
||||
// GameGenerationFailed stores recipients for
|
||||
// `game.generation_failed`.
|
||||
// GameGenerationFailed stores recipients for `game.generation_failed`.
|
||||
GameGenerationFailed []string
|
||||
|
||||
// LobbyRuntimePausedAfterStart stores recipients for
|
||||
@@ -431,14 +432,16 @@ func DefaultConfig() Config {
|
||||
IdleTimeout: defaultIdleTimeout,
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
DB: defaultRedisDB,
|
||||
OperationTimeout: defaultRedisOperationTimeout,
|
||||
Conn: redisconn.DefaultConfig(),
|
||||
},
|
||||
Postgres: PostgresConfig{
|
||||
Conn: postgres.DefaultConfig(),
|
||||
},
|
||||
Streams: StreamsConfig{
|
||||
Intents: defaultIntentsStream,
|
||||
GatewayClientEvents: defaultGatewayClientEventsStream,
|
||||
Intents: defaultIntentsStream,
|
||||
GatewayClientEvents: defaultGatewayClientEventsStream,
|
||||
GatewayClientEventsStreamMaxLen: defaultGatewayClientEventsStreamMaxLen,
|
||||
MailDeliveryCommands: defaultMailDeliveryCommandsStream,
|
||||
MailDeliveryCommands: defaultMailDeliveryCommandsStream,
|
||||
},
|
||||
IntentsReadBlockTimeout: defaultIntentsReadBlockTimeout,
|
||||
Retry: RetryConfig{
|
||||
@@ -447,10 +450,13 @@ func DefaultConfig() Config {
|
||||
RouteLeaseTTL: defaultRouteLeaseTTL,
|
||||
RouteBackoffMin: defaultRouteBackoffMin,
|
||||
RouteBackoffMax: defaultRouteBackoffMax,
|
||||
DeadLetterTTL: defaultDeadLetterTTL,
|
||||
RecordTTL: defaultRecordTTL,
|
||||
IdempotencyTTL: defaultIdempotencyTTL,
|
||||
},
|
||||
Retention: RetentionConfig{
|
||||
RecordRetention: defaultRecordRetention,
|
||||
MalformedIntentRetention: defaultMalformedIntentRetention,
|
||||
CleanupInterval: defaultCleanupInterval,
|
||||
},
|
||||
UserService: UserServiceConfig{
|
||||
Timeout: defaultUserServiceTimeout,
|
||||
},
|
||||
@@ -462,167 +468,21 @@ func DefaultConfig() Config {
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFromEnv loads the Notification Service process configuration from
|
||||
// environment variables, applying documented defaults where appropriate.
|
||||
func LoadFromEnv() (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
var err error
|
||||
|
||||
cfg.ShutdownTimeout, err = loadDurationEnvWithDefault(shutdownTimeoutEnvVar, cfg.ShutdownTimeout)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
|
||||
cfg.Logging.Level = loadStringEnvWithDefault(logLevelEnvVar, cfg.Logging.Level)
|
||||
if err := validateLogLevel(cfg.Logging.Level); err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %s: %w", logLevelEnvVar, err)
|
||||
}
|
||||
|
||||
cfg.InternalHTTP.Addr = loadStringEnvWithDefault(internalHTTPAddrEnvVar, cfg.InternalHTTP.Addr)
|
||||
cfg.InternalHTTP.ReadHeaderTimeout, err = loadDurationEnvWithDefault(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.InternalHTTP.ReadTimeout, err = loadDurationEnvWithDefault(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.InternalHTTP.IdleTimeout, err = loadDurationEnvWithDefault(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
|
||||
cfg.Redis.Addr = loadStringEnvWithDefault(redisAddrEnvVar, cfg.Redis.Addr)
|
||||
cfg.Redis.Username = os.Getenv(redisUsernameEnvVar)
|
||||
cfg.Redis.Password = os.Getenv(redisPasswordEnvVar)
|
||||
cfg.Redis.DB, err = loadIntEnvWithDefault(redisDBEnvVar, cfg.Redis.DB)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.Redis.TLSEnabled, err = loadBoolEnvWithDefault(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.Redis.OperationTimeout, err = loadDurationEnvWithDefault(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
|
||||
cfg.Streams.Intents = loadStringEnvWithDefault(intentsStreamEnvVar, cfg.Streams.Intents)
|
||||
cfg.Streams.GatewayClientEvents = loadStringEnvWithDefault(gatewayClientEventsStreamEnvVar, cfg.Streams.GatewayClientEvents)
|
||||
cfg.Streams.GatewayClientEventsStreamMaxLen, err = loadInt64EnvWithDefault(gatewayClientEventsStreamMaxEnvVar, cfg.Streams.GatewayClientEventsStreamMaxLen)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.Streams.MailDeliveryCommands = loadStringEnvWithDefault(mailDeliveryCommandsStreamEnvVar, cfg.Streams.MailDeliveryCommands)
|
||||
cfg.IntentsReadBlockTimeout, err = loadDurationEnvWithDefault(intentsReadBlockTimeoutEnvVar, cfg.IntentsReadBlockTimeout)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
|
||||
cfg.Retry.PushMaxAttempts, err = loadIntEnvWithDefault(pushRetryMaxAttemptsEnvVar, cfg.Retry.PushMaxAttempts)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.Retry.EmailMaxAttempts, err = loadIntEnvWithDefault(emailRetryMaxAttemptsEnvVar, cfg.Retry.EmailMaxAttempts)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.Retry.RouteLeaseTTL, err = loadDurationEnvWithDefault(routeLeaseTTLEnvVar, cfg.Retry.RouteLeaseTTL)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.Retry.RouteBackoffMin, err = loadDurationEnvWithDefault(routeBackoffMinEnvVar, cfg.Retry.RouteBackoffMin)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.Retry.RouteBackoffMax, err = loadDurationEnvWithDefault(routeBackoffMaxEnvVar, cfg.Retry.RouteBackoffMax)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.Retry.DeadLetterTTL, err = loadDurationEnvWithDefault(deadLetterTTLEnvVar, cfg.Retry.DeadLetterTTL)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.Retry.RecordTTL, err = loadDurationEnvWithDefault(recordTTLEnvVar, cfg.Retry.RecordTTL)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.Retry.IdempotencyTTL, err = loadDurationEnvWithDefault(idempotencyTTLEnvVar, cfg.Retry.IdempotencyTTL)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
|
||||
cfg.UserService.BaseURL = normalizeBaseURL(loadStringEnvWithDefault(userServiceBaseURLEnvVar, cfg.UserService.BaseURL))
|
||||
cfg.UserService.Timeout, err = loadDurationEnvWithDefault(userServiceTimeoutEnvVar, cfg.UserService.Timeout)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
|
||||
cfg.AdminRouting.GeoReviewRecommended, err = loadEmailListEnv(adminEmailsGeoReviewRecommendedEnvVar, cfg.AdminRouting.GeoReviewRecommended)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.AdminRouting.GameGenerationFailed, err = loadEmailListEnv(adminEmailsGameGenerationFailedEnvVar, cfg.AdminRouting.GameGenerationFailed)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.AdminRouting.LobbyRuntimePausedAfterStart, err = loadEmailListEnv(adminEmailsLobbyRuntimePausedAfterEnvVar, cfg.AdminRouting.LobbyRuntimePausedAfterStart)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.AdminRouting.LobbyApplicationSubmitted, err = loadEmailListEnv(adminEmailsLobbyApplicationSubmittedEnvVar, cfg.AdminRouting.LobbyApplicationSubmitted)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
|
||||
cfg.Telemetry.ServiceName = loadStringEnvWithDefault(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
|
||||
cfg.Telemetry.TracesExporter = normalizeExporterValue(loadStringEnvWithDefault(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
|
||||
cfg.Telemetry.MetricsExporter = normalizeExporterValue(loadStringEnvWithDefault(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 = loadBoolEnvWithDefault(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
cfg.Telemetry.StdoutMetricsEnabled, err = loadBoolEnvWithDefault(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a consistent Notification Service
|
||||
// process configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.ShutdownTimeout <= 0:
|
||||
if cfg.ShutdownTimeout <= 0 {
|
||||
return fmt.Errorf("load notification config: %s must be positive", shutdownTimeoutEnvVar)
|
||||
case strings.TrimSpace(cfg.Redis.Addr) == "":
|
||||
return fmt.Errorf("load notification config: %s must not be empty", redisAddrEnvVar)
|
||||
case strings.TrimSpace(cfg.UserService.BaseURL) == "":
|
||||
return fmt.Errorf("load notification config: %s must not be empty", userServiceBaseURLEnvVar)
|
||||
}
|
||||
|
||||
if err := cfg.InternalHTTP.Validate(); err != nil {
|
||||
return fmt.Errorf("load notification config: %s", err)
|
||||
}
|
||||
if err := cfg.Redis.Validate(); err != nil {
|
||||
return fmt.Errorf("load notification config: %s", err)
|
||||
return fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
if err := cfg.Postgres.Validate(); err != nil {
|
||||
return fmt.Errorf("load notification config: %w", err)
|
||||
}
|
||||
if err := cfg.Streams.Validate(); err != nil {
|
||||
return fmt.Errorf("load notification config: %s", err)
|
||||
@@ -633,6 +493,9 @@ func (cfg Config) Validate() error {
|
||||
if err := cfg.Retry.Validate(); err != nil {
|
||||
return fmt.Errorf("load notification config: %s", err)
|
||||
}
|
||||
if err := cfg.Retention.Validate(); err != nil {
|
||||
return fmt.Errorf("load notification config: %s", err)
|
||||
}
|
||||
if err := cfg.UserService.Validate(); err != nil {
|
||||
return fmt.Errorf("load notification config: %s", err)
|
||||
}
|
||||
@@ -646,77 +509,35 @@ func (cfg Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStringEnvWithDefault(name string, value string) string {
|
||||
if raw, ok := os.LookupEnv(name); ok {
|
||||
return strings.TrimSpace(raw)
|
||||
func validateNormalizedEmailList(name string, values []string) error {
|
||||
for index, value := range values {
|
||||
normalized, err := normalizeMailboxAddress(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s[%d]: %w", name, index, err)
|
||||
}
|
||||
if normalized != value {
|
||||
return fmt.Errorf("%s[%d]: email address must already be normalized", name, index)
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadDurationEnvWithDefault(name string, value time.Duration) (time.Duration, error) {
|
||||
raw, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return value, nil
|
||||
func normalizeMailboxAddress(value string) (string, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("email address must not be empty")
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(raw))
|
||||
parsed, err := netmail.ParseAddress(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", name, err)
|
||||
return "", fmt.Errorf("invalid email address %q: %w", trimmed, err)
|
||||
}
|
||||
if parsed.Name != "" {
|
||||
return "", fmt.Errorf("email address %q must not include a display name", trimmed)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func loadIntEnvWithDefault(name string, value int) (int, error) {
|
||||
raw, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func loadInt64EnvWithDefault(name string, value int64) (int64, error) {
|
||||
raw, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func loadBoolEnvWithDefault(name string, value bool) (bool, error) {
|
||||
raw, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func loadEmailListEnv(name string, value []string) ([]string, error) {
|
||||
raw, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return append([]string(nil), value...), nil
|
||||
}
|
||||
|
||||
return parseEmailList(name, raw)
|
||||
return strings.ToLower(parsed.Address), nil
|
||||
}
|
||||
|
||||
func parseEmailList(name string, raw string) ([]string, error) {
|
||||
@@ -743,63 +564,6 @@ func parseEmailList(name string, raw string) ([]string, error) {
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
func normalizeMailboxAddress(value string) (string, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("email address must not be empty")
|
||||
}
|
||||
|
||||
parsed, err := netmail.ParseAddress(trimmed)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid email address %q: %w", trimmed, err)
|
||||
}
|
||||
if parsed.Name != "" {
|
||||
return "", fmt.Errorf("email address %q must not include a display name", trimmed)
|
||||
}
|
||||
|
||||
return strings.ToLower(parsed.Address), nil
|
||||
}
|
||||
|
||||
func validateNormalizedEmailList(name string, values []string) error {
|
||||
for index, value := range values {
|
||||
normalized, err := normalizeMailboxAddress(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s[%d]: %w", name, index, err)
|
||||
}
|
||||
if normalized != value {
|
||||
return fmt.Errorf("%s[%d]: email address must already be normalized", name, index)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func normalizeBaseURL(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -4,12 +4,42 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/postgres"
|
||||
"galaxy/redisconn"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
const (
|
||||
envRedisMasterAddr = "NOTIFICATION_REDIS_MASTER_ADDR"
|
||||
envRedisReplicaAddrs = "NOTIFICATION_REDIS_REPLICA_ADDRS"
|
||||
envRedisPassword = "NOTIFICATION_REDIS_PASSWORD"
|
||||
envRedisDB = "NOTIFICATION_REDIS_DB"
|
||||
envRedisOpTimeout = "NOTIFICATION_REDIS_OPERATION_TIMEOUT"
|
||||
envRedisTLSEnabled = "NOTIFICATION_REDIS_TLS_ENABLED"
|
||||
envRedisUsername = "NOTIFICATION_REDIS_USERNAME"
|
||||
|
||||
envPostgresPrimaryDSN = "NOTIFICATION_POSTGRES_PRIMARY_DSN"
|
||||
envPostgresOpTimeout = "NOTIFICATION_POSTGRES_OPERATION_TIMEOUT"
|
||||
envPostgresMaxOpenConns = "NOTIFICATION_POSTGRES_MAX_OPEN_CONNS"
|
||||
envPostgresMaxIdleConns = "NOTIFICATION_POSTGRES_MAX_IDLE_CONNS"
|
||||
envPostgresConnMaxLife = "NOTIFICATION_POSTGRES_CONN_MAX_LIFETIME"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPrimaryDSN = "postgres://notificationservice:notificationservice@127.0.0.1:5432/galaxy?search_path=notification&sslmode=disable"
|
||||
)
|
||||
|
||||
func setRequiredConnEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv(envRedisMasterAddr, "127.0.0.1:6379")
|
||||
t.Setenv(envRedisPassword, "secret")
|
||||
t.Setenv(envPostgresPrimaryDSN, defaultPrimaryDSN)
|
||||
t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
||||
setRequiredConnEnv(t)
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
@@ -18,11 +48,14 @@ func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
||||
require.Equal(t, defaults.ShutdownTimeout, cfg.ShutdownTimeout)
|
||||
require.Equal(t, defaults.Logging, cfg.Logging)
|
||||
require.Equal(t, defaults.InternalHTTP, cfg.InternalHTTP)
|
||||
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
|
||||
require.Equal(t, defaults.Redis.DB, cfg.Redis.DB)
|
||||
require.Equal(t, defaults.Redis.OperationTimeout, cfg.Redis.OperationTimeout)
|
||||
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Conn.MasterAddr)
|
||||
require.Equal(t, "secret", cfg.Redis.Conn.Password)
|
||||
require.Equal(t, defaults.Redis.Conn.DB, cfg.Redis.Conn.DB)
|
||||
require.Equal(t, defaults.Redis.Conn.OperationTimeout, cfg.Redis.Conn.OperationTimeout)
|
||||
require.Equal(t, defaultPrimaryDSN, cfg.Postgres.Conn.PrimaryDSN)
|
||||
require.Equal(t, defaults.Streams, cfg.Streams)
|
||||
require.Equal(t, defaults.Retry, cfg.Retry)
|
||||
require.Equal(t, defaults.Retention, cfg.Retention)
|
||||
require.Equal(t, UserServiceConfig{
|
||||
BaseURL: "http://user-service.internal",
|
||||
Timeout: defaults.UserService.Timeout,
|
||||
@@ -38,12 +71,19 @@ func TestLoadFromEnvAppliesOverrides(t *testing.T) {
|
||||
t.Setenv(internalHTTPReadHeaderTimeoutEnvVar, "3s")
|
||||
t.Setenv(internalHTTPReadTimeoutEnvVar, "11s")
|
||||
t.Setenv(internalHTTPIdleTimeoutEnvVar, "61s")
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6380")
|
||||
t.Setenv(redisUsernameEnvVar, "alice")
|
||||
t.Setenv(redisPasswordEnvVar, "secret")
|
||||
t.Setenv(redisDBEnvVar, "3")
|
||||
t.Setenv(redisTLSEnabledEnvVar, "true")
|
||||
t.Setenv(redisOperationTimeoutEnvVar, "750ms")
|
||||
|
||||
t.Setenv(envRedisMasterAddr, "127.0.0.1:6380")
|
||||
t.Setenv(envRedisReplicaAddrs, "127.0.0.1:6381,127.0.0.1:6382")
|
||||
t.Setenv(envRedisPassword, "topsecret")
|
||||
t.Setenv(envRedisDB, "3")
|
||||
t.Setenv(envRedisOpTimeout, "750ms")
|
||||
|
||||
t.Setenv(envPostgresPrimaryDSN, defaultPrimaryDSN)
|
||||
t.Setenv(envPostgresOpTimeout, "1500ms")
|
||||
t.Setenv(envPostgresMaxOpenConns, "32")
|
||||
t.Setenv(envPostgresMaxIdleConns, "8")
|
||||
t.Setenv(envPostgresConnMaxLife, "45m")
|
||||
|
||||
t.Setenv(intentsStreamEnvVar, "notification:test_intents")
|
||||
t.Setenv(intentsReadBlockTimeoutEnvVar, "3500ms")
|
||||
t.Setenv(gatewayClientEventsStreamEnvVar, "gateway:test_client-events")
|
||||
@@ -54,9 +94,10 @@ func TestLoadFromEnvAppliesOverrides(t *testing.T) {
|
||||
t.Setenv(routeLeaseTTLEnvVar, "7s")
|
||||
t.Setenv(routeBackoffMinEnvVar, "2s")
|
||||
t.Setenv(routeBackoffMaxEnvVar, "7m")
|
||||
t.Setenv(deadLetterTTLEnvVar, "120h")
|
||||
t.Setenv(recordTTLEnvVar, "240h")
|
||||
t.Setenv(idempotencyTTLEnvVar, "48h")
|
||||
t.Setenv(recordRetentionEnvVar, "21d")
|
||||
t.Setenv(malformedIntentRetentionEnvVar, "168h")
|
||||
t.Setenv(cleanupIntervalEnvVar, "30m")
|
||||
t.Setenv(userServiceBaseURLEnvVar, "https://user-service.internal/api/")
|
||||
t.Setenv(userServiceTimeoutEnvVar, "1500ms")
|
||||
t.Setenv(adminEmailsGeoReviewRecommendedEnvVar, "First@example.com, second@example.com, first@example.com")
|
||||
@@ -70,6 +111,9 @@ func TestLoadFromEnvAppliesOverrides(t *testing.T) {
|
||||
t.Setenv(otelStdoutTracesEnabledEnvVar, "true")
|
||||
t.Setenv(otelStdoutMetricsEnabledEnvVar, "true")
|
||||
|
||||
// Time package does not support `21d`; use 504h directly.
|
||||
t.Setenv(recordRetentionEnvVar, "504h")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -82,18 +126,28 @@ func TestLoadFromEnvAppliesOverrides(t *testing.T) {
|
||||
IdleTimeout: 61 * time.Second,
|
||||
}, cfg.InternalHTTP)
|
||||
require.Equal(t, RedisConfig{
|
||||
Addr: "127.0.0.1:6380",
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
DB: 3,
|
||||
TLSEnabled: true,
|
||||
OperationTimeout: 750 * time.Millisecond,
|
||||
Conn: redisconn.Config{
|
||||
MasterAddr: "127.0.0.1:6380",
|
||||
ReplicaAddrs: []string{"127.0.0.1:6381", "127.0.0.1:6382"},
|
||||
Password: "topsecret",
|
||||
DB: 3,
|
||||
OperationTimeout: 750 * time.Millisecond,
|
||||
},
|
||||
}, cfg.Redis)
|
||||
require.Equal(t, PostgresConfig{
|
||||
Conn: postgres.Config{
|
||||
PrimaryDSN: defaultPrimaryDSN,
|
||||
OperationTimeout: 1500 * time.Millisecond,
|
||||
MaxOpenConns: 32,
|
||||
MaxIdleConns: 8,
|
||||
ConnMaxLifetime: 45 * time.Minute,
|
||||
},
|
||||
}, cfg.Postgres)
|
||||
require.Equal(t, StreamsConfig{
|
||||
Intents: "notification:test_intents",
|
||||
GatewayClientEvents: "gateway:test_client-events",
|
||||
Intents: "notification:test_intents",
|
||||
GatewayClientEvents: "gateway:test_client-events",
|
||||
GatewayClientEventsStreamMaxLen: 2048,
|
||||
MailDeliveryCommands: "mail:test_delivery_commands",
|
||||
MailDeliveryCommands: "mail:test_delivery_commands",
|
||||
}, cfg.Streams)
|
||||
require.Equal(t, 3500*time.Millisecond, cfg.IntentsReadBlockTimeout)
|
||||
require.Equal(t, RetryConfig{
|
||||
@@ -102,10 +156,13 @@ func TestLoadFromEnvAppliesOverrides(t *testing.T) {
|
||||
RouteLeaseTTL: 7 * time.Second,
|
||||
RouteBackoffMin: 2 * time.Second,
|
||||
RouteBackoffMax: 7 * time.Minute,
|
||||
DeadLetterTTL: 120 * time.Hour,
|
||||
RecordTTL: 240 * time.Hour,
|
||||
IdempotencyTTL: 48 * time.Hour,
|
||||
}, cfg.Retry)
|
||||
require.Equal(t, RetentionConfig{
|
||||
RecordRetention: 504 * time.Hour,
|
||||
MalformedIntentRetention: 168 * time.Hour,
|
||||
CleanupInterval: 30 * time.Minute,
|
||||
}, cfg.Retention)
|
||||
require.Equal(t, UserServiceConfig{
|
||||
BaseURL: "https://user-service.internal/api",
|
||||
Timeout: 1500 * time.Millisecond,
|
||||
@@ -127,6 +184,27 @@ func TestLoadFromEnvAppliesOverrides(t *testing.T) {
|
||||
}, cfg.Telemetry)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsDeprecatedRedisVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envName string
|
||||
}{
|
||||
{name: "tls enabled rejected", envName: envRedisTLSEnabled},
|
||||
{name: "username rejected", envName: envRedisUsername},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
setRequiredConnEnv(t)
|
||||
t.Setenv(tt.envName, "true")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.envName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -135,14 +213,16 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
}{
|
||||
{name: "invalid duration", envName: shutdownTimeoutEnvVar, envVal: "later"},
|
||||
{name: "invalid log level", envName: logLevelEnvVar, envVal: "verbose"},
|
||||
{name: "invalid redis db", envName: redisDBEnvVar, envVal: "db-three"},
|
||||
{name: "invalid redis tls", envName: redisTLSEnabledEnvVar, envVal: "sometimes"},
|
||||
{name: "invalid redis db", envName: envRedisDB, envVal: "db-three"},
|
||||
{name: "invalid push retries", envName: pushRetryMaxAttemptsEnvVar, envVal: "many"},
|
||||
{name: "invalid email retries", envName: emailRetryMaxAttemptsEnvVar, envVal: "several"},
|
||||
{name: "invalid gateway client events stream max len", envName: gatewayClientEventsStreamMaxEnvVar, envVal: "many"},
|
||||
{name: "invalid user service timeout", envName: userServiceTimeoutEnvVar, envVal: "soon"},
|
||||
{name: "invalid intents read block timeout", envName: intentsReadBlockTimeoutEnvVar, envVal: "later"},
|
||||
{name: "invalid route lease ttl", envName: routeLeaseTTLEnvVar, envVal: "eventually"},
|
||||
{name: "invalid record retention", envName: recordRetentionEnvVar, envVal: "later"},
|
||||
{name: "invalid malformed intent retention", envName: malformedIntentRetentionEnvVar, envVal: "later"},
|
||||
{name: "invalid cleanup interval", envName: cleanupIntervalEnvVar, envVal: "later"},
|
||||
{name: "invalid traces exporter", envName: otelTracesExporterEnvVar, envVal: "stdout"},
|
||||
{name: "invalid metrics protocol", envName: otelExporterOTLPMetricsProtocolEnvVar, envVal: "udp"},
|
||||
{name: "invalid stdout traces", envName: otelStdoutTracesEnabledEnvVar, envVal: "sometimes"},
|
||||
@@ -152,8 +232,7 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal")
|
||||
setRequiredConnEnv(t)
|
||||
t.Setenv(tt.envName, tt.envVal)
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
@@ -163,20 +242,44 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsMissingRequiredValues(t *testing.T) {
|
||||
t.Run("missing redis addr", func(t *testing.T) {
|
||||
t.Run("missing redis master addr", func(t *testing.T) {
|
||||
t.Setenv(envRedisPassword, "secret")
|
||||
t.Setenv(envPostgresPrimaryDSN, defaultPrimaryDSN)
|
||||
t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), redisAddrEnvVar)
|
||||
require.Contains(t, err.Error(), envRedisMasterAddr)
|
||||
})
|
||||
|
||||
t.Run("missing user service base url", func(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Run("missing redis password", func(t *testing.T) {
|
||||
t.Setenv(envRedisMasterAddr, "127.0.0.1:6379")
|
||||
t.Setenv(envPostgresPrimaryDSN, defaultPrimaryDSN)
|
||||
t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), userServiceBaseURLEnvVar)
|
||||
require.Contains(t, err.Error(), envRedisPassword)
|
||||
})
|
||||
|
||||
t.Run("missing postgres primary dsn", func(t *testing.T) {
|
||||
t.Setenv(envRedisMasterAddr, "127.0.0.1:6379")
|
||||
t.Setenv(envRedisPassword, "secret")
|
||||
t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), envPostgresPrimaryDSN)
|
||||
})
|
||||
|
||||
t.Run("missing user service base url", func(t *testing.T) {
|
||||
t.Setenv(envRedisMasterAddr, "127.0.0.1:6379")
|
||||
t.Setenv(envRedisPassword, "secret")
|
||||
t.Setenv(envPostgresPrimaryDSN, defaultPrimaryDSN)
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "user service base URL")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -188,7 +291,6 @@ func TestLoadFromEnvRejectsInvalidConfiguration(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{name: "invalid internal http addr", envName: internalHTTPAddrEnvVar, envVal: "127.0.0.1", want: "internal HTTP addr"},
|
||||
{name: "invalid redis addr", envName: redisAddrEnvVar, envVal: "127.0.0.1", want: "redis addr"},
|
||||
{name: "relative user service url", envName: userServiceBaseURLEnvVar, envVal: "/internal/users", want: "absolute http(s) URL"},
|
||||
{name: "invalid admin email", envName: adminEmailsGeoReviewRecommendedEnvVar, envVal: "broken-email", want: "invalid email address"},
|
||||
{name: "blank admin email slot", envName: adminEmailsGameGenerationFailedEnvVar, envVal: "ops@example.com, , second@example.com", want: "must not be empty"},
|
||||
@@ -201,8 +303,7 @@ func TestLoadFromEnvRejectsInvalidConfiguration(t *testing.T) {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal")
|
||||
setRequiredConnEnv(t)
|
||||
t.Setenv(routeBackoffMaxEnvVar, "5m")
|
||||
t.Setenv(tt.envName, tt.envVal)
|
||||
|
||||
@@ -223,7 +324,7 @@ func TestLoadFromEnvRejectsNonPositiveValues(t *testing.T) {
|
||||
{name: "read header timeout", envName: internalHTTPReadHeaderTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "read timeout", envName: internalHTTPReadTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "idle timeout", envName: internalHTTPIdleTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "redis timeout", envName: redisOperationTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "redis timeout", envName: envRedisOpTimeout, envVal: "0s"},
|
||||
{name: "intents read block timeout", envName: intentsReadBlockTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "push retries", envName: pushRetryMaxAttemptsEnvVar, envVal: "0"},
|
||||
{name: "email retries", envName: emailRetryMaxAttemptsEnvVar, envVal: "0"},
|
||||
@@ -231,9 +332,10 @@ func TestLoadFromEnvRejectsNonPositiveValues(t *testing.T) {
|
||||
{name: "route lease ttl", envName: routeLeaseTTLEnvVar, envVal: "0s"},
|
||||
{name: "route backoff min", envName: routeBackoffMinEnvVar, envVal: "0s"},
|
||||
{name: "route backoff max", envName: routeBackoffMaxEnvVar, envVal: "0s"},
|
||||
{name: "dead letter ttl", envName: deadLetterTTLEnvVar, envVal: "0s"},
|
||||
{name: "record ttl", envName: recordTTLEnvVar, envVal: "0s"},
|
||||
{name: "idempotency ttl", envName: idempotencyTTLEnvVar, envVal: "0s"},
|
||||
{name: "record retention", envName: recordRetentionEnvVar, envVal: "0s"},
|
||||
{name: "malformed intent retention", envName: malformedIntentRetentionEnvVar, envVal: "0s"},
|
||||
{name: "cleanup interval", envName: cleanupIntervalEnvVar, envVal: "0s"},
|
||||
{name: "user service timeout", envName: userServiceTimeoutEnvVar, envVal: "0s"},
|
||||
}
|
||||
|
||||
@@ -241,8 +343,7 @@ func TestLoadFromEnvRejectsNonPositiveValues(t *testing.T) {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal")
|
||||
setRequiredConnEnv(t)
|
||||
t.Setenv(tt.envName, tt.envVal)
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user