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