package config import ( "context" "fmt" "os" "path/filepath" "reflect" "strings" "sync" "time" "github.com/mitchellh/mapstructure" "go.uber.org/zap" sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config" configruntime "github.com/geo-platform/tenant-api/internal/shared/config/runtime" runtimeenv "github.com/geo-platform/tenant-api/internal/shared/config/runtime/env" runtimefile "github.com/geo-platform/tenant-api/internal/shared/config/runtime/file" ) type Config struct { Server sharedconfig.ServerConfig `mapstructure:"server"` Database sharedconfig.DatabaseConfig `mapstructure:"database"` MonitoringDatabase sharedconfig.DatabaseConfig `mapstructure:"monitoring_database"` Redis sharedconfig.RedisConfig `mapstructure:"redis"` Cache sharedconfig.CacheConfig `mapstructure:"cache"` RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"` Log sharedconfig.LogConfig `mapstructure:"log"` JWT JWTConfig `mapstructure:"jwt"` DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"` AdminUsers AdminUsersConfig `mapstructure:"admin_users"` IPRegion IPRegionConfig `mapstructure:"ip_region"` } type JWTConfig struct { Secret string `mapstructure:"secret"` AccessTTL time.Duration `mapstructure:"access_ttl"` } type DefaultAdminConfig struct { Username string `mapstructure:"username"` Password string `mapstructure:"password"` DisplayName string `mapstructure:"display_name"` Email string `mapstructure:"email"` } type AdminUsersConfig struct { DefaultPlanCode string `mapstructure:"default_plan_code"` } type IPRegionConfig struct { V4XDBPath string `mapstructure:"v4_xdb_path"` V6XDBPath string `mapstructure:"v6_xdb_path"` } type Store struct { path string mu sync.RWMutex cfg *Config runtime configruntime.Config watchedFiles map[string]fileSnapshot updates chan struct{} } type ReloadEvent struct { Previous *Config Current *Config Changes []FieldChange } type FieldChange struct { Path string Hot bool } func Load(path string) (*Config, error) { cfg, err := load(path) if err != nil { return nil, err } return cfg, nil } func NewStore(path string) (*Store, error) { cfg, err := load(path) if err != nil { return nil, err } store := &Store{ path: path, cfg: cfg, watchedFiles: snapshotFiles(watchFiles(path)), updates: make(chan struct{}, 1), } store.runtime = store.newRuntimeConfig() return store, nil } func (s *Store) Current() *Config { if s == nil { return nil } s.mu.RLock() defer s.mu.RUnlock() return s.cfg } func (s *Store) Watch(ctx context.Context, logger *zap.Logger, onReload func(ReloadEvent)) error { if s == nil { return fmt.Errorf("ops config store is nil") } if s.runtime == nil { s.runtime = s.newRuntimeConfig() } if err := s.runtime.Load(); err != nil { return err } if err := s.runtime.Watch("", func(string, configruntime.Value) { s.notifyReload() }); err != nil { _ = s.runtime.Close() return err } if logger != nil { logger.Info("ops config hot reload watcher started", zap.String("file", s.path)) } for { select { case <-ctx.Done(): return nil case <-s.updates: s.reload(logger, onReload) } } } func (s *Store) Close() { if s == nil { return } if s.runtime != nil { _ = s.runtime.Close() } } func (s *Store) reload(logger *zap.Logger, onReload func(ReloadEvent)) { next, err := load(s.path) if err != nil { if logger != nil { logger.Warn("ops config reload rejected", zap.Error(err)) } return } s.mu.Lock() previous := s.cfg s.cfg = next s.watchedFiles = snapshotFiles(watchFiles(s.path)) s.mu.Unlock() event := ReloadEvent{ Previous: previous, Current: next, Changes: Diff(previous, next), } if onReload != nil { onReload(event) } if logger != nil { restartRequired := RestartRequired(event.Changes) logger.Info("ops config reloaded", zap.Bool("restart_required", restartRequired)) if restartRequired { logger.Warn("ops config reload changed connection or listener settings; restart process to apply those fields") } } } func (s *Store) notifyReload() { if !s.consumeFileChange() { return } select { case s.updates <- struct{}{}: default: } } func (s *Store) consumeFileChange() bool { s.mu.Lock() defer s.mu.Unlock() next := snapshotFiles(watchFiles(s.path)) if len(s.watchedFiles) == 0 { s.watchedFiles = next return true } changed := !fileSnapshotsEqual(s.watchedFiles, next) if changed { s.watchedFiles = next } return changed } func (s *Store) newRuntimeConfig() configruntime.Config { return configruntime.New(configruntime.WithSource( runtimefile.NewSource(filepath.Dir(s.path), watchNames(s.path)...), runtimeenv.NewSource("OPS"), )) } func Diff(previous, current *Config) []FieldChange { if previous == nil || current == nil { return nil } changes := make([]FieldChange, 0) add := func(path string, hot bool) { changes = append(changes, FieldChange{Path: path, Hot: hot}) } if !reflect.DeepEqual(previous.Server, current.Server) { add("server", false) } if previous.Database != current.Database { add("database", false) } if previous.MonitoringDatabase != current.MonitoringDatabase { add("monitoring_database", false) } if previous.Redis != current.Redis { add("redis", false) } if previous.Cache != current.Cache { add("cache", false) } if previous.RabbitMQ != current.RabbitMQ { add("rabbitmq", false) } if previous.Log != current.Log { add("log", false) } if previous.IPRegion != current.IPRegion { add("ip_region", false) } if previous.JWT != current.JWT { add("jwt", true) } if previous.AdminUsers != current.AdminUsers { add("admin_users", true) } if previous.DefaultAdmin != current.DefaultAdmin { add("default_admin", true) } return changes } func RestartRequired(changes []FieldChange) bool { for _, change := range changes { if !change.Hot { return true } } return false } func load(path string) (*Config, error) { resolved := configruntime.New(configruntime.WithSource( runtimefile.NewSource(path), runtimeenv.NewSource("OPS"), )) defer resolved.Close() if err := resolved.Load(); err != nil { return nil, fmt.Errorf("read config: %w", err) } settings := defaultSettings() if err := resolved.Scan(&settings); err != nil { return nil, fmt.Errorf("scan config: %w", err) } applySharedDefaults(settings) var cfg Config decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ Result: &cfg, TagName: "mapstructure", WeaklyTypedInput: true, DecodeHook: mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToSliceHookFunc(","), ), }) if err != nil { return nil, err } if err := decoder.Decode(settings); err != nil { return nil, fmt.Errorf("unmarshal config: %w", err) } if err := applyEnvOverrides(&cfg); err != nil { return nil, err } normalizeConfig(&cfg) if cfg.JWT.Secret == "" { return nil, fmt.Errorf("jwt.secret is required (set jwt.secret in yaml or OPS_JWT_SECRET env)") } if cfg.MonitoringDatabase.DBName == "" { cfg.MonitoringDatabase = cfg.Database } return &cfg, nil } func applySharedDefaults(settings map[string]any) { cacheSettings := ensureMapSetting(settings, "cache") if _, ok := cacheSettings["metrics_enabled"]; !ok { cacheSettings["metrics_enabled"] = true } } func ensureMapSetting(settings map[string]any, key string) map[string]any { if settings == nil { return map[string]any{} } if existing, ok := settings[key].(map[string]any); ok { return existing } next := make(map[string]any) settings[key] = next return next } func normalizeConfig(cfg *Config) { if cfg.Server.Port == 0 { cfg.Server.Port = 8090 } if strings.TrimSpace(cfg.Server.Mode) == "" { cfg.Server.Mode = "debug" } sharedconfig.NormalizeServerConfig(&cfg.Server) if strings.TrimSpace(cfg.Redis.Addr) == "" { cfg.Redis.Addr = "localhost:6379" } sharedconfig.NormalizeRedisConfig(&cfg.Redis) if strings.TrimSpace(cfg.Cache.Driver) == "" { cfg.Cache.Driver = "redis" } sharedconfig.NormalizeCacheConfig(&cfg.Cache) normalizeOpsRabbitMQConfig(&cfg.RabbitMQ) if strings.TrimSpace(cfg.Log.Level) == "" { cfg.Log.Level = "info" } if strings.TrimSpace(cfg.Log.Format) == "" { cfg.Log.Format = "json" } if cfg.JWT.AccessTTL <= 0 { cfg.JWT.AccessTTL = 8 * time.Hour } if strings.TrimSpace(cfg.DefaultAdmin.Username) == "" { cfg.DefaultAdmin.Username = "admin" } if strings.TrimSpace(cfg.DefaultAdmin.DisplayName) == "" { cfg.DefaultAdmin.DisplayName = "Administrator" } if strings.TrimSpace(cfg.AdminUsers.DefaultPlanCode) == "" { cfg.AdminUsers.DefaultPlanCode = "free" } } func defaultSettings() map[string]any { return map[string]any{ "server": map[string]any{ "port": 8090, "mode": "debug", "trusted_proxies": sharedconfig.DefaultTrustedProxies(), }, "redis": map[string]any{ "addr": "localhost:6379", "db": 0, }, "cache": map[string]any{ "driver": "redis", }, "rabbitmq": map[string]any{}, "log": map[string]any{ "level": "info", "format": "json", }, "jwt": map[string]any{ "access_ttl": 8 * time.Hour, }, "default_admin": map[string]any{ "username": "admin", "display_name": "Administrator", }, "admin_users": map[string]any{ "default_plan_code": "free", }, "ip_region": map[string]any{ "v4_xdb_path": "", "v6_xdb_path": "", }, } } func applyEnvOverrides(cfg *Config) error { if v := os.Getenv("OPS_JWT_SECRET"); v != "" { cfg.JWT.Secret = v } if v := os.Getenv("OPS_DEFAULT_ADMIN_USERNAME"); v != "" { cfg.DefaultAdmin.Username = v } if v := os.Getenv("OPS_DEFAULT_ADMIN_PASSWORD"); v != "" { cfg.DefaultAdmin.Password = v } if v := os.Getenv("OPS_DEFAULT_ADMIN_DISPLAY_NAME"); v != "" { cfg.DefaultAdmin.DisplayName = v } if v := os.Getenv("OPS_DEFAULT_ADMIN_EMAIL"); v != "" { cfg.DefaultAdmin.Email = v } if v := os.Getenv("OPS_ADMIN_USERS_DEFAULT_PLAN_CODE"); v != "" { cfg.AdminUsers.DefaultPlanCode = v } if v := os.Getenv("OPS_IP_REGION_V4_XDB_PATH"); v != "" { cfg.IPRegion.V4XDBPath = v } if v := os.Getenv("OPS_IP_REGION_V6_XDB_PATH"); v != "" { cfg.IPRegion.V6XDBPath = v } if v := os.Getenv("OPS_SERVER_TRUSTED_PROXIES"); v != "" { cfg.Server.TrustedProxies = strings.Split(v, ",") } if v := os.Getenv("OPS_RABBITMQ_URL"); v != "" { cfg.RabbitMQ.URL = v } return nil } func normalizeOpsRabbitMQConfig(cfg *sharedconfig.RabbitMQConfig) { if cfg == nil { return } if strings.TrimSpace(cfg.GenerationExchange) == "" { cfg.GenerationExchange = "generation.task" } if strings.TrimSpace(cfg.GenerationRoutingKey) == "" { cfg.GenerationRoutingKey = "generation.task.run" } if strings.TrimSpace(cfg.DesktopTaskEventExchange) == "" { cfg.DesktopTaskEventExchange = "desktop.task.event" } if strings.TrimSpace(cfg.DesktopDispatchExchange) == "" { cfg.DesktopDispatchExchange = "desktop.task.dispatch" } if strings.TrimSpace(cfg.DesktopDispatchPublishPrefix) == "" { cfg.DesktopDispatchPublishPrefix = "publish" } if strings.TrimSpace(cfg.DesktopDispatchMonitorPrefix) == "" { cfg.DesktopDispatchMonitorPrefix = "monitor" } if strings.TrimSpace(cfg.TemplateAssistExchange) == "" { cfg.TemplateAssistExchange = "template.assist" } if strings.TrimSpace(cfg.TemplateAssistRoutingKey) == "" { cfg.TemplateAssistRoutingKey = "template.assist.run" } if strings.TrimSpace(cfg.KolAssistExchange) == "" { cfg.KolAssistExchange = "kol.assist" } if strings.TrimSpace(cfg.KolAssistRoutingKey) == "" { cfg.KolAssistRoutingKey = "kol.assist.run" } if strings.TrimSpace(cfg.ComplianceReviewExchange) == "" { cfg.ComplianceReviewExchange = "compliance.review" } if strings.TrimSpace(cfg.ComplianceReviewRoutingKey) == "" { cfg.ComplianceReviewRoutingKey = "compliance.review.run" } if cfg.PublishChannelPoolSize <= 0 { cfg.PublishChannelPoolSize = 16 } } type fileSnapshot struct { size int64 modTime time.Time exists bool } func snapshotFiles(files []string) map[string]fileSnapshot { snapshots := make(map[string]fileSnapshot, len(files)) for _, file := range files { if strings.TrimSpace(file) == "" { continue } abs, err := filepath.Abs(file) if err == nil { file = abs } file = filepath.Clean(file) info, err := os.Stat(file) if err != nil { snapshots[file] = fileSnapshot{} continue } snapshots[file] = fileSnapshot{ size: info.Size(), modTime: info.ModTime(), exists: true, } } return snapshots } func fileSnapshotsEqual(a, b map[string]fileSnapshot) bool { if len(a) != len(b) { return false } for key, left := range a { right, ok := b[key] if !ok || left != right { return false } } return true } func watchFiles(path string) []string { if strings.TrimSpace(path) == "" { return nil } return []string{path} } func watchNames(path string) []string { if strings.TrimSpace(path) == "" { return nil } return []string{filepath.Base(path)} }