Files
go-caatsm/internal/infra/config/config.go
T
windyboyandClaude Sonnet 4.5 7b6f6383ad Add AFTN protocol validation and serial reader health monitoring to enhance aviation telegram processing reliability and observability
Implement comprehensive AFTN/ICAO protocol compliance validation with configurable enforcement, enabling early detection of malformed telegrams and reducing downstream processing errors. Add real-time serial reader health monitoring to automatically detect message flow interruptions and sequence gaps, ensuring operational visibility into the telegram ingestion pipeline.

Key enhancements:
- AFTN validator validates priority indicators (FF/GG/QU/DD/SS/KK), ICAO addresses (4-char alphanumeric), and datetime formats (DDHHMM) with detailed error categorization
- Invalid telegrams automatically routed to DLQ with full context for offline review and correction
- Serial reader health monitoring tracks message gaps and sequence numbers to detect stalled readers or missing messages within configurable threshold (default: 2 minutes)
- Four new Prometheus metrics expose validation errors by type, message gaps, sequence gaps, and health status for operational alerting
- Pre-configured Prometheus alert rules for critical conditions (stalled reader, high error rates, consumer lag)
- Grafana dashboard provides real-time visibility into AFTN compliance and serial reader health
- Validation disabled by default for safe rollout with zero breaking changes to existing functionality

Implementation maintains clean architecture with validator in adapter layer, extends processor and consumer with health tracking, and ensures thread-safe concurrent access to tracking state. All changes fully tested with 48 validator tests, 10 processor tests, and 21 consumer tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-24 14:21:59 +08:00

452 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"`
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.>"
}