diff --git a/README.md b/README.md index bb8e666..0069ef7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ executioner: The available values for `watcher.type` are: +- `file`: [File Watcher](docs/watchers/file_watcher.md) - `gce_metadata`: [GCE Metadata Watcher](docs/watchers/gce_metadata_watcher.md) - `time`: [Time Watcher](docs/watchers/time_watcher.md) diff --git a/docs/watchers/file_watcher.md b/docs/watchers/file_watcher.md new file mode 100644 index 0000000..15975de --- /dev/null +++ b/docs/watchers/file_watcher.md @@ -0,0 +1,37 @@ +# File Watcher + +The File Watcher allows you to trigger an action when a file is changed. When a +change is detected, Goverseer can trigger an executioner to take action. The +path to the changed file is passed to the executioner for processing. + +## Configuration + +To use the File Watcher, you need to configure it in your Goverseer config file. +The following configuration options are available: + +- `path`: This is the path to the file that should be monitored for changes +- `poll_seconds`: (Optional) This specifies the frequency in seconds for + checking if the file has been modified. Defaults to `5` if not provided. + +**Example Configuration:** + +```yaml +watcher: + type: file + config: + path: /path/to/file + poll_seconds: 10 +executioner: + type: log +``` + +This configuration would check the file located at `/path/to/file` for a changed +timestamp every 10 seconds. If the file has been modified, it would trigger the +log executioner. + +**Note:** + +- The File Watcher will only trigger the executioner if the modification + timestamp of the file being monitored has been updated since the last check. +- Consider the resource consumption when choosing a polling interval, as + frequent checks can impact performance. diff --git a/internal/goverseer/watcher/file_watcher/file_watcher.go b/internal/goverseer/watcher/file_watcher/file_watcher.go new file mode 100644 index 0000000..3c27f3c --- /dev/null +++ b/internal/goverseer/watcher/file_watcher/file_watcher.go @@ -0,0 +1,128 @@ +package file_watcher + +import ( + "fmt" + "log/slog" + "os" + "time" + + "github.com/lmittmann/tint" + "github.com/simplifi/goverseer/internal/goverseer/config" +) + +const ( + // DefaultPollSeconds is the default number of seconds to wait between polls + DefaultPollSeconds = 5 +) + +// FileWatcherConfig is the configuration for a file watcher +type FileWatcherConfig struct { + // Path is the path to the file to watch + Path string + + // PollSeconds is the number of seconds to wait between ticks + PollSeconds int +} + +// ParseConfig parses the config for a file watcher +// It validates the config, sets defaults if missing, and returns the config +func ParseConfig(config interface{}) (*FileWatcherConfig, error) { + cfgMap, ok := config.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid config") + } + + cfg := &FileWatcherConfig{ + PollSeconds: DefaultPollSeconds, + } + + // Path is required and must be a string + if path, ok := cfgMap["path"].(string); ok { + if path == "" { + return nil, fmt.Errorf("path must not be empty") + } + cfg.Path = path + } else if cfgMap["path"] != nil { + return nil, fmt.Errorf("path must be a string") + } else { + return nil, fmt.Errorf("path is required") + } + + // If PollSeconds is set, it must be a positive number + if pollSeconds, ok := cfgMap["poll_seconds"].(int); ok { + if pollSeconds < 1 { + return nil, fmt.Errorf("poll_seconds must be greater than or equal to 1") + } + cfg.PollSeconds = pollSeconds + } else if cfgMap["poll_seconds"] != nil { + return nil, fmt.Errorf("poll_seconds must be an integer") + } + + return cfg, nil +} + +// FileWatcher watches a file for changes and sends the path thru change channel +type FileWatcher struct { + // Path is the path to the file to watch + Path string + + // PollInterval is the interval to poll the file for changes + PollInterval time.Duration + + // lastValue is the last time the file was modified + lastValue time.Time + + // log is the logger + log *slog.Logger + + // stop is a channel to signal the watcher to stop + stop chan struct{} +} + +// New creates a new FileWatcher based on the config +func New(cfg config.Config, log *slog.Logger) (*FileWatcher, error) { + tcfg, err := ParseConfig(cfg.Watcher.Config) + if err != nil { + return nil, err + } + + return &FileWatcher{ + Path: tcfg.Path, + PollInterval: time.Duration(tcfg.PollSeconds) * time.Second, + lastValue: time.Now(), + log: log, + stop: make(chan struct{}), + }, nil +} + +// Watch watches the file for changes and sends the path to the changes channel +// The changes channel is where the path to the file is sent when it changes +func (w *FileWatcher) Watch(changes chan interface{}) { + w.log.Info("starting watcher") + + for { + select { + case <-w.stop: + return + case <-time.After(w.PollInterval): + info, err := os.Stat(w.Path) + if err != nil { + w.log.Error("error getting file info", + slog.String("path", w.Path), tint.Err(err)) + } + if err == nil && info.ModTime().After(w.lastValue) { + w.log.Info("file changed", + slog.String("path", w.Path), + slog.Time("mod_time", info.ModTime())) + w.lastValue = info.ModTime() + changes <- w.Path + } + } + } +} + +// Stop signals the watcher to stop +func (w *FileWatcher) Stop() { + w.log.Info("shutting down watcher") + close(w.stop) +} diff --git a/internal/goverseer/watcher/file_watcher/file_watcher_test.go b/internal/goverseer/watcher/file_watcher/file_watcher_test.go new file mode 100644 index 0000000..d54b33e --- /dev/null +++ b/internal/goverseer/watcher/file_watcher/file_watcher_test.go @@ -0,0 +1,204 @@ +package file_watcher + +import ( + "log/slog" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/lmittmann/tint" + "github.com/simplifi/goverseer/internal/goverseer/config" + "github.com/stretchr/testify/assert" +) + +func touchFile(t *testing.T, filename string) { + t.Helper() + + currentTime := time.Now() + + _, err := os.Stat(filename) + if os.IsNotExist(err) { + file, err := os.Create(filename) + if err != nil { + t.Fatalf("Failed to create temporary file: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("Failed to close temporary file: %v", err) + } + } + + if err := os.Chtimes(filename, currentTime, currentTime); err != nil { + t.Fatalf("Failed to change file times: %v", err) + } +} + +func TestParseConfig(t *testing.T) { + var parsedConfig *FileWatcherConfig + var err error + + parsedConfig, err = ParseConfig(map[string]interface{}{ + "path": "/tmp/test", + "poll_seconds": DefaultPollSeconds, + }) + assert.NoError(t, err, + "Parsing a valid config should not return an error") + assert.Equal(t, DefaultPollSeconds, parsedConfig.PollSeconds, + "PollSeconds should be set to the value in the config") + + // Test setting the path + parsedConfig, err = ParseConfig(map[string]interface{}{ + "path": "/tmp/test", + }) + assert.NoError(t, err, + "Parsing a config with a valid path should not return an error") + assert.Equal(t, "/tmp/test", parsedConfig.Path, + "Path should be set to the value in the config") + + _, err = ParseConfig(map[string]interface{}{ + "path": 9, + }) + assert.Error(t, err, + "Parsing a config with an invalid path should return an error") + + // Test setting PollSeconds + parsedConfig, err = ParseConfig(map[string]interface{}{ + "path": "/tmp/test", + "poll_seconds": 10, + }) + assert.NoError(t, err, + "Parsing a config with valid poll_seconds should not return an error") + assert.Equal(t, 10, parsedConfig.PollSeconds, + "PollSeconds should be set to the value in the config") + + _, err = ParseConfig(map[string]interface{}{ + "path": "/tmp/test", + "poll_seconds": 0, + }) + assert.Error(t, err, + "Parsing a config with poll_seconds less than 1 should return an error") +} + +func TestNew(t *testing.T) { + var cfg config.Config + cfg = config.Config{ + Name: "TestConfig", + Watcher: config.WatcherConfig{ + Type: "file", + Config: map[string]interface{}{ + "path": "/tmp/test", + }, + }, + } + watcher, err := New(cfg, nil) + assert.NoError(t, err, + "Creating a new FileWatcher should not return an error") + assert.NotNil(t, watcher, + "Creating a new FileWatcher should return a watcher") + + cfg = config.Config{ + Name: "TestConfig", + Watcher: config.WatcherConfig{ + Type: "file", + Config: map[string]interface{}{ + "path": nil, + }, + }, + } + watcher, err = New(cfg, nil) + assert.Error(t, err, + "Creating a new FileWatcher with an invalid config should return an error") + assert.Nil(t, watcher, + "Creating a new FileWatcher with an invalid config should not return a watcher") +} + +func TestFileWatcher_Watch(t *testing.T) { + log := slog.New(tint.NewHandler(os.Stderr, &tint.Options{Level: slog.LevelError})) + + // Create a temp file we can watch + testFilePath := filepath.Join(t.TempDir(), "test.txt") + touchFile(t, testFilePath) + + cfg := config.Config{ + Name: "TestConfig", + Watcher: config.WatcherConfig{ + Type: "file", + Config: map[string]interface{}{ + "path": testFilePath, + "poll_seconds": 1, // Set a short poll interval for testing + }, + }, + Executioner: config.ExecutionerConfig{}, + } + + // Create a channel to receive changes + changes := make(chan interface{}) + wg := &sync.WaitGroup{} + + // Create a new FileWatcher + watcher, err := New(cfg, log) + assert.NoError(t, err) + + // Start watching the file + wg.Add(1) + go func() { + defer wg.Done() + watcher.Watch(changes) + }() + + // Touch the file to trigger a change + touchFile(t, testFilePath) + + // Assert that the tick was detected + // We limit the time to avoid hanging tests + select { + case path := <-changes: + assert.Equal(t, testFilePath, path) + case <-time.After(2 * time.Second): + assert.Fail(t, "Timed out waiting for file change") + } + + // Stop the watcher + watcher.Stop() + wg.Wait() +} + +func TestFileWatcher_Stop(t *testing.T) { + log := slog.New(tint.NewHandler(os.Stderr, &tint.Options{Level: slog.LevelError})) + + // Create a temp file we can watch + testFilePath := filepath.Join(t.TempDir(), "test.txt") + touchFile(t, testFilePath) + + watcher := FileWatcher{ + Path: testFilePath, + PollInterval: 1 * time.Second, + lastValue: time.Now(), + log: log, + stop: make(chan struct{}), + } + + changes := make(chan interface{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + watcher.Watch(changes) + }() + + // Stop the watcher and wait + watcher.Stop() + wg.Wait() + + // Trigger a change by touching the test file + touchFile(t, testFilePath) + + // Assert that the change was NOT received + select { + case <-changes: + assert.Fail(t, "Received change after stopping watcher") + case <-time.After(1 * time.Second): + // Success + } +} diff --git a/internal/goverseer/watcher/watcher.go b/internal/goverseer/watcher/watcher.go index f4d694c..efbe4fe 100644 --- a/internal/goverseer/watcher/watcher.go +++ b/internal/goverseer/watcher/watcher.go @@ -7,6 +7,7 @@ import ( "github.com/lmittmann/tint" "github.com/simplifi/goverseer/internal/goverseer/config" + "github.com/simplifi/goverseer/internal/goverseer/watcher/file_watcher" "github.com/simplifi/goverseer/internal/goverseer/watcher/gce_metadata_watcher" "github.com/simplifi/goverseer/internal/goverseer/watcher/time_watcher" ) @@ -27,6 +28,8 @@ func New(cfg *config.Config) (Watcher, error) { With("watcher", cfg.Watcher.Type) switch cfg.Watcher.Type { + case "file": + return file_watcher.New(*cfg, logger) case "time": return time_watcher.New(*cfg, logger) case "gce_metadata":