Skip to content

Commit

Permalink
Merge branch 'main' into fix-empty-config
Browse files Browse the repository at this point in the history
  • Loading branch information
cjonesy authored Sep 13, 2024
2 parents 35dbe58 + f81e664 commit fe38b3a
Show file tree
Hide file tree
Showing 5 changed files with 373 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
37 changes: 37 additions & 0 deletions docs/watchers/file_watcher.md
Original file line number Diff line number Diff line change
@@ -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.
128 changes: 128 additions & 0 deletions internal/goverseer/watcher/file_watcher/file_watcher.go
Original file line number Diff line number Diff line change
@@ -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)
}
204 changes: 204 additions & 0 deletions internal/goverseer/watcher/file_watcher/file_watcher_test.go
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 3 additions & 0 deletions internal/goverseer/watcher/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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":
Expand Down

0 comments on commit fe38b3a

Please sign in to comment.