// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package config import ( "context" "fmt" "os" "path/filepath" "sync" "time" "github.com/fsnotify/fsnotify" "github.com/hashicorp/go-hclog" ) const timeoutDuration = 200 * time.Millisecond type Watcher interface { Start(ctx context.Context) Stop() error Add(filename string) error Remove(filename string) Replace(oldFile, newFile string) error EventsCh() chan *FileWatcherEvent } type fileWatcher struct { watcher *fsnotify.Watcher configFiles map[string]*watchedFile configFilesLock sync.RWMutex logger hclog.Logger reconcileTimeout time.Duration cancel context.CancelFunc done chan interface{} stopOnce sync.Once //eventsCh Channel where an event will be emitted when a file change is detected // a call to Start is needed before any event is emitted // after a Call to Stop succeed, the channel will be closed eventsCh chan *FileWatcherEvent } type watchedFile struct { modTime time.Time } type FileWatcherEvent struct { Filenames []string } // NewFileWatcher create a file watcher that will watch all the files/folders from configFiles // if success a fileWatcher will be returned and a nil error // otherwise an error and a nil fileWatcher are returned func NewFileWatcher(configFiles []string, logger hclog.Logger) (Watcher, error) { ws, err := fsnotify.NewWatcher() if err != nil { return nil, err } w := &fileWatcher{ watcher: ws, logger: logger.Named("file-watcher"), configFiles: make(map[string]*watchedFile), eventsCh: make(chan *FileWatcherEvent), reconcileTimeout: timeoutDuration, done: make(chan interface{}), } for _, f := range configFiles { err = w.Add(f) if err != nil { return nil, fmt.Errorf("error adding file %q: %w", f, err) } } return w, nil } // Start start a file watcher, with a copy of the passed context. // calling Start multiple times is a noop func (w *fileWatcher) Start(ctx context.Context) { if w.cancel == nil { cancelCtx, cancel := context.WithCancel(ctx) w.cancel = cancel go w.watch(cancelCtx) } } // Stop the file watcher // calling Stop multiple times is a noop, Stop must be called after a Start func (w *fileWatcher) Stop() error { var err error w.stopOnce.Do(func() { w.cancel() <-w.done err = w.watcher.Close() }) return err } // Add a file to the file watcher // Add will lock the file watcher during the add func (w *fileWatcher) Add(filename string) error { filename = filepath.Clean(filename) w.logger.Trace("adding file", "file", filename) if err := w.watcher.Add(filename); err != nil { return err } modTime, err := w.getFileModifiedTime(filename) if err != nil { return err } w.addFile(filename, modTime) return nil } // Remove a file from the file watcher // Remove will lock the file watcher during the remove func (w *fileWatcher) Remove(filename string) { w.removeFile(filename) } // Replace a file in the file watcher // Replace will lock the file watcher during the replace func (w *fileWatcher) Replace(oldFile, newFile string) error { if oldFile == newFile { return nil } newFile = filepath.Clean(newFile) w.logger.Trace("adding file", "file", newFile) if err := w.watcher.Add(newFile); err != nil { return err } modTime, err := w.getFileModifiedTime(newFile) if err != nil { return err } w.replaceFile(oldFile, newFile, modTime) return nil } func (w *fileWatcher) replaceFile(oldFile, newFile string, modTime time.Time) { w.configFilesLock.Lock() defer w.configFilesLock.Unlock() delete(w.configFiles, oldFile) w.configFiles[newFile] = &watchedFile{modTime: modTime} } func (w *fileWatcher) addFile(filename string, modTime time.Time) { w.configFilesLock.Lock() defer w.configFilesLock.Unlock() w.configFiles[filename] = &watchedFile{modTime: modTime} } func (w *fileWatcher) removeFile(filename string) { w.configFilesLock.Lock() defer w.configFilesLock.Unlock() delete(w.configFiles, filename) } func (w *fileWatcher) EventsCh() chan *FileWatcherEvent { return w.eventsCh } func (w *fileWatcher) watch(ctx context.Context) { ticker := time.NewTicker(w.reconcileTimeout) defer ticker.Stop() defer close(w.done) defer close(w.eventsCh) for { select { case event, ok := <-w.watcher.Events: if !ok { w.logger.Error("watcher event channel is closed") return } w.logger.Trace("received watcher event", "event", event) if err := w.handleEvent(ctx, event); err != nil { w.logger.Error("error handling watcher event", "error", err, "event", event) } case _, ok := <-w.watcher.Errors: if !ok { w.logger.Error("watcher error channel is closed") return } case <-ticker.C: w.reconcile(ctx) case <-ctx.Done(): return } } } func (w *fileWatcher) handleEvent(ctx context.Context, event fsnotify.Event) error { w.logger.Trace("event received ", "filename", event.Name, "OP", event.Op) // we only want Create and Remove events to avoid triggering a reload on file modification if !isCreateEvent(event) && !isRemoveEvent(event) && !isWriteEvent(event) && !isRenameEvent(event) { return nil } filename := filepath.Clean(event.Name) configFile, basename, ok := w.isWatched(filename) if !ok { return fmt.Errorf("file %s is not watched", event.Name) } // we only want to update mod time and re-add if the event is on the watched file itself if filename == basename { if isRemoveEvent(event) { // If the file was removed, try to reconcile and see if anything changed. w.logger.Trace("attempt a reconcile ", "filename", event.Name, "OP", event.Op) configFile.modTime = time.Time{} w.reconcile(ctx) } } if isCreateEvent(event) || isWriteEvent(event) || isRenameEvent(event) { w.logger.Trace("call the handler", "filename", event.Name, "OP", event.Op) select { case w.eventsCh <- &FileWatcherEvent{Filenames: []string{filename}}: case <-ctx.Done(): return ctx.Err() } } return nil } func (w *fileWatcher) isWatched(filename string) (*watchedFile, string, bool) { path := filename w.configFilesLock.RLock() configFile, ok := w.configFiles[path] w.configFilesLock.RUnlock() if ok { return configFile, path, true } stat, err := os.Lstat(filename) // if the error is a not exist still try to find if the event for a configured file if os.IsNotExist(err) || (!stat.IsDir() && stat.Mode()&os.ModeSymlink == 0) { w.logger.Trace("not a dir and not a symlink to a dir") // try to see if the watched path is the parent dir newPath := filepath.Dir(path) w.logger.Trace("get dir", "dir", newPath) w.configFilesLock.RLock() configFile, ok = w.configFiles[newPath] w.configFilesLock.RUnlock() } return configFile, path, ok } func (w *fileWatcher) reconcile(ctx context.Context) { w.configFilesLock.Lock() defer w.configFilesLock.Unlock() for filename, configFile := range w.configFiles { newModTime, err := w.getFileModifiedTime(filename) if err != nil { w.logger.Error("failed to get file modTime", "file", filename, "err", err) continue } err = w.watcher.Add(filename) if err != nil { w.logger.Error("failed to add file to watcher", "file", filename, "err", err) continue } if !configFile.modTime.Equal(newModTime) { w.logger.Trace("call the handler", "filename", filename, "old modTime", configFile.modTime, "new modTime", newModTime) configFile.modTime = newModTime select { case w.eventsCh <- &FileWatcherEvent{Filenames: []string{filename}}: case <-ctx.Done(): return } } } } func isCreateEvent(event fsnotify.Event) bool { return event.Op&fsnotify.Create == fsnotify.Create } func isRemoveEvent(event fsnotify.Event) bool { return event.Op&fsnotify.Remove == fsnotify.Remove } func isWriteEvent(event fsnotify.Event) bool { return event.Op&fsnotify.Write == fsnotify.Write } func isRenameEvent(event fsnotify.Event) bool { return event.Op&fsnotify.Rename == fsnotify.Rename } func (w *fileWatcher) getFileModifiedTime(filename string) (time.Time, error) { fileInfo, err := os.Stat(filename) if err != nil { return time.Time{}, err } return fileInfo.ModTime(), err }