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>
452 lines
14 KiB
Go
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.>"
|
|
}
|