package config import ( "context" "errors" "fmt" "os" "path/filepath" "sync" "time" "go.uber.org/zap" "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 Store struct { path string mu sync.RWMutex cfg *Config files []string runtime runtime.Config watchedFiles map[string]fileSnapshot updates chan struct{} ctx context.Context cancel context.CancelFunc } type ReloadEvent struct { Previous *Config Current *Config Files []string Changes []FieldChange } func NewStore(configPath string) (*Store, error) { cfg, files, err := loadWithFiles(configPath) if err != nil { return nil, err } ctx, cancel := context.WithCancel(context.Background()) store := &Store{ path: configPath, cfg: cfg, files: normalizeConfigFiles(files), watchedFiles: snapshotFiles(nil), updates: make(chan struct{}, 1), ctx: ctx, cancel: cancel, } store.watchedFiles = snapshotFiles(store.watchFiles()) store.runtime = store.newRuntimeConfig() return store, nil } func NewStaticStore(cfg *Config) (*Store, error) { if cfg == nil { return nil, errors.New("config is nil") } return &Store{cfg: cfg}, 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 errors.New("config store is nil") } if s.runtime == nil { s.runtime = s.newRuntimeConfig() } if err := s.runtime.Load(); err != nil { return fmt.Errorf("load config runtime source: %w", err) } if err := s.runtime.Watch("", func(string, runtime.Value) { s.notifyReload() }); err != nil { _ = s.runtime.Close() return fmt.Errorf("watch config runtime source: %w", err) } if logger != nil { logger.Info("config hot reload watcher started", zap.Strings("files", s.files)) } for { select { case <-ctx.Done(): return nil case <-s.ctx.Done(): return nil case <-s.updates: s.reload(logger, onReload) } } } func (s *Store) Close() { if s == nil { return } if s.cancel != nil { s.cancel() } if s.runtime != nil { _ = s.runtime.Close() } } func (s *Store) reload(logger *zap.Logger, onReload func(ReloadEvent)) { next, files, err := loadWithFiles(s.path) if err != nil { if logger != nil { logger.Warn("config reload rejected", zap.Error(err)) } return } files = normalizeConfigFiles(files) s.mu.Lock() previous := s.cfg s.cfg = next s.files = files s.watchedFiles = snapshotFiles(s.watchFiles()) s.mu.Unlock() changes := Diff(previous, next) if logger != nil { logger.Info("config reloaded", zap.Strings("files", files), zap.Strings("hot_changes", ChangePaths(changes, true)), zap.Strings("restart_required_changes", ChangePaths(changes, false)), ) } if onReload != nil { onReload(ReloadEvent{ Previous: previous, Current: next, Files: files, Changes: changes, }) } } func (s *Store) newRuntimeConfig() runtime.Config { return runtime.New(runtime.WithSource( runtimefile.NewSource(s.watchRoot(), s.watchNames()...), runtimeenv.NewSource(), )) } func (s *Store) watchRoot() string { for _, candidate := range candidateConfigPaths(s.path, false) { if candidate == "" { continue } return filepath.Dir(candidate) } return filepath.Dir(s.path) } 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(s.watchFiles()) 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) watchNames() []string { names := make([]string, 0, 4) seen := make(map[string]struct{}) for _, local := range []bool{false, true} { for _, candidate := range candidateConfigPaths(s.path, local) { if candidate == "" { continue } name := filepath.Base(candidate) if _, ok := seen[name]; ok { continue } seen[name] = struct{}{} names = append(names, name) } } return names } func (s *Store) watchFiles() []string { files := make([]string, 0, 4) seen := make(map[string]struct{}) for _, local := range []bool{false, true} { for _, candidate := range candidateConfigPaths(s.path, local) { if candidate == "" { continue } abs, err := filepath.Abs(candidate) if err == nil { candidate = abs } candidate = filepath.Clean(candidate) if _, ok := seen[candidate]; ok { continue } seen[candidate] = struct{}{} files = append(files, candidate) } } return files } func normalizeConfigFiles(files []string) []string { seen := make(map[string]struct{}, len(files)) normalized := make([]string, 0, len(files)) for _, file := range files { if file == "" { continue } abs, err := filepath.Abs(file) if err == nil { file = abs } file = filepath.Clean(file) if _, ok := seen[file]; ok { continue } seen[file] = struct{}{} normalized = append(normalized, file) } return normalized } 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 normalizeConfigFiles(files) { 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 }