feat: notification service
This commit is contained in:
@@ -0,0 +1,839 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user