840 lines
28 KiB
Go
840 lines
28 KiB
Go
// Package config loads the Notification Service process configuration from
|
|
// environment variables.
|
|
package config
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
netmail "net/mail"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"galaxy/notification/internal/telemetry"
|
|
)
|
|
|
|
const (
|
|
shutdownTimeoutEnvVar = "NOTIFICATION_SHUTDOWN_TIMEOUT"
|
|
logLevelEnvVar = "NOTIFICATION_LOG_LEVEL"
|
|
|
|
internalHTTPAddrEnvVar = "NOTIFICATION_INTERNAL_HTTP_ADDR"
|
|
internalHTTPReadHeaderTimeoutEnvVar = "NOTIFICATION_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
|
|
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"
|
|
gatewayClientEventsStreamMaxEnvVar = "NOTIFICATION_GATEWAY_CLIENT_EVENTS_STREAM_MAX_LEN"
|
|
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"
|
|
|
|
userServiceBaseURLEnvVar = "NOTIFICATION_USER_SERVICE_BASE_URL"
|
|
userServiceTimeoutEnvVar = "NOTIFICATION_USER_SERVICE_TIMEOUT"
|
|
|
|
adminEmailsGeoReviewRecommendedEnvVar = "NOTIFICATION_ADMIN_EMAILS_GEO_REVIEW_RECOMMENDED"
|
|
adminEmailsGameGenerationFailedEnvVar = "NOTIFICATION_ADMIN_EMAILS_GAME_GENERATION_FAILED"
|
|
adminEmailsLobbyRuntimePausedAfterEnvVar = "NOTIFICATION_ADMIN_EMAILS_LOBBY_RUNTIME_PAUSED_AFTER_START"
|
|
adminEmailsLobbyApplicationSubmittedEnvVar = "NOTIFICATION_ADMIN_EMAILS_LOBBY_APPLICATION_SUBMITTED"
|
|
|
|
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 = "NOTIFICATION_OTEL_STDOUT_TRACES_ENABLED"
|
|
otelStdoutMetricsEnabledEnvVar = "NOTIFICATION_OTEL_STDOUT_METRICS_ENABLED"
|
|
|
|
defaultShutdownTimeout = 5 * time.Second
|
|
defaultLogLevel = "info"
|
|
defaultInternalHTTPAddr = ":8092"
|
|
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"
|
|
defaultGatewayClientEventsStreamMaxLen int64 = 1024
|
|
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
|
|
|
|
defaultUserServiceTimeout = time.Second
|
|
defaultOTelServiceName = "galaxy-notification"
|
|
|
|
otelExporterNone = "none"
|
|
otelExporterOTLP = "otlp"
|
|
otelProtocolHTTPProtobuf = "http/protobuf"
|
|
otelProtocolGRPC = "grpc"
|
|
)
|
|
|
|
// Config stores the full Notification Service process configuration.
|
|
type Config struct {
|
|
// ShutdownTimeout bounds graceful shutdown of every long-lived component.
|
|
ShutdownTimeout time.Duration
|
|
|
|
// Logging configures the process-wide structured logger.
|
|
Logging LoggingConfig
|
|
|
|
// InternalHTTP configures the private probe HTTP listener.
|
|
InternalHTTP InternalHTTPConfig
|
|
|
|
// Redis configures the shared Redis client used by the process.
|
|
Redis RedisConfig
|
|
|
|
// Streams stores the stable stream names reserved for notification 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 RetryConfig
|
|
|
|
// UserService configures the trusted user-enrichment dependency.
|
|
UserService UserServiceConfig
|
|
|
|
// AdminRouting stores the type-specific configured administrator email
|
|
// lists.
|
|
AdminRouting AdminRoutingConfig
|
|
|
|
// Telemetry configures the process-wide OpenTelemetry runtime.
|
|
Telemetry TelemetryConfig
|
|
}
|
|
|
|
// LoggingConfig configures the process-wide structured logger.
|
|
type LoggingConfig struct {
|
|
// Level stores the process log level accepted by log/slog.
|
|
Level string
|
|
}
|
|
|
|
// InternalHTTPConfig configures the private probe 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
|
|
}
|
|
|
|
// 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 !isTCPAddr(cfg.Addr):
|
|
return fmt.Errorf("internal HTTP addr %q must use host:port form", cfg.Addr)
|
|
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")
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// RedisConfig configures the shared Redis client and its connection settings.
|
|
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}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// StreamsConfig stores the stable Redis Stream names used by Notification
|
|
// Service.
|
|
type StreamsConfig struct {
|
|
// Intents stores the ingress intent stream.
|
|
Intents string
|
|
|
|
// GatewayClientEvents stores the downstream Gateway client-events stream.
|
|
GatewayClientEvents string
|
|
|
|
// GatewayClientEventsStreamMaxLen bounds the downstream Gateway
|
|
// client-events stream with approximate trimming.
|
|
GatewayClientEventsStreamMaxLen int64
|
|
|
|
// MailDeliveryCommands stores the downstream Mail Service command stream.
|
|
MailDeliveryCommands string
|
|
}
|
|
|
|
// Validate reports whether cfg stores usable stream names.
|
|
func (cfg StreamsConfig) Validate() error {
|
|
switch {
|
|
case strings.TrimSpace(cfg.Intents) == "":
|
|
return fmt.Errorf("intents stream must not be empty")
|
|
case strings.TrimSpace(cfg.GatewayClientEvents) == "":
|
|
return fmt.Errorf("gateway client-events stream must not be empty")
|
|
case cfg.GatewayClientEventsStreamMaxLen <= 0:
|
|
return fmt.Errorf("gateway client-events stream max len must be positive")
|
|
case strings.TrimSpace(cfg.MailDeliveryCommands) == "":
|
|
return fmt.Errorf("mail delivery-commands stream must not be empty")
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// RetryConfig stores the frozen retry budgets, backoff settings, and retention
|
|
// periods used by the service.
|
|
type RetryConfig struct {
|
|
// PushMaxAttempts stores the route retry budget for the `push` channel.
|
|
PushMaxAttempts int
|
|
|
|
// EmailMaxAttempts stores the route retry budget for the `email` channel.
|
|
EmailMaxAttempts int
|
|
|
|
// RouteLeaseTTL stores the temporary route-lease lifetime used to avoid
|
|
// duplicate publication across replicas.
|
|
RouteLeaseTTL time.Duration
|
|
|
|
// RouteBackoffMin stores the minimum retry backoff.
|
|
RouteBackoffMin time.Duration
|
|
|
|
// 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 time.Duration
|
|
}
|
|
|
|
// Validate reports whether cfg stores usable retry and retention settings.
|
|
func (cfg RetryConfig) Validate() error {
|
|
switch {
|
|
case cfg.PushMaxAttempts <= 0:
|
|
return fmt.Errorf("push retry max attempts must be positive")
|
|
case cfg.EmailMaxAttempts <= 0:
|
|
return fmt.Errorf("email retry max attempts must be positive")
|
|
case cfg.RouteLeaseTTL <= 0:
|
|
return fmt.Errorf("route lease ttl must be positive")
|
|
case cfg.RouteBackoffMin <= 0:
|
|
return fmt.Errorf("route backoff min must be positive")
|
|
case cfg.RouteBackoffMax <= 0:
|
|
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:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// UserServiceConfig configures the trusted user-enrichment dependency.
|
|
type UserServiceConfig struct {
|
|
// BaseURL stores the absolute base URL of the trusted User Service.
|
|
BaseURL string
|
|
|
|
// Timeout bounds one outbound User Service request.
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// Validate reports whether cfg stores a usable User Service configuration.
|
|
func (cfg UserServiceConfig) Validate() error {
|
|
switch {
|
|
case strings.TrimSpace(cfg.BaseURL) == "":
|
|
return fmt.Errorf("user service base URL must not be empty")
|
|
case !isAbsoluteHTTPURL(cfg.BaseURL):
|
|
return fmt.Errorf("user service base URL %q must be an absolute http(s) URL", cfg.BaseURL)
|
|
case cfg.Timeout <= 0:
|
|
return fmt.Errorf("user service timeout must be positive")
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// AdminRoutingConfig stores the type-specific configured administrator email
|
|
// lists.
|
|
type AdminRoutingConfig struct {
|
|
// GeoReviewRecommended stores recipients for
|
|
// `geo.review_recommended`.
|
|
GeoReviewRecommended []string
|
|
|
|
// GameGenerationFailed stores recipients for
|
|
// `game.generation_failed`.
|
|
GameGenerationFailed []string
|
|
|
|
// LobbyRuntimePausedAfterStart stores recipients for
|
|
// `lobby.runtime_paused_after_start`.
|
|
LobbyRuntimePausedAfterStart []string
|
|
|
|
// LobbyApplicationSubmitted stores recipients for public
|
|
// `lobby.application.submitted` notifications.
|
|
LobbyApplicationSubmitted []string
|
|
}
|
|
|
|
// Validate reports whether cfg stores valid normalized administrator email
|
|
// lists.
|
|
func (cfg AdminRoutingConfig) Validate() error {
|
|
if err := validateNormalizedEmailList("geo.review_recommended", cfg.GeoReviewRecommended); err != nil {
|
|
return err
|
|
}
|
|
if err := validateNormalizedEmailList("game.generation_failed", cfg.GameGenerationFailed); err != nil {
|
|
return err
|
|
}
|
|
if err := validateNormalizedEmailList("lobby.runtime_paused_after_start", cfg.LobbyRuntimePausedAfterStart); err != nil {
|
|
return err
|
|
}
|
|
if err := validateNormalizedEmailList("lobby.application.submitted", cfg.LobbyApplicationSubmitted); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TelemetryConfig configures the Notification 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
|
|
// configuration.
|
|
func (cfg TelemetryConfig) Validate() error {
|
|
return telemetry.ProcessConfig{
|
|
ServiceName: cfg.ServiceName,
|
|
TracesExporter: cfg.TracesExporter,
|
|
MetricsExporter: cfg.MetricsExporter,
|
|
TracesProtocol: cfg.TracesProtocol,
|
|
MetricsProtocol: cfg.MetricsProtocol,
|
|
StdoutTracesEnabled: cfg.StdoutTracesEnabled,
|
|
StdoutMetricsEnabled: cfg.StdoutMetricsEnabled,
|
|
}.Validate()
|
|
}
|
|
|
|
// DefaultConfig returns the default Notification Service process
|
|
// configuration.
|
|
func DefaultConfig() Config {
|
|
return Config{
|
|
ShutdownTimeout: defaultShutdownTimeout,
|
|
Logging: LoggingConfig{
|
|
Level: defaultLogLevel,
|
|
},
|
|
InternalHTTP: InternalHTTPConfig{
|
|
Addr: defaultInternalHTTPAddr,
|
|
ReadHeaderTimeout: defaultReadHeaderTimeout,
|
|
ReadTimeout: defaultReadTimeout,
|
|
IdleTimeout: defaultIdleTimeout,
|
|
},
|
|
Redis: RedisConfig{
|
|
DB: defaultRedisDB,
|
|
OperationTimeout: defaultRedisOperationTimeout,
|
|
},
|
|
Streams: StreamsConfig{
|
|
Intents: defaultIntentsStream,
|
|
GatewayClientEvents: defaultGatewayClientEventsStream,
|
|
GatewayClientEventsStreamMaxLen: defaultGatewayClientEventsStreamMaxLen,
|
|
MailDeliveryCommands: defaultMailDeliveryCommandsStream,
|
|
},
|
|
IntentsReadBlockTimeout: defaultIntentsReadBlockTimeout,
|
|
Retry: RetryConfig{
|
|
PushMaxAttempts: defaultPushRetryMaxAttempts,
|
|
EmailMaxAttempts: defaultEmailRetryMaxAttempts,
|
|
RouteLeaseTTL: defaultRouteLeaseTTL,
|
|
RouteBackoffMin: defaultRouteBackoffMin,
|
|
RouteBackoffMax: defaultRouteBackoffMax,
|
|
DeadLetterTTL: defaultDeadLetterTTL,
|
|
RecordTTL: defaultRecordTTL,
|
|
IdempotencyTTL: defaultIdempotencyTTL,
|
|
},
|
|
UserService: UserServiceConfig{
|
|
Timeout: defaultUserServiceTimeout,
|
|
},
|
|
Telemetry: TelemetryConfig{
|
|
ServiceName: defaultOTelServiceName,
|
|
TracesExporter: otelExporterNone,
|
|
MetricsExporter: otelExporterNone,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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:
|
|
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)
|
|
}
|
|
if err := cfg.Streams.Validate(); err != nil {
|
|
return fmt.Errorf("load notification config: %s", err)
|
|
}
|
|
if cfg.IntentsReadBlockTimeout <= 0 {
|
|
return fmt.Errorf("load notification config: %s must be positive", intentsReadBlockTimeoutEnvVar)
|
|
}
|
|
if err := cfg.Retry.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)
|
|
}
|
|
if err := cfg.AdminRouting.Validate(); err != nil {
|
|
return fmt.Errorf("load notification config: %s", err)
|
|
}
|
|
if err := cfg.Telemetry.Validate(); err != nil {
|
|
return fmt.Errorf("load notification config: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func loadStringEnvWithDefault(name string, value string) string {
|
|
if raw, ok := os.LookupEnv(name); ok {
|
|
return strings.TrimSpace(raw)
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func loadDurationEnvWithDefault(name string, value time.Duration) (time.Duration, error) {
|
|
raw, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return value, nil
|
|
}
|
|
|
|
parsed, err := time.ParseDuration(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func parseEmailList(name string, raw string) ([]string, error) {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
parts := strings.Split(trimmed, ",")
|
|
addresses := make([]string, 0, len(parts))
|
|
seen := make(map[string]struct{}, len(parts))
|
|
for index, part := range parts {
|
|
normalized, err := normalizeMailboxAddress(part)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s[%d]: %w", name, index, err)
|
|
}
|
|
if _, ok := seen[normalized]; ok {
|
|
continue
|
|
}
|
|
seen[normalized] = struct{}{}
|
|
addresses = append(addresses, normalized)
|
|
}
|
|
|
|
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 == "" {
|
|
return ""
|
|
}
|
|
|
|
return strings.TrimRight(trimmed, "/")
|
|
}
|
|
|
|
func isAbsoluteHTTPURL(value string) bool {
|
|
parsed, err := url.Parse(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return false
|
|
}
|
|
|
|
return parsed.Host != ""
|
|
}
|
|
|
|
func isTCPAddr(value string) bool {
|
|
host, port, err := net.SplitHostPort(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
if port == "" {
|
|
return false
|
|
}
|
|
if host == "" {
|
|
return true
|
|
}
|
|
|
|
return true
|
|
}
|