429 lines
14 KiB
Go
429 lines
14 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/knadh/koanf/parsers/toml"
|
|
envprovider "github.com/knadh/koanf/providers/env"
|
|
"github.com/knadh/koanf/providers/file"
|
|
"github.com/knadh/koanf/v2"
|
|
)
|
|
|
|
// Config holds all application configuration
|
|
type Config struct {
|
|
NATS NATSConfig `koanf:"nats"`
|
|
Postgres PostgresConfig `koanf:"postgres"`
|
|
App AppConfig `koanf:"app"`
|
|
Log LogConfig `koanf:"log"`
|
|
Publisher PublisherConfig `koanf:"publisher"`
|
|
Telemetry TelemetryConfig `koanf:"telemetry"`
|
|
Monitoring MonitoringConfig `koanf:"monitoring"`
|
|
DLQ DLQConfig `koanf:"dlq"`
|
|
// Legacy fields for backward compatibility during migration
|
|
Subscription SubscriptionConfig `koanf:"subscription"`
|
|
Timeouts TimeoutsConfig `koanf:"timeouts"`
|
|
}
|
|
|
|
// NATSConfig holds NATS/JetStream configuration
|
|
type NATSConfig struct {
|
|
URL string `koanf:"url"`
|
|
Mode string `koanf:"mode"`
|
|
Stream string `koanf:"stream"`
|
|
Consumer string `koanf:"consumer"`
|
|
StreamLimits StreamLimitsConfig `koanf:"stream_limits"`
|
|
ConsumerRules ConsumerRulesConfig `koanf:"consumer_rules"`
|
|
Auth NATSAuthConfig `koanf:"auth"`
|
|
// Legacy fields
|
|
Client string `koanf:"client"`
|
|
Cluster string `koanf:"cluster"`
|
|
}
|
|
|
|
// NATSAuthConfig holds NATS authentication configuration
|
|
type NATSAuthConfig struct {
|
|
// Token authentication (mutually exclusive with User/Password and CredentialsFile)
|
|
Token string `koanf:"token"`
|
|
|
|
// Credentials file authentication (mutually exclusive with Token and User/Password)
|
|
// Path to NATS credentials file (e.g., /path/to/user.creds)
|
|
CredentialsFile string `koanf:"credentials_file"`
|
|
|
|
// User/Password authentication (mutually exclusive with Token and CredentialsFile)
|
|
User string `koanf:"user"`
|
|
Password string `koanf:"password"`
|
|
|
|
// TLS configuration
|
|
TLSEnabled bool `koanf:"tls_enabled"`
|
|
TLSCertFile string `koanf:"tls_cert_file"` // Client certificate file
|
|
TLSKeyFile string `koanf:"tls_key_file"` // Client private key file
|
|
TLSCAFile string `koanf:"tls_ca_file"` // CA certificate file for server verification
|
|
}
|
|
|
|
// StreamLimitsConfig defines JetStream retention controls.
|
|
type StreamLimitsConfig struct {
|
|
MaxMsgs int64 `koanf:"max_msgs"`
|
|
MaxBytes int64 `koanf:"max_bytes"`
|
|
MaxAge time.Duration `koanf:"max_age"`
|
|
Discard string `koanf:"discard"`
|
|
Storage string `koanf:"storage"`
|
|
Replicas int `koanf:"replicas"`
|
|
}
|
|
|
|
// ConsumerRulesConfig captures consumer-level options.
|
|
type ConsumerRulesConfig struct {
|
|
MaxDeliver int `koanf:"max_deliver"`
|
|
AckWait time.Duration `koanf:"ack_wait"`
|
|
MaxAckPending int `koanf:"max_ack_pending"`
|
|
DeliverPolicy string `koanf:"deliver_policy"`
|
|
ReplayPolicy string `koanf:"replay_policy"`
|
|
Backoff []time.Duration `koanf:"backoff"`
|
|
StartSequence uint64 `koanf:"start_sequence"`
|
|
StartTime string `koanf:"start_time"`
|
|
}
|
|
|
|
// PostgresConfig holds PostgreSQL configuration
|
|
type PostgresConfig struct {
|
|
URL string `koanf:"url"`
|
|
MaxConns int32 `koanf:"max_conns"`
|
|
MinConns int32 `koanf:"min_conns"`
|
|
}
|
|
|
|
// AppConfig holds application-level configuration
|
|
type AppConfig struct {
|
|
BatchSize int `koanf:"batch_size"`
|
|
BatchTimeout time.Duration `koanf:"batch_timeout"`
|
|
MonitorInterval time.Duration `koanf:"monitor_interval"`
|
|
}
|
|
|
|
// LogConfig holds logging configuration
|
|
type LogConfig struct {
|
|
Level string `koanf:"level"`
|
|
Format string `koanf:"format"` // json or console
|
|
|
|
// Output configuration
|
|
Output []string `koanf:"output"` // stdout, stderr, or file path
|
|
File string `koanf:"file"` // Log file path (if output includes file)
|
|
|
|
// File rotation settings
|
|
MaxSize int `koanf:"max_size"` // Max size in MB before rotation
|
|
MaxBackups int `koanf:"max_backups"` // Max number of old log files to keep
|
|
MaxAge int `koanf:"max_age"` // Max days to retain old log files
|
|
Compress bool `koanf:"compress"` // Compress rotated log files
|
|
|
|
// Advanced zap options
|
|
DisableCaller bool `koanf:"disable_caller"` // Disable caller information
|
|
DisableStacktrace bool `koanf:"disable_stacktrace"` // Disable stacktrace
|
|
Development bool `koanf:"development"` // Enable development mode
|
|
Sampling *LogSamplingConfig `koanf:"sampling"`
|
|
}
|
|
|
|
// LogSamplingConfig configures log sampling to reduce high-volume logs
|
|
type LogSamplingConfig struct {
|
|
Initial int `koanf:"initial"` // Log first N messages per tick
|
|
Thereafter int `koanf:"thereafter"` // Then log every Nth message
|
|
Tick time.Duration `koanf:"tick"` // Sampling tick interval
|
|
}
|
|
|
|
// PublisherConfig holds publisher configuration
|
|
type PublisherConfig struct {
|
|
Topic string `koanf:"topic"`
|
|
}
|
|
|
|
// TelemetryConfig controls tracing/metrics exporters.
|
|
type TelemetryConfig struct {
|
|
Enabled bool `koanf:"enabled"`
|
|
Endpoint string `koanf:"endpoint"`
|
|
Insecure bool `koanf:"insecure"`
|
|
}
|
|
|
|
// DLQConfig defines the dead-letter queue routing for poison/permanent messages.
|
|
// If Enabled is true and Subject is non-empty, permanent failures will be published
|
|
// to the configured subject for offline processing.
|
|
type DLQConfig struct {
|
|
Enabled bool `koanf:"enabled"`
|
|
Subject string `koanf:"subject"`
|
|
}
|
|
|
|
// MonitoringConfig controls the lightweight HTTP server that exposes health and metrics endpoints.
|
|
type MonitoringConfig struct {
|
|
Disabled bool `koanf:"disabled"`
|
|
Addr string `koanf:"addr"`
|
|
EnableMetrics bool `koanf:"enable_metrics"`
|
|
EnableHealth bool `koanf:"enable_health"`
|
|
ReadTimeout time.Duration `koanf:"read_timeout"`
|
|
WriteTimeout time.Duration `koanf:"write_timeout"`
|
|
HealthTimeout time.Duration `koanf:"health_timeout"`
|
|
}
|
|
|
|
// SubscriptionConfig holds subscription configuration (legacy)
|
|
type SubscriptionConfig struct {
|
|
Topic string `koanf:"topic"`
|
|
QueueGroup string `koanf:"queue_group"`
|
|
}
|
|
|
|
// TimeoutsConfig holds timeout configuration (legacy)
|
|
type TimeoutsConfig struct {
|
|
Server time.Duration `koanf:"server"`
|
|
ReconnectWait time.Duration `koanf:"reconnect_wait"`
|
|
Close time.Duration `koanf:"close"`
|
|
AckWait time.Duration `koanf:"ack_wait"`
|
|
}
|
|
|
|
// LoadConfig loads configuration from file and environment variables
|
|
func LoadConfig() (*Config, error) {
|
|
k := koanf.New(".")
|
|
|
|
// Determine environment
|
|
env := os.Getenv("GO_ENV")
|
|
if env == "" {
|
|
env = "dev"
|
|
}
|
|
|
|
// Load from TOML file
|
|
configFile := fmt.Sprintf("configs/config.%s.toml", env)
|
|
if err := k.Load(file.Provider(configFile), toml.Parser()); err != nil {
|
|
return nil, fmt.Errorf("error loading config file '%s': %w", configFile, err)
|
|
}
|
|
|
|
// Load from environment variables with CAATSM_ prefix
|
|
envProvider := envprovider.Provider("CAATSM_", ".", func(s string) string {
|
|
// Convert CAATSM_NATS_URL to nats.url
|
|
s = strings.TrimPrefix(s, "CAATSM_")
|
|
return strings.ToLower(strings.ReplaceAll(s, "_", "."))
|
|
})
|
|
if err := k.Load(envProvider, nil); err != nil {
|
|
// Environment variables are optional, so we don't fail if they're not present.
|
|
// This allows the config to work with just the file.
|
|
_ = err // explicitly ignore
|
|
}
|
|
|
|
// Unmarshal into Config struct
|
|
var cfg Config
|
|
if err := k.Unmarshal("", &cfg); err != nil {
|
|
return nil, fmt.Errorf("error unmarshaling config: %w", err)
|
|
}
|
|
|
|
// Set defaults
|
|
if cfg.App.BatchSize == 0 {
|
|
cfg.App.BatchSize = 50
|
|
}
|
|
if cfg.App.BatchTimeout == 0 {
|
|
cfg.App.BatchTimeout = 2 * time.Second
|
|
}
|
|
if cfg.App.MonitorInterval == 0 {
|
|
cfg.App.MonitorInterval = 30 * time.Second
|
|
}
|
|
if cfg.Postgres.MaxConns == 0 {
|
|
cfg.Postgres.MaxConns = 10
|
|
}
|
|
if cfg.Postgres.MinConns == 0 {
|
|
cfg.Postgres.MinConns = 2
|
|
}
|
|
if cfg.Log.Level == "" {
|
|
cfg.Log.Level = "info"
|
|
}
|
|
if cfg.Log.Format == "" {
|
|
cfg.Log.Format = "json"
|
|
}
|
|
if len(cfg.Log.Output) == 0 {
|
|
cfg.Log.Output = []string{"stdout"}
|
|
} else {
|
|
// If output contains "file" but file path is empty, set default path
|
|
hasFile := false
|
|
for _, out := range cfg.Log.Output {
|
|
if out == "file" {
|
|
hasFile = true
|
|
break
|
|
}
|
|
}
|
|
if hasFile && cfg.Log.File == "" {
|
|
cfg.Log.File = "logs/caatsm.log"
|
|
}
|
|
}
|
|
if cfg.Log.MaxSize == 0 {
|
|
cfg.Log.MaxSize = 100 // 100 MB
|
|
}
|
|
if cfg.Log.MaxBackups == 0 {
|
|
cfg.Log.MaxBackups = 7
|
|
}
|
|
if cfg.Log.MaxAge == 0 {
|
|
cfg.Log.MaxAge = 30 // 30 days
|
|
}
|
|
if cfg.NATS.Mode == "" {
|
|
cfg.NATS.Mode = "jetstream"
|
|
} else {
|
|
cfg.NATS.Mode = strings.ToLower(cfg.NATS.Mode)
|
|
}
|
|
if cfg.NATS.Stream == "" {
|
|
cfg.NATS.Stream = "TELEGRAM"
|
|
}
|
|
if cfg.NATS.Consumer == "" {
|
|
cfg.NATS.Consumer = "telegram-consumer"
|
|
}
|
|
if cfg.NATS.StreamLimits.MaxMsgs == 0 {
|
|
cfg.NATS.StreamLimits.MaxMsgs = 100000
|
|
}
|
|
if cfg.NATS.StreamLimits.MaxBytes == 0 {
|
|
cfg.NATS.StreamLimits.MaxBytes = 64 * 1024 * 1024
|
|
}
|
|
if cfg.NATS.StreamLimits.MaxAge == 0 {
|
|
cfg.NATS.StreamLimits.MaxAge = 24 * time.Hour
|
|
}
|
|
if cfg.NATS.StreamLimits.Discard == "" {
|
|
cfg.NATS.StreamLimits.Discard = "old"
|
|
}
|
|
if cfg.NATS.StreamLimits.Storage == "" {
|
|
cfg.NATS.StreamLimits.Storage = "file"
|
|
}
|
|
if cfg.NATS.StreamLimits.Replicas == 0 {
|
|
cfg.NATS.StreamLimits.Replicas = 1
|
|
}
|
|
if cfg.NATS.ConsumerRules.MaxDeliver == 0 {
|
|
cfg.NATS.ConsumerRules.MaxDeliver = 5
|
|
}
|
|
if cfg.Timeouts.AckWait == 0 {
|
|
cfg.Timeouts.AckWait = 30 * time.Second
|
|
}
|
|
if cfg.NATS.ConsumerRules.AckWait == 0 {
|
|
cfg.NATS.ConsumerRules.AckWait = cfg.Timeouts.AckWait
|
|
}
|
|
if cfg.NATS.ConsumerRules.MaxAckPending == 0 {
|
|
cfg.NATS.ConsumerRules.MaxAckPending = 1024
|
|
}
|
|
if cfg.NATS.ConsumerRules.DeliverPolicy == "" {
|
|
cfg.NATS.ConsumerRules.DeliverPolicy = "all"
|
|
}
|
|
if cfg.NATS.ConsumerRules.ReplayPolicy == "" {
|
|
cfg.NATS.ConsumerRules.ReplayPolicy = "instant"
|
|
}
|
|
if cfg.Telemetry.Endpoint == "" {
|
|
cfg.Telemetry.Endpoint = ""
|
|
}
|
|
|
|
if !cfg.Monitoring.Disabled && cfg.Monitoring.Addr == "" && !cfg.Monitoring.EnableHealth && !cfg.Monitoring.EnableMetrics {
|
|
cfg.Monitoring.Addr = ":2112"
|
|
cfg.Monitoring.EnableHealth = true
|
|
cfg.Monitoring.EnableMetrics = true
|
|
}
|
|
if cfg.Monitoring.ReadTimeout == 0 {
|
|
cfg.Monitoring.ReadTimeout = 5 * time.Second
|
|
}
|
|
if cfg.Monitoring.WriteTimeout == 0 {
|
|
cfg.Monitoring.WriteTimeout = 5 * time.Second
|
|
}
|
|
if cfg.Monitoring.HealthTimeout == 0 {
|
|
cfg.Monitoring.HealthTimeout = 2 * time.Second
|
|
}
|
|
|
|
// Validate configuration
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, fmt.Errorf("config validation failed: %w", err)
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// Validate validates the configuration
|
|
func (c *Config) Validate() error {
|
|
if c.NATS.URL == "" {
|
|
return fmt.Errorf("nats.url is required")
|
|
}
|
|
// Validate NATS mode - only JetStream is supported
|
|
mode := strings.ToLower(c.NATS.Mode)
|
|
if mode != "" && mode != "jetstream" {
|
|
return fmt.Errorf("nats.mode must be 'jetstream' or empty (defaults to 'jetstream'), got: %s", c.NATS.Mode)
|
|
}
|
|
if c.NATS.Stream == "" {
|
|
return fmt.Errorf("nats.stream is required")
|
|
}
|
|
if c.Publisher.Topic == "" {
|
|
return fmt.Errorf("publisher.topic is required")
|
|
}
|
|
if c.Postgres.URL == "" {
|
|
return fmt.Errorf("postgres.url is required")
|
|
}
|
|
if c.App.BatchSize <= 0 {
|
|
return fmt.Errorf("app.batch_size must be greater than 0")
|
|
}
|
|
if c.App.BatchTimeout <= 0 {
|
|
return fmt.Errorf("app.batch_timeout must be greater than 0")
|
|
}
|
|
if c.App.MonitorInterval <= 0 {
|
|
return fmt.Errorf("app.monitor_interval must be greater than 0")
|
|
}
|
|
if c.NATS.StreamLimits.MaxMsgs < 0 {
|
|
return fmt.Errorf("nats.stream_limits.max_msgs must be >= 0")
|
|
}
|
|
if c.NATS.StreamLimits.MaxBytes < 0 {
|
|
return fmt.Errorf("nats.stream_limits.max_bytes must be >= 0")
|
|
}
|
|
if c.NATS.StreamLimits.MaxAge < 0 {
|
|
return fmt.Errorf("nats.stream_limits.max_age must be >= 0")
|
|
}
|
|
if c.NATS.StreamLimits.Replicas <= 0 {
|
|
return fmt.Errorf("nats.stream_limits.replicas must be greater than 0")
|
|
}
|
|
if c.NATS.ConsumerRules.MaxDeliver <= 0 {
|
|
return fmt.Errorf("nats.consumer.max_deliver must be greater than 0")
|
|
}
|
|
if c.NATS.ConsumerRules.AckWait <= 0 {
|
|
return fmt.Errorf("nats.consumer.ack_wait must be greater than 0")
|
|
}
|
|
if c.NATS.ConsumerRules.MaxAckPending < 0 {
|
|
return fmt.Errorf("nats.consumer.max_ack_pending must be >= 0")
|
|
}
|
|
switch strings.ToLower(c.NATS.ConsumerRules.DeliverPolicy) {
|
|
case "", "all", "new", "last", "last_per_subject", "sequence", "time":
|
|
default:
|
|
return fmt.Errorf("nats.consumer.deliver_policy must be one of all,new,last,last_per_subject,sequence,time")
|
|
}
|
|
switch strings.ToLower(c.NATS.ConsumerRules.ReplayPolicy) {
|
|
case "", "instant", "original":
|
|
default:
|
|
return fmt.Errorf("nats.consumer.replay_policy must be instant or original")
|
|
}
|
|
if c.NATS.ConsumerRules.StartTime != "" {
|
|
if _, err := time.Parse(time.RFC3339, c.NATS.ConsumerRules.StartTime); err != nil {
|
|
return fmt.Errorf("nats.consumer.start_time must be RFC3339: %w", err)
|
|
}
|
|
}
|
|
for _, d := range c.NATS.ConsumerRules.Backoff {
|
|
if d < 0 {
|
|
return fmt.Errorf("nats.consumer.backoff durations must be >= 0")
|
|
}
|
|
}
|
|
if c.Telemetry.Endpoint == "" && c.Telemetry.Enabled {
|
|
return fmt.Errorf("telemetry.endpoint is required when telemetry.enabled=true")
|
|
}
|
|
if c.Monitoring.ReadTimeout < 0 {
|
|
return fmt.Errorf("monitoring.read_timeout must be >= 0")
|
|
}
|
|
if c.Monitoring.WriteTimeout < 0 {
|
|
return fmt.Errorf("monitoring.write_timeout must be >= 0")
|
|
}
|
|
if c.Monitoring.HealthTimeout < 0 {
|
|
return fmt.Errorf("monitoring.health_timeout must be >= 0")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ProvideConfig is a Wire provider function
|
|
func ProvideConfig() (*Config, error) {
|
|
return LoadConfig()
|
|
}
|
|
|
|
// EffectiveSubscriptionTopic returns the active subscription subject.
|
|
// Retains support for legacy config.Subscription fields while allowing
|
|
// future consolidation.
|
|
func (c *Config) EffectiveSubscriptionTopic() string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
if topic := c.Subscription.Topic; topic != "" {
|
|
return topic
|
|
}
|
|
return "telegram.>"
|
|
}
|