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"` AFTN AFTNConfig `koanf:"aftn"` // 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"` } // AFTNConfig defines AFTN protocol validation and monitoring settings type AFTNConfig struct { // ValidationEnabled enables AFTN protocol validation ValidationEnabled bool `koanf:"validation_enabled"` // MessageGapThreshold is the duration after which the serial reader // is considered stalled (no messages received). Default: 2 minutes. MessageGapThreshold time.Duration `koanf:"message_gap_threshold"` // EnableSequenceGapDetection enables monitoring for missing sequence numbers EnableSequenceGapDetection bool `koanf:"enable_sequence_gap_detection"` } // 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 } // Set AFTN defaults if cfg.AFTN.MessageGapThreshold == 0 { cfg.AFTN.MessageGapThreshold = 2 * time.Minute } // 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") } // Validate AFTN configuration if c.AFTN.MessageGapThreshold < 0 { return fmt.Errorf("aftn.message_gap_threshold 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.>" }