diff --git a/go.mod b/go.mod index 2dd3df4704..5cc8a0926f 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.16 require ( github.com/cenkalti/backoff/v4 v4.1.0 + github.com/fsnotify/fsnotify v1.4.9 github.com/go-playground/universal-translator v0.17.0 // indirect github.com/go-zookeeper/zk v1.0.2 github.com/gogo/googleapis v1.4.0 // indirect diff --git a/internal/configsource/fileconfigsource/README.md b/internal/configsource/fileconfigsource/README.md new file mode 100644 index 0000000000..e229822a81 --- /dev/null +++ b/internal/configsource/fileconfigsource/README.md @@ -0,0 +1,46 @@ +# File Config Source (Alpha) + +Use the file config source to inject YAML fragments or scalars into the +configuration. + +## Configuration + +Under the top-level `config_sources:` component mapping use `file:` or +`file/:` to create a file config source capable of reading from +subsequently specified files and detecting their changes: + + +```yaml +config_sources: + file: +``` + +By default, the config source will monitor for updates on the used files +and will trigger a configuration reload when they are updated. +Configuration reload causes temporary interruption of the data flow during +the time taken to shut down the current pipeline configuration and start the +new one. +Optionally, the file config source can be configured to delete the injected file +(typically to remove secrets from the file system) as soon as its value is read +or to not watch for changes to the file. + +```yaml +config_sources: + file: + +components: + component_0: + # Default usage: configuration will be reloaded if the file + # '/etc/configs/component_field' is changed. + component_field: ${file:/etc/configs/component_field} + + component_1: + # Use the 'delete' parameter to force the removal of files with + # secrets that shouldn't stay in the OS. + component_field: ${file:/etc/configs/secret?delete=true} + + component_2: + # Use the 'disable_watch' parameter to avoid reloading the configuration + # if the file is changed. + component_field: ${file:/etc/configs/secret?disable_watch=true} +``` diff --git a/internal/configsource/fileconfigsource/config.go b/internal/configsource/fileconfigsource/config.go new file mode 100644 index 0000000000..aa068eef2a --- /dev/null +++ b/internal/configsource/fileconfigsource/config.go @@ -0,0 +1,25 @@ +// Copyright Splunk, Inc. +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fileconfigsource + +import ( + "github.com/signalfx/splunk-otel-collector/internal/configprovider" +) + +// Config holds the configuration for the creation of file config source objects. +type Config struct { + *configprovider.Settings +} diff --git a/internal/configsource/fileconfigsource/config_test.go b/internal/configsource/fileconfigsource/config_test.go new file mode 100644 index 0000000000..b4f8d6c5ff --- /dev/null +++ b/internal/configsource/fileconfigsource/config_test.go @@ -0,0 +1,68 @@ +// Copyright Splunk, Inc. +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fileconfigsource + +import ( + "context" + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/config" + "go.uber.org/zap" + + "github.com/signalfx/splunk-otel-collector/internal/configprovider" +) + +func TestFileConfigSourceLoadConfig(t *testing.T) { + fileName := path.Join("testdata", "config.yaml") + v, err := config.NewParserFromFile(fileName) + require.NoError(t, err) + + factories := map[config.Type]configprovider.Factory{ + typeStr: NewFactory(), + } + + actualSettings, err := configprovider.Load(context.Background(), v, factories) + require.NoError(t, err) + + expectedSettings := map[string]configprovider.ConfigSettings{ + "file": &Config{ + Settings: &configprovider.Settings{ + TypeVal: "file", + NameVal: "file", + }, + }, + "file/named": &Config{ + Settings: &configprovider.Settings{ + TypeVal: "file", + NameVal: "file/named", + }, + }, + } + + require.Equal(t, expectedSettings, actualSettings) + + params := configprovider.CreateParams{ + Logger: zap.NewNop(), + } + cfgSrcs, err := configprovider.Build(context.Background(), actualSettings, params, factories) + require.NoError(t, err) + for k := range expectedSettings { + assert.Contains(t, cfgSrcs, k) + } +} diff --git a/internal/configsource/fileconfigsource/configsource.go b/internal/configsource/fileconfigsource/configsource.go new file mode 100644 index 0000000000..ba7d0415e3 --- /dev/null +++ b/internal/configsource/fileconfigsource/configsource.go @@ -0,0 +1,35 @@ +// Copyright Splunk, Inc. +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fileconfigsource + +import ( + "context" + + "go.opentelemetry.io/collector/config/experimental/configsource" + "go.uber.org/zap" +) + +type fileConfigSource struct{} + +var _ configsource.ConfigSource = (*fileConfigSource)(nil) + +func (f *fileConfigSource) NewSession(context.Context) (configsource.Session, error) { + return newSession() +} + +func newConfigSource(_ *zap.Logger, _ *Config) (*fileConfigSource, error) { + return &fileConfigSource{}, nil +} diff --git a/internal/configsource/fileconfigsource/configsource_test.go b/internal/configsource/fileconfigsource/configsource_test.go new file mode 100644 index 0000000000..b408d3f97c --- /dev/null +++ b/internal/configsource/fileconfigsource/configsource_test.go @@ -0,0 +1,100 @@ +// Copyright Splunk, Inc. +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fileconfigsource + +import ( + "context" + "io/ioutil" + "path" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/config" + "go.opentelemetry.io/collector/config/experimental/configsource" + "go.uber.org/zap" + + "github.com/signalfx/splunk-otel-collector/internal/configprovider" +) + +func TestFileConfigSourceNew(t *testing.T) { + cfgSrc, err := newConfigSource(zap.NewNop(), &Config{}) + require.NoError(t, err) + require.NotNil(t, cfgSrc) +} + +func TestFileConfigSource_End2End(t *testing.T) { + file := path.Join("testdata", "file_config_source_end_2_end.yaml") + p, err := config.NewParserFromFile(file) + require.NoError(t, err) + require.NotNil(t, p) + + factories := configprovider.Factories{ + "file": NewFactory(), + } + m, err := configprovider.NewManager(p, zap.NewNop(), component.DefaultBuildInfo(), factories) + require.NoError(t, err) + require.NotNil(t, m) + + ctx := context.Background() + r, err := m.Resolve(ctx, p) + require.NoError(t, err) + require.NotNil(t, r) + t.Cleanup(func() { + assert.NoError(t, m.Close(ctx)) + }) + + var watchErr error + watchDone := make(chan struct{}) + go func() { + defer close(watchDone) + watchErr = m.WatchForUpdate() + }() + m.WaitForWatcher() + + fileWithExpectedData := path.Join("testdata", "file_config_source_end_2_end_expected.yaml") + expected, err := config.NewParserFromFile(fileWithExpectedData) + require.NoError(t, err) + require.NotNil(t, expected) + + assert.Equal(t, expected.ToStringMap(), r.ToStringMap()) + + // Touch one of the files to trigger an update. + yamlDataFile := path.Join("testdata", "yaml_data_file") + touchFile(t, yamlDataFile) + + select { + case <-watchDone: + case <-time.After(3 * time.Second): + require.Fail(t, "expected file change notification didn't happen") + } + + assert.ErrorIs(t, watchErr, configsource.ErrValueUpdated) + + // Value should not have changed, resolve it again and confirm. + r, err = m.Resolve(ctx, p) + require.NoError(t, err) + require.NotNil(t, r) + assert.Equal(t, expected.ToStringMap(), r.ToStringMap()) +} + +func touchFile(t *testing.T, file string) { + contents, err := ioutil.ReadFile(file) + require.NoError(t, err) + require.NoError(t, ioutil.WriteFile(file, contents, 0)) +} diff --git a/internal/configsource/fileconfigsource/factory.go b/internal/configsource/fileconfigsource/factory.go new file mode 100644 index 0000000000..ec1e335909 --- /dev/null +++ b/internal/configsource/fileconfigsource/factory.go @@ -0,0 +1,51 @@ +// Copyright Splunk, Inc. +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fileconfigsource + +import ( + "context" + + "go.opentelemetry.io/collector/config" + "go.opentelemetry.io/collector/config/experimental/configsource" + + "github.com/signalfx/splunk-otel-collector/internal/configprovider" +) + +const ( + // The "type" of file config sources in configuration. + typeStr = "file" +) + +type fileFactory struct{} + +func (f *fileFactory) Type() config.Type { + return typeStr +} + +func (f *fileFactory) CreateDefaultConfig() configprovider.ConfigSettings { + return &Config{ + Settings: configprovider.NewSettings(typeStr), + } +} + +func (f *fileFactory) CreateConfigSource(_ context.Context, params configprovider.CreateParams, cfg configprovider.ConfigSettings) (configsource.ConfigSource, error) { + return newConfigSource(params.Logger, cfg.(*Config)) +} + +// NewFactory creates a factory for Vault ConfigSource objects. +func NewFactory() configprovider.Factory { + return &fileFactory{} +} diff --git a/internal/configsource/fileconfigsource/factory_test.go b/internal/configsource/fileconfigsource/factory_test.go new file mode 100644 index 0000000000..e60adf20d3 --- /dev/null +++ b/internal/configsource/fileconfigsource/factory_test.go @@ -0,0 +1,39 @@ +// Copyright Splunk, Inc. +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fileconfigsource + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/collector/config" + "go.uber.org/zap" + + "github.com/signalfx/splunk-otel-collector/internal/configprovider" +) + +func TestFileConfigSourceFactory_CreateConfigSource(t *testing.T) { + factory := NewFactory() + assert.Equal(t, config.Type("file"), factory.Type()) + createParams := configprovider.CreateParams{ + Logger: zap.NewNop(), + } + + actual, err := factory.CreateConfigSource(context.Background(), createParams, &Config{}) + assert.NoError(t, err) + assert.NotNil(t, actual) +} diff --git a/internal/configsource/fileconfigsource/session.go b/internal/configsource/fileconfigsource/session.go new file mode 100644 index 0000000000..7bf8a74334 --- /dev/null +++ b/internal/configsource/fileconfigsource/session.go @@ -0,0 +1,154 @@ +// Copyright Splunk, Inc. +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fileconfigsource + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path/filepath" + + "github.com/fsnotify/fsnotify" + "github.com/spf13/cast" + "go.opentelemetry.io/collector/config" + "go.opentelemetry.io/collector/config/experimental/configsource" + + "github.com/signalfx/splunk-otel-collector/internal/configprovider" +) + +// Private error types to help with testability. +type ( + errFailedToDeleteFile struct{ error } + errInvalidRetrieveParams struct{ error } + errMissingRequiredFile struct{ error } +) + +type retrieveParams struct { + // Delete is used to instruct the config source to delete the + // file after its content is read. The default value is 'false'. + // Set it to 'true' to force the deletion of the file as soon + // as the config source finished using it. + Delete bool `mapstructure:"delete"` + // DisableWatch is used to control if the referenced file should + // be watched for updates or not. The default value is 'false'. + // Set it to 'true' to prevent monitoring for updates on the given + // file. + DisableWatch bool `mapstructure:"disable_watch"` +} + +// fileSession implements the configsource.Session interface. +type fileSession struct { + watcher *fsnotify.Watcher + watchedFiles map[string]struct{} +} + +var _ configsource.Session = (*fileSession)(nil) + +func (fs *fileSession) Retrieve(_ context.Context, selector string, params interface{}) (configsource.Retrieved, error) { + actualParams := retrieveParams{} + if params != nil { + paramsParser := config.NewParserFromStringMap(cast.ToStringMap(params)) + if err := paramsParser.UnmarshalExact(&actualParams); err != nil { + return nil, &errInvalidRetrieveParams{fmt.Errorf("failed to unmarshall retrieve params: %w", err)} + } + } + + bytes, err := ioutil.ReadFile(filepath.Clean(selector)) + if err != nil { + return nil, &errMissingRequiredFile{err} + } + + if actualParams.Delete { + if err = os.Remove(selector); err != nil { + return nil, &errFailedToDeleteFile{fmt.Errorf("failed to delete file %q as requested: %w", selector, err)} + } + } + + if actualParams.Delete || actualParams.DisableWatch { + return configprovider.NewRetrieved(bytes, configprovider.WatcherNotSupported), nil + } + + watchForUpdateFn, err := fs.watchFile(selector) + if err != nil { + return nil, err + } + + return configprovider.NewRetrieved(bytes, watchForUpdateFn), nil +} + +func (fs *fileSession) RetrieveEnd(context.Context) error { + return nil +} + +func (fs *fileSession) Close(context.Context) error { + if fs.watcher != nil { + return fs.watcher.Close() + } + + return nil +} + +func newSession() (*fileSession, error) { + return &fileSession{ + watchedFiles: make(map[string]struct{}), + }, nil +} + +func (fs *fileSession) watchFile(file string) (func() error, error) { + watchForUpdateFn := configprovider.WatcherNotSupported + if _, watched := fs.watchedFiles[file]; watched { + // This file is already watched another watch function is not needed. + return watchForUpdateFn, nil + } + + if fs.watcher == nil { + // First watcher create a real watch for update function. + var err error + fs.watcher, err = fsnotify.NewWatcher() + if err != nil { + return nil, err + } + + watchForUpdateFn = func() error { + for { + select { + case event, ok := <-fs.watcher.Events: + if !ok { + return configsource.ErrSessionClosed + } + if event.Op&fsnotify.Write == fsnotify.Write { + return fmt.Errorf("file used in the config modified: %q: %w", event.Name, configsource.ErrValueUpdated) + } + case watcherErr, ok := <-fs.watcher.Errors: + if !ok { + return configsource.ErrSessionClosed + } + return watcherErr + } + } + } + } + + // Now just add the file. + if err := fs.watcher.Add(file); err != nil { + return nil, err + } + + fs.watchedFiles[file] = struct{}{} + + return watchForUpdateFn, nil +} diff --git a/internal/configsource/fileconfigsource/session_test.go b/internal/configsource/fileconfigsource/session_test.go new file mode 100644 index 0000000000..51db6b9fa8 --- /dev/null +++ b/internal/configsource/fileconfigsource/session_test.go @@ -0,0 +1,179 @@ +// Copyright Splunk, Inc. +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fileconfigsource + +import ( + "context" + "io/ioutil" + "os" + "path" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/config/experimental/configsource" +) + +func TestFileConfigSource_Session(t *testing.T) { + tests := []struct { + defaults map[string]interface{} + params map[string]interface{} + expected interface{} + wantErr error + name string + selector string + }{ + { + name: "simple", + selector: "scalar_data_file", + expected: []byte("42"), + }, + { + name: "invalid_param", + params: map[string]interface{}{ + "unknown_params_field": true, + }, + wantErr: &errInvalidRetrieveParams{}, + }, + { + name: "missing_file", + selector: "not_to_be_found", + wantErr: &errMissingRequiredFile{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, err := newSession() + require.NoError(t, err) + require.NotNil(t, s) + + ctx := context.Background() + defer func() { + assert.NoError(t, s.RetrieveEnd(ctx)) + assert.NoError(t, s.Close(ctx)) + }() + + file := path.Join("testdata", tt.selector) + r, err := s.Retrieve(ctx, file, tt.params) + if tt.wantErr != nil { + assert.Nil(t, r) + require.IsType(t, tt.wantErr, err) + return + } + + require.NoError(t, err) + require.NotNil(t, r) + assert.Equal(t, tt.expected, r.Value()) + }) + } +} + +func TestFileConfigSource_DeleteFile(t *testing.T) { + s, err := newSession() + require.NoError(t, err) + require.NotNil(t, s) + + ctx := context.Background() + defer func() { + assert.NoError(t, s.RetrieveEnd(ctx)) + assert.NoError(t, s.Close(ctx)) + }() + + // Copy test file + src := path.Join("testdata", "scalar_data_file") + contents, err := ioutil.ReadFile(src) + require.NoError(t, err) + dst := path.Join("testdata", "copy_scalar_data_file") + require.NoError(t, ioutil.WriteFile(dst, contents, 0644)) + t.Cleanup(func() { + // It should be removed prior to this so an error is expected. + assert.Error(t, os.Remove(dst)) + }) + + params := map[string]interface{}{ + "delete": true, + } + r, err := s.Retrieve(ctx, dst, params) + + require.NoError(t, err) + require.NotNil(t, r) + assert.Equal(t, []byte("42"), r.Value()) + + assert.Equal(t, configsource.ErrWatcherNotSupported, r.WatchForUpdate()) +} + +func TestFileConfigSource_DeleteFileError(t *testing.T) { + if runtime.GOOS != "windows" { + // Locking the file is trivial on Windows, but not on *nix given the + // golang API, run the test only on Windows. + t.Skip("Windows only test") + } + + s, err := newSession() + require.NoError(t, err) + require.NotNil(t, s) + + ctx := context.Background() + defer func() { + assert.NoError(t, s.RetrieveEnd(ctx)) + assert.NoError(t, s.Close(ctx)) + }() + + // Copy test file + src := path.Join("testdata", "scalar_data_file") + contents, err := ioutil.ReadFile(src) + require.NoError(t, err) + dst := path.Join("testdata", "copy_scalar_data_file") + require.NoError(t, ioutil.WriteFile(dst, contents, 0644)) + f, err := os.OpenFile(dst, os.O_RDWR, 0) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, f.Close()) + assert.NoError(t, os.Remove(dst)) + }) + + params := map[string]interface{}{ + "delete": true, + } + r, err := s.Retrieve(ctx, dst, params) + assert.IsType(t, &errFailedToDeleteFile{}, err) + assert.Nil(t, r) +} + +func TestFileConfigSource_DisableWatch(t *testing.T) { + s, err := newSession() + require.NoError(t, err) + require.NotNil(t, s) + + ctx := context.Background() + defer func() { + assert.NoError(t, s.RetrieveEnd(ctx)) + assert.NoError(t, s.Close(ctx)) + }() + + src := path.Join("testdata", "scalar_data_file") + params := map[string]interface{}{ + "disable_watch": true, + } + r, err := s.Retrieve(ctx, src, params) + require.NoError(t, err) + require.NotNil(t, r) + assert.Equal(t, []byte("42"), r.Value()) + + assert.Equal(t, configsource.ErrWatcherNotSupported, r.WatchForUpdate()) +} diff --git a/internal/configsource/fileconfigsource/testdata/config.yaml b/internal/configsource/fileconfigsource/testdata/config.yaml new file mode 100644 index 0000000000..cd04d3d7ad --- /dev/null +++ b/internal/configsource/fileconfigsource/testdata/config.yaml @@ -0,0 +1,4 @@ +config_sources: + # File config source doesn't have any parameters. + file: + file/named: diff --git a/internal/configsource/fileconfigsource/testdata/file_config_source_end_2_end.yaml b/internal/configsource/fileconfigsource/testdata/file_config_source_end_2_end.yaml new file mode 100644 index 0000000000..c3944a9900 --- /dev/null +++ b/internal/configsource/fileconfigsource/testdata/file_config_source_end_2_end.yaml @@ -0,0 +1,6 @@ +config_sources: + file: + +config: + scalar_data: ${file:./testdata/scalar_data_file} + yaml_data: $file:./testdata/yaml_data_file diff --git a/internal/configsource/fileconfigsource/testdata/file_config_source_end_2_end_expected.yaml b/internal/configsource/fileconfigsource/testdata/file_config_source_end_2_end_expected.yaml new file mode 100644 index 0000000000..50ec9528b5 --- /dev/null +++ b/internal/configsource/fileconfigsource/testdata/file_config_source_end_2_end_expected.yaml @@ -0,0 +1,7 @@ +config: + scalar_data: 42 + yaml_data: + field: value + map: + k0: 42 + k1: v1 diff --git a/internal/configsource/fileconfigsource/testdata/scalar_data_file b/internal/configsource/fileconfigsource/testdata/scalar_data_file new file mode 100644 index 0000000000..f70d7bba4a --- /dev/null +++ b/internal/configsource/fileconfigsource/testdata/scalar_data_file @@ -0,0 +1 @@ +42 \ No newline at end of file diff --git a/internal/configsource/fileconfigsource/testdata/yaml_data_file b/internal/configsource/fileconfigsource/testdata/yaml_data_file new file mode 100644 index 0000000000..5c532409e9 --- /dev/null +++ b/internal/configsource/fileconfigsource/testdata/yaml_data_file @@ -0,0 +1,4 @@ +field: value +map: + k0: 42 + k1: v1