-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'main' into fix-empty-config
- Loading branch information
Showing
5 changed files
with
373 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
128
internal/goverseer/watcher/file_watcher/file_watcher.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
204
internal/goverseer/watcher/file_watcher/file_watcher_test.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters