feat: add 'watcher' interface to file sync (#1365)

Implement fsnotify and `os.Stat` based watchers

fixes: #1344

<!-- Please use this template for your pull request. -->
<!-- Please use the sections that you need and delete other sections -->

## 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 <dave.josephsen@gmail.com>
Signed-off-by: Kavindu Dodanduwa <kavindudodanduwa@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Kavindu Dodanduwa <Kavindu-Dodan@users.noreply.github.com>
Co-authored-by: Michael Beemer <beeme1mr@users.noreply.github.com>
This commit is contained in:
Dave Josephsen 2024-08-22 13:02:31 -05:00 committed by GitHub
parent abb5ca3e31
commit 61fff43e28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 618 additions and 27 deletions

View File

@ -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,22 +113,48 @@ 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(
zap.String("component", "sync"),
zap.String("sync", "filepath"),
),
Mux: &msync.RWMutex{},
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", syncProviderFsNotify),
),
)
}
// 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) {
dynamicClient, err := sb.k8sClientBuilder.GetK8sClient()
if err != nil {

View File

@ -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
}

View File

@ -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"
}

View File

@ -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")

View File

@ -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
}

View File

@ -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` - <http://my-flag-source.json/>
- `https` - <https://my-secure-flag-source.json/>
- `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