From 61fff43e288daac88efb127ada20276c01ed5928 Mon Sep 17 00:00:00 2001 From: Dave Josephsen Date: Thu, 22 Aug 2024 13:02:31 -0500 Subject: [PATCH] feat: add 'watcher' interface to file sync (#1365) Implement fsnotify and `os.Stat` based watchers fixes: #1344 ## This PR Intent of this PR is to begin a conversation about fixing #1344. The approach taken is to replace the current use of `fsontify.Watcher` with a local `Watcher` interface type that describes the `fsnotify.Watcher` interface. My original take was to use fsnotify.Watcher directly as an implementation of local `Watcher`, but fsnotify's Watcher directly exposes its Error and Event channels, making it impossible to describe with an interface, so I had to create a small wrapper for `fsnotify.Watcher` to satisfy the new Watcher interface (this is fsnotify_watcher.go). From there, we implement the `Watcher` interface again, this time using `os.Stat` and `fs.FileInfo` (this is fileinfo_watcher.go). Then we change the filepath sync code to use an interface to Watcher, rather than fsnotify.Watcher directly. The new fileinfo watcher plugs right in, and nothing really needs to change in the sync. * I have not wired up configs, so the fileinfo watcher has a hard-coded 1-second polling interval, and there is no current means of selecting between them. * I've added a couple tests, to demonstrate how unit tests would work in general (we use a configurable os-stat func in the fileinfo watcher, which can be mocked for tests) * I don't have a way of testing this on Windows. I'm vaguely aware there's an upstream issue in package `fs` that may require some work-around boilerplate to make this work on windows at the moment. If yall are favorable to this approach, I'll finish wiring up configs, and flesh out the tests. I didn't want to go much further without some buy-in or feedback. ### Related Issues Fixes #1344 ### Notes See bullet-points above ### How to test go test -v ./... --------- Signed-off-by: Dave Josephsen Signed-off-by: Kavindu Dodanduwa Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Kavindu Dodanduwa Co-authored-by: Michael Beemer --- core/pkg/sync/builder/syncbuilder.go | 52 +++- core/pkg/sync/file/fileinfo_watcher.go | 202 ++++++++++++++++ core/pkg/sync/file/fileinfo_watcher_test.go | 248 ++++++++++++++++++++ core/pkg/sync/file/filepath_sync.go | 57 +++-- core/pkg/sync/file/fsnotify_watcher.go | 67 ++++++ docs/reference/sync-configuration.md | 17 +- 6 files changed, 617 insertions(+), 26 deletions(-) create mode 100644 core/pkg/sync/file/fileinfo_watcher.go create mode 100644 core/pkg/sync/file/fileinfo_watcher_test.go create mode 100644 core/pkg/sync/file/fsnotify_watcher.go diff --git a/core/pkg/sync/builder/syncbuilder.go b/core/pkg/sync/builder/syncbuilder.go index a923a331f..d5e6a1a9f 100644 --- a/core/pkg/sync/builder/syncbuilder.go +++ b/core/pkg/sync/builder/syncbuilder.go @@ -5,7 +5,6 @@ import ( "net/http" "os" "regexp" - msync "sync" "time" "github.com/open-feature/flagd/core/pkg/logger" @@ -26,6 +25,8 @@ import ( const ( syncProviderFile = "file" + syncProviderFsNotify = "fsnotify" + syncProviderFileInfo = "fileinfo" syncProviderGrpc = "grpc" syncProviderKubernetes = "kubernetes" syncProviderHTTP = "http" @@ -91,8 +92,13 @@ func (sb *SyncBuilder) SyncsFromConfig(sourceConfigs []sync.SourceConfig, logger func (sb *SyncBuilder) syncFromConfig(sourceConfig sync.SourceConfig, logger *logger.Logger) (sync.ISync, error) { switch sourceConfig.Provider { case syncProviderFile: - logger.Debug(fmt.Sprintf("using filepath sync-provider for: %q", sourceConfig.URI)) return sb.newFile(sourceConfig.URI, logger), nil + case syncProviderFsNotify: + logger.Debug(fmt.Sprintf("using fsnotify sync-provider for: %q", sourceConfig.URI)) + return sb.newFsNotify(sourceConfig.URI, logger), nil + case syncProviderFileInfo: + logger.Debug(fmt.Sprintf("using fileinfo sync-provider for: %q", sourceConfig.URI)) + return sb.newFileInfo(sourceConfig.URI, logger), nil case syncProviderKubernetes: logger.Debug(fmt.Sprintf("using kubernetes sync-provider for: %s", sourceConfig.URI)) return sb.newK8s(sourceConfig.URI, logger) @@ -107,20 +113,46 @@ func (sb *SyncBuilder) syncFromConfig(sourceConfig sync.SourceConfig, logger *lo return sb.newGcs(sourceConfig, logger), nil default: - return nil, fmt.Errorf("invalid sync provider: %s, must be one of with '%s', '%s', '%s' or '%s'", - sourceConfig.Provider, syncProviderFile, syncProviderKubernetes, syncProviderHTTP, syncProviderKubernetes) + return nil, fmt.Errorf("invalid sync provider: %s, must be one of with '%s', '%s', '%s', %s', '%s' or '%s'", + sourceConfig.Provider, syncProviderFile, syncProviderFsNotify, syncProviderFileInfo, + syncProviderKubernetes, syncProviderHTTP, syncProviderKubernetes) } } +// newFile returns an fsinfo sync if we are in k8s or fileinfo if not func (sb *SyncBuilder) newFile(uri string, logger *logger.Logger) *file.Sync { - return &file.Sync{ - URI: regFile.ReplaceAllString(uri, ""), - Logger: logger.WithFields( + switch os.Getenv("KUBERNETES_SERVICE_HOST") { + case "": + // no k8s service host env; use fileinfo + return sb.newFileInfo(uri, logger) + default: + // default to fsnotify + return sb.newFsNotify(uri, logger) + } +} + +// return a new file.Sync that uses fsnotify under the hood +func (sb *SyncBuilder) newFsNotify(uri string, logger *logger.Logger) *file.Sync { + return file.NewFileSync( + regFile.ReplaceAllString(uri, ""), + file.FSNOTIFY, + logger.WithFields( zap.String("component", "sync"), - zap.String("sync", "filepath"), + zap.String("sync", syncProviderFsNotify), ), - Mux: &msync.RWMutex{}, - } + ) +} + +// return a new file.Sync that uses os.Stat/fs.FileInfo under the hood +func (sb *SyncBuilder) newFileInfo(uri string, logger *logger.Logger) *file.Sync { + return file.NewFileSync( + regFile.ReplaceAllString(uri, ""), + file.FILEINFO, + logger.WithFields( + zap.String("component", "sync"), + zap.String("sync", syncProviderFileInfo), + ), + ) } func (sb *SyncBuilder) newK8s(uri string, logger *logger.Logger) (*kubernetes.Sync, error) { diff --git a/core/pkg/sync/file/fileinfo_watcher.go b/core/pkg/sync/file/fileinfo_watcher.go new file mode 100644 index 000000000..21173ae36 --- /dev/null +++ b/core/pkg/sync/file/fileinfo_watcher.go @@ -0,0 +1,202 @@ +package file + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/open-feature/flagd/core/pkg/logger" +) + +// Implements file.Watcher using a timer and os.FileInfo +type fileInfoWatcher struct { + // Event Chan + evChan chan fsnotify.Event + // Errors Chan + erChan chan error + // logger + logger *logger.Logger + // Func to wrap os.Stat (injection point for test helpers) + statFunc func(string) (fs.FileInfo, error) + // thread-safe interface to underlying files we are watching + mu sync.RWMutex + watches map[string]fs.FileInfo // filename -> info +} + +// NewFsNotifyWatcher returns a new fsNotifyWatcher +func NewFileInfoWatcher(ctx context.Context, logger *logger.Logger) Watcher { + fiw := &fileInfoWatcher{ + evChan: make(chan fsnotify.Event, 32), + erChan: make(chan error, 32), + statFunc: getFileInfo, + logger: logger, + watches: make(map[string]fs.FileInfo), + } + fiw.run(ctx, (1 * time.Second)) + return fiw +} + +// fileInfoWatcher explicitly implements file.Watcher +var _ Watcher = &fileInfoWatcher{} + +// Close calls close on the underlying fsnotify.Watcher +func (f *fileInfoWatcher) Close() error { + // close all channels and exit + close(f.evChan) + close(f.erChan) + return nil +} + +// Add calls Add on the underlying fsnotify.Watcher +func (f *fileInfoWatcher) Add(name string) error { + f.mu.Lock() + defer f.mu.Unlock() + + // exit early if name already exists + if _, ok := f.watches[name]; ok { + return nil + } + + info, err := f.statFunc(name) + if err != nil { + return err + } + + f.watches[name] = info + + return nil +} + +// Remove calls Remove on the underlying fsnotify.Watcher +func (f *fileInfoWatcher) Remove(name string) error { + f.mu.Lock() + defer f.mu.Unlock() + + // no need to exit early, deleting non-existent key is a no-op + delete(f.watches, name) + + return nil +} + +// Watchlist calls watchlist on the underlying fsnotify.Watcher +func (f *fileInfoWatcher) WatchList() []string { + f.mu.RLock() + defer f.mu.RUnlock() + out := []string{} + for name := range f.watches { + n := name + out = append(out, n) + } + return out +} + +// Events returns the underlying watcher's Events chan +func (f *fileInfoWatcher) Events() chan fsnotify.Event { + return f.evChan +} + +// Errors returns the underlying watcher's Errors chan +func (f *fileInfoWatcher) Errors() chan error { + return f.erChan +} + +// run is a blocking function that starts the filewatcher's timer thread +func (f *fileInfoWatcher) run(ctx context.Context, s time.Duration) { + // timer thread + go func() { + // execute update on the configured interval of time + ticker := time.NewTicker(s) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := f.update(); err != nil { + f.erChan <- err + return + } + } + } + }() +} + +func (f *fileInfoWatcher) update() error { + f.mu.Lock() + defer f.mu.Unlock() + + for path, info := range f.watches { + newInfo, err := f.statFunc(path) + if err != nil { + // if the file isn't there, it must have been removed + // fire off a remove event and remove it from the watches + if errors.Is(err, os.ErrNotExist) { + f.evChan <- fsnotify.Event{ + Name: path, + Op: fsnotify.Remove, + } + delete(f.watches, path) + continue + } + return err + } + + // if the new stat doesn't match the old stat, figure out what changed + if info != newInfo { + event := f.generateEvent(path, newInfo) + if event != nil { + f.evChan <- *event + } + f.watches[path] = newInfo + } + } + return nil +} + +// generateEvent figures out what changed and generates an fsnotify.Event for it. (if we care) +// file removal are handled above in the update() method +func (f *fileInfoWatcher) generateEvent(path string, newInfo fs.FileInfo) *fsnotify.Event { + info := f.watches[path] + switch { + // new mod time is more recent than old mod time, generate a write event + case newInfo.ModTime().After(info.ModTime()): + return &fsnotify.Event{ + Name: path, + Op: fsnotify.Write, + } + // the file modes changed, generate a chmod event + case info.Mode() != newInfo.Mode(): + return &fsnotify.Event{ + Name: path, + Op: fsnotify.Chmod, + } + // nothing changed that we care about + default: + return nil + } +} + +// getFileInfo returns the fs.FileInfo for the given path +func getFileInfo(path string) (fs.FileInfo, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("error from os.Open(%s): %w", path, err) + } + + info, err := f.Stat() + if err != nil { + return info, fmt.Errorf("error from fs.Stat(%s): %w", path, err) + } + + if err := f.Close(); err != nil { + return info, fmt.Errorf("err from fs.Close(%s): %w", path, err) + } + + return info, nil +} diff --git a/core/pkg/sync/file/fileinfo_watcher_test.go b/core/pkg/sync/file/fileinfo_watcher_test.go new file mode 100644 index 000000000..5917e9ab6 --- /dev/null +++ b/core/pkg/sync/file/fileinfo_watcher_test.go @@ -0,0 +1,248 @@ +package file + +import ( + "errors" + "fmt" + "io/fs" + "os" + "testing" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/google/go-cmp/cmp" +) + +func Test_fileInfoWatcher_Close(t *testing.T) { + tests := []struct { + name string + watcher *fileInfoWatcher + wantErr bool + }{ + { + name: "all chans close", + watcher: makeTestWatcher(t, map[string]fs.FileInfo{}), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.watcher.Close(); (err != nil) != tt.wantErr { + t.Errorf("fileInfoWatcher.Close() error = %v, wantErr %v", err, tt.wantErr) + } + if _, ok := (<-tt.watcher.Errors()); ok != false { + t.Error("fileInfoWatcher.Close() failed to close error chan") + } + if _, ok := (<-tt.watcher.Events()); ok != false { + t.Error("fileInfoWatcher.Close() failed to close events chan") + } + }) + } +} + +func Test_fileInfoWatcher_Add(t *testing.T) { + tests := []struct { + name string + watcher *fileInfoWatcher + add []string + want map[string]fs.FileInfo + wantErr bool + }{ + { + name: "add one watch", + watcher: makeTestWatcher(t, map[string]fs.FileInfo{}), + add: []string{"/foo"}, + want: map[string]fs.FileInfo{ + "/foo": &mockFileInfo{}, + }, + }, + } + for _, tt := range tests { + tt.watcher.statFunc = makeStatFunc(t, &mockFileInfo{}) + t.Run(tt.name, func(t *testing.T) { + for _, path := range tt.add { + if err := tt.watcher.Add(path); (err != nil) != tt.wantErr { + t.Errorf("fileInfoWatcher.Add() error = %v, wantErr %v", err, tt.wantErr) + } + } + if !cmp.Equal(tt.watcher.watches, tt.want, cmp.AllowUnexported(mockFileInfo{})) { + t.Errorf("fileInfoWatcher.Add(): want-, got+: %v ", cmp.Diff(tt.want, tt.watcher.watches)) + } + }) + } +} + +func Test_fileInfoWatcher_Remove(t *testing.T) { + tests := []struct { + name string + watcher *fileInfoWatcher + removeThis string + want []string + }{{ + name: "remove foo", + watcher: makeTestWatcher(t, map[string]fs.FileInfo{"foo": &mockFileInfo{}, "bar": &mockFileInfo{}}), + removeThis: "foo", + want: []string{"bar"}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.watcher.Remove(tt.removeThis) + if err != nil { + t.Errorf("fileInfoWatcher.Remove() error = %v", err) + } + if !cmp.Equal(tt.watcher.WatchList(), tt.want) { + t.Errorf("fileInfoWatcher.Add(): want-, got+: %v ", cmp.Diff(tt.want, tt.watcher.WatchList())) + } + }) + } +} + +func Test_fileInfoWatcher_update(t *testing.T) { + tests := []struct { + name string + watcher *fileInfoWatcher + statFunc func(string) (fs.FileInfo, error) + wantErr bool + want *fsnotify.Event + }{ + { + name: "chmod", + watcher: makeTestWatcher(t, + map[string]fs.FileInfo{ + "foo": &mockFileInfo{ + name: "foo", + mode: 0, + }, + }, + ), + statFunc: func(_ string) (fs.FileInfo, error) { + return &mockFileInfo{ + name: "foo", + mode: 1, + }, nil + }, + want: &fsnotify.Event{Name: "foo", Op: fsnotify.Chmod}, + }, + { + name: "write", + watcher: makeTestWatcher(t, + map[string]fs.FileInfo{ + "foo": &mockFileInfo{ + name: "foo", + modTime: time.Now().Local(), + }, + }, + ), + statFunc: func(_ string) (fs.FileInfo, error) { + return &mockFileInfo{ + name: "foo", + modTime: (time.Now().Local().Add(5 * time.Minute)), + }, nil + }, + want: &fsnotify.Event{Name: "foo", Op: fsnotify.Write}, + }, + { + name: "remove", + watcher: makeTestWatcher(t, + map[string]fs.FileInfo{ + "foo": &mockFileInfo{ + name: "foo", + }, + }, + ), + statFunc: func(_ string) (fs.FileInfo, error) { + return nil, fmt.Errorf("mock file-no-existy error: %w", os.ErrNotExist) + }, + want: &fsnotify.Event{Name: "foo", Op: fsnotify.Remove}, + }, + { + name: "unknown error", + watcher: makeTestWatcher(t, + map[string]fs.FileInfo{ + "foo": &mockFileInfo{ + name: "foo", + }, + }, + ), + statFunc: func(_ string) (fs.FileInfo, error) { + return nil, errors.New("unhandled error") + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // set the statFunc + tt.watcher.statFunc = tt.statFunc + // run an update + // this also flexes fileinfowatcher.generateEvent() + err := tt.watcher.update() + if err != nil { + if tt.wantErr { + return + } + t.Errorf("fileInfoWatcher.update() unexpected error = %v, wantErr %v", err, tt.wantErr) + } + // slurp an event off the event chan + out := <-tt.watcher.Events() + if out != *tt.want { + t.Errorf("fileInfoWatcher.update() wanted %v, got %v", tt.want, out) + } + }) + } +} + +// Helpers + +// makeTestWatcher returns a pointer to a fileInfoWatcher suitable for testing +func makeTestWatcher(t *testing.T, watches map[string]fs.FileInfo) *fileInfoWatcher { + t.Helper() + + return &fileInfoWatcher{ + evChan: make(chan fsnotify.Event, 512), + erChan: make(chan error, 512), + watches: watches, + } +} + +// makeStateFunc returns an os.Stat wrapper that parrots back whatever its +// constructor is given +func makeStatFunc(t *testing.T, fi fs.FileInfo) func(string) (fs.FileInfo, error) { + t.Helper() + return func(_ string) (fs.FileInfo, error) { + return fi, nil + } +} + +// mockFileInfo implements fs.FileInfo for mocks +type mockFileInfo struct { + name string // base name of the file + size int64 // length in bytes for regular files; system-dependent for others + mode fs.FileMode // file mode bits + modTime time.Time // modification time +} + +// explicitly impements fs.FileInfo +var _ fs.FileInfo = &mockFileInfo{} + +func (mfi *mockFileInfo) Name() string { + return mfi.name +} + +func (mfi *mockFileInfo) Size() int64 { + return mfi.size +} + +func (mfi *mockFileInfo) Mode() fs.FileMode { + return mfi.mode +} + +func (mfi *mockFileInfo) ModTime() time.Time { + return mfi.modTime +} + +func (mfi *mockFileInfo) IsDir() bool { + return false +} + +func (mfi *mockFileInfo) Sys() any { + return "foo" +} diff --git a/core/pkg/sync/file/filepath_sync.go b/core/pkg/sync/file/filepath_sync.go index 6b2899a13..c67ca57c8 100644 --- a/core/pkg/sync/file/filepath_sync.go +++ b/core/pkg/sync/file/filepath_sync.go @@ -15,21 +15,38 @@ import ( "gopkg.in/yaml.v3" ) +const ( + FSNOTIFY = "fsnotify" + FILEINFO = "fileinfo" +) + +type Watcher interface { + Close() error + Add(name string) error + Remove(name string) error + WatchList() []string + Events() chan fsnotify.Event + Errors() chan error +} + type Sync struct { URI string Logger *logger.Logger // FileType indicates the file type e.g., json, yaml/yml etc., fileType string - watcher *fsnotify.Watcher - ready bool - Mux *msync.RWMutex + // watchType indicates how to watch the file FSNOTIFY|FILEINFO + watchType string + watcher Watcher + ready bool + Mux *msync.RWMutex } -func NewFileSync(uri string, logger *logger.Logger) *Sync { +func NewFileSync(uri string, watchType string, logger *logger.Logger) *Sync { return &Sync{ - URI: uri, - Logger: logger, - Mux: &msync.RWMutex{}, + URI: uri, + watchType: watchType, + Logger: logger, + Mux: &msync.RWMutex{}, } } @@ -41,14 +58,24 @@ func (fs *Sync) ReSync(ctx context.Context, dataSync chan<- sync.DataSync) error return nil } -func (fs *Sync) Init(_ context.Context) error { +func (fs *Sync) Init(ctx context.Context) error { fs.Logger.Info("Starting filepath sync notifier") - w, err := fsnotify.NewWatcher() - if err != nil { - return fmt.Errorf("error creating filepath watcher: %w", err) + + switch fs.watchType { + case FSNOTIFY, "": + w, err := NewFSNotifyWatcher() + if err != nil { + return fmt.Errorf("error creating fsnotify watcher: %w", err) + } + fs.watcher = w + case FILEINFO: + w := NewFileInfoWatcher(ctx, fs.Logger) + fs.watcher = w + default: + return fmt.Errorf("unknown watcher type: '%s'", fs.watchType) } - fs.watcher = w - if err = fs.watcher.Add(fs.URI); err != nil { + + if err := fs.watcher.Add(fs.URI); err != nil { return fmt.Errorf("error adding watcher %s: %w", fs.URI, err) } return nil @@ -74,7 +101,7 @@ func (fs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error { fs.Logger.Info(fmt.Sprintf("watching filepath: %s", fs.URI)) for { select { - case event, ok := <-fs.watcher.Events: + case event, ok := <-fs.watcher.Events(): if !ok { fs.Logger.Info("filepath notifier closed") return errors.New("filepath notifier closed") @@ -108,7 +135,7 @@ func (fs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error { } } - case err, ok := <-fs.watcher.Errors: + case err, ok := <-fs.watcher.Errors(): if !ok { fs.setReady(false) return errors.New("watcher error") diff --git a/core/pkg/sync/file/fsnotify_watcher.go b/core/pkg/sync/file/fsnotify_watcher.go new file mode 100644 index 000000000..93c98ce1c --- /dev/null +++ b/core/pkg/sync/file/fsnotify_watcher.go @@ -0,0 +1,67 @@ +package file + +import ( + "fmt" + + "github.com/fsnotify/fsnotify" +) + +// Implements file.Watcher by wrapping fsnotify.Watcher +// This is only necessary because fsnotify.Watcher directly exposes its Errors +// and Events channels rather than returning them by method invocation +type fsNotifyWatcher struct { + watcher *fsnotify.Watcher +} + +// NewFsNotifyWatcher returns a new fsNotifyWatcher +func NewFSNotifyWatcher() (Watcher, error) { + fsn, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("fsnotify: %w", err) + } + return &fsNotifyWatcher{ + watcher: fsn, + }, nil +} + +// explicitly implements file.Watcher +var _ Watcher = &fsNotifyWatcher{} + +// Close calls close on the underlying fsnotify.Watcher +func (f *fsNotifyWatcher) Close() error { + if err := f.watcher.Close(); err != nil { + return fmt.Errorf("fsnotify: %w", err) + } + return nil +} + +// Add calls Add on the underlying fsnotify.Watcher +func (f *fsNotifyWatcher) Add(name string) error { + if err := f.watcher.Add(name); err != nil { + return fmt.Errorf("fsnotify: %w", err) + } + return nil +} + +// Remove calls Remove on the underlying fsnotify.Watcher +func (f *fsNotifyWatcher) Remove(name string) error { + if err := f.watcher.Remove(name); err != nil { + return fmt.Errorf("fsnotify: %w", err) + } + return nil +} + +// Watchlist calls watchlist on the underlying fsnotify.Watcher +func (f *fsNotifyWatcher) WatchList() []string { + return f.watcher.WatchList() +} + +// Events returns the underlying watcher's Events chan +func (f *fsNotifyWatcher) Events() chan fsnotify.Event { + return f.watcher.Events +} + +// Errors returns the underlying watcher's Errors chan +func (f *fsNotifyWatcher) Errors() chan error { + return f.watcher.Errors +} diff --git a/docs/reference/sync-configuration.md b/docs/reference/sync-configuration.md index 902463903..947d826a6 100644 --- a/docs/reference/sync-configuration.md +++ b/docs/reference/sync-configuration.md @@ -31,7 +31,7 @@ Alternatively, these configurations can be passed to flagd via config file, spec | Field | Type | Note | | ----------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | uri | required `string` | Flag configuration source of the sync | -| provider | required `string` | Provider type - `file`, `kubernetes`, `http`, `grpc` or `gcs` | +| provider | required `string` | Provider type - `file`, `fsnotify`, `fileinfo`, `kubernetes`, `http`, `grpc` or `gcs` | | authHeader | optional `string` | Used for http sync; set this to include the complete `Authorization` header value for any authentication scheme (e.g., "Bearer token_here", "Basic base64_credentials", etc.). Cannot be used with `bearerToken` | | bearerToken | optional `string` | (Deprecated) Used for http sync; token gets appended to `Authorization` header with [bearer schema](https://www.rfc-editor.org/rfc/rfc6750#section-2.1). Cannot be used with `authHeader` | | interval | optional `uint32` | Used for http and gcs syncs; requests will be made at this interval. Defaults to 5 seconds. | @@ -45,11 +45,20 @@ The `uri` field values **do not** follow the [URI patterns](#uri-patterns). The from the `provider` field. Only exception is the remote provider where `http(s)://` is expected by default. Incorrect URIs will result in a flagd start-up failure with errors from the respective sync provider implementation. +The `file` provider type uses either an `fsnotify` notification (on systems that +support it), or a timer-based poller that relies on `os.Stat` and `fs.FileInfo`. +The moniker: `file` defaults to using `fsnotify` when flagd detects it is +running in kubernetes and `fileinfo` in all other cases, but you may explicitly +select either polling back-end by setting the provider value to either +`fsnotify` or `fileinfo`. + Given below are example sync providers, startup command and equivalent config file definition: Sync providers: - `file` - config/samples/example_flags.json +- `fsnotify` - config/samples/example_flags.json +- `fileinfo` - config/samples/example_flags.json - `http` - - `https` - - `kubernetes` - default/my-flag-config @@ -62,6 +71,8 @@ Startup command: ```sh ./bin/flagd start --sources='[{"uri":"config/samples/example_flags.json","provider":"file"}, + {"uri":"config/samples/example_flags.json","provider":"fsnotify"}, + {"uri":"config/samples/example_flags.json","provider":"fileinfo"}, {"uri":"http://my-flag-source.json","provider":"http","bearerToken":"bearer-dji34ld2l"}, {"uri":"https://secure-remote/bearer-auth","provider":"http","authHeader":"Bearer bearer-dji34ld2l"}, {"uri":"https://secure-remote/basic-auth","provider":"http","authHeader":"Basic dXNlcjpwYXNz"}, @@ -78,6 +89,10 @@ Configuration file, sources: - uri: config/samples/example_flags.json provider: file + - uri: config/samples/example_flags.json + provider: fsnotify + - uri: config/samples/example_flags.json + provider: fileinfo - uri: http://my-flag-source.json provider: http bearerToken: bearer-dji34ld2l