warn if no acquisition files are found, acquisition_test refactoring, tests (#1816)

This commit is contained in:
mmetc 2022-10-17 17:32:08 +02:00 committed by GitHub
parent 0ecb6dcd4d
commit 2b7e3ff1e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 199 additions and 153 deletions

View file

@ -153,7 +153,7 @@ func LoadAcquisition(cConfig *csconfig.Config) error {
} else {
dataSources, err = acquisition.LoadAcquisitionFromFile(cConfig.Crowdsec)
if err != nil {
return errors.Wrap(err, "while loading acquisition configuration")
return err
}
}

View file

@ -18,6 +18,7 @@ config_paths:
plugin_dir: /usr/local/lib/crowdsec/plugins/
crowdsec_service:
acquisition_path: /etc/crowdsec/acquis.yaml
acquisition_dir: /etc/crowdsec/acquis.d
parser_routines: 1
cscli:
output: human

View file

@ -175,7 +175,7 @@ func LoadAcquisitionFromFile(config *csconfig.CrowdsecServiceCfg) ([]DataSource,
log.Infof("loading acquisition file : %s", acquisFile)
yamlFile, err := os.Open(acquisFile)
if err != nil {
return nil, errors.Wrapf(err, "can't open %s", acquisFile)
return nil, err
}
dec := yaml.NewDecoder(yamlFile)
dec.SetStrict(true)

View file

@ -9,9 +9,10 @@ import (
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
tomb "gopkg.in/tomb.v2"
"gopkg.in/yaml.v2"
"gotest.tools/v3/assert"
"github.com/crowdsecurity/crowdsec/pkg/acquisition/configuration"
"github.com/crowdsecurity/crowdsec/pkg/csconfig"
@ -89,128 +90,123 @@ func TestDataSourceConfigure(t *testing.T) {
appendMockSource()
tests := []struct {
TestName string
RawBytes []byte
String string
ExpectedError string
}{
{
TestName: "basic_valid_config",
RawBytes: []byte(`
String: `
mode: cat
labels:
test: foobar
log_level: info
source: mock
toto: test_value1
`),
`,
},
{
TestName: "basic_debug_config",
RawBytes: []byte(`
String: `
mode: cat
labels:
test: foobar
log_level: debug
source: mock
toto: test_value1
`),
`,
},
{
TestName: "basic_tailmode_config",
RawBytes: []byte(`
String: `
mode: tail
labels:
test: foobar
log_level: debug
source: mock
toto: test_value1
`),
`,
},
{
TestName: "bad_mode_config",
RawBytes: []byte(`
String: `
mode: ratata
labels:
test: foobar
log_level: debug
source: mock
toto: test_value1
`),
`,
ExpectedError: "failed to configure datasource mock: mode ratata is not supported",
},
{
TestName: "bad_type_config",
RawBytes: []byte(`
String: `
mode: cat
labels:
test: foobar
log_level: debug
source: tutu
`),
`,
ExpectedError: "cannot find source tutu",
},
{
TestName: "mismatch_config",
RawBytes: []byte(`
String: `
mode: cat
labels:
test: foobar
log_level: debug
source: mock
wowo: ajsajasjas
`),
`,
ExpectedError: "field wowo not found in type acquisition.MockSource",
},
{
TestName: "cant_run_error",
RawBytes: []byte(`
String: `
mode: cat
labels:
test: foobar
log_level: debug
source: mock_cant_run
wowo: ajsajasjas
`),
`,
ExpectedError: "datasource mock_cant_run cannot be run: can't run bro",
},
}
for _, test := range tests {
common := configuration.DataSourceCommonCfg{}
yaml.Unmarshal(test.RawBytes, &common)
ds, err := DataSourceConfigure(common)
if test.ExpectedError != "" {
if err == nil {
t.Fatalf("expected error %s, got none", test.ExpectedError)
for _, tc := range tests {
tc := tc
t.Run(tc.TestName, func(t *testing.T) {
common := configuration.DataSourceCommonCfg{}
yaml.Unmarshal([]byte(tc.String), &common)
ds, err := DataSourceConfigure(common)
cstest.RequireErrorContains(t, err, tc.ExpectedError)
if tc.ExpectedError == "" {
return
}
if !strings.Contains(err.Error(), test.ExpectedError) {
t.Fatalf("%s : expected error '%s' in '%s'", test.TestName, test.ExpectedError, err)
}
continue
}
if err != nil {
t.Fatalf("%s : unexpected error '%s'", test.TestName, err)
}
switch test.TestName {
case "basic_valid_config":
mock := (*ds).Dump().(*MockSource)
assert.Equal(t, mock.Toto, "test_value1")
assert.Equal(t, mock.Mode, "cat")
assert.Equal(t, mock.logger.Logger.Level, log.InfoLevel)
assert.DeepEqual(t, mock.Labels, map[string]string{"test": "foobar"})
case "basic_debug_config":
mock := (*ds).Dump().(*MockSource)
assert.Equal(t, mock.Toto, "test_value1")
assert.Equal(t, mock.Mode, "cat")
assert.Equal(t, mock.logger.Logger.Level, log.DebugLevel)
assert.DeepEqual(t, mock.Labels, map[string]string{"test": "foobar"})
case "basic_tailmode_config":
mock := (*ds).Dump().(*MockSource)
assert.Equal(t, mock.Toto, "test_value1")
assert.Equal(t, mock.Mode, "tail")
assert.Equal(t, mock.logger.Logger.Level, log.DebugLevel)
assert.DeepEqual(t, mock.Labels, map[string]string{"test": "foobar"})
}
switch tc.TestName {
case "basic_valid_config":
mock := (*ds).Dump().(*MockSource)
assert.Equal(t, mock.Toto, "test_value1")
assert.Equal(t, mock.Mode, "cat")
assert.Equal(t, mock.logger.Logger.Level, log.InfoLevel)
assert.Equal(t, mock.Labels, map[string]string{"test": "foobar"})
case "basic_debug_config":
mock := (*ds).Dump().(*MockSource)
assert.Equal(t, mock.Toto, "test_value1")
assert.Equal(t, mock.Mode, "cat")
assert.Equal(t, mock.logger.Logger.Level, log.DebugLevel)
assert.Equal(t, mock.Labels, map[string]string{"test": "foobar"})
case "basic_tailmode_config":
mock := (*ds).Dump().(*MockSource)
assert.Equal(t, mock.Toto, "test_value1")
assert.Equal(t, mock.Mode, "tail")
assert.Equal(t, mock.logger.Logger.Level, log.DebugLevel)
assert.Equal(t, mock.Labels, map[string]string{"test": "foobar"})
}
})
}
}
@ -227,7 +223,7 @@ func TestLoadAcquisitionFromFile(t *testing.T) {
Config: csconfig.CrowdsecServiceCfg{
AcquisitionFiles: []string{"does_not_exist"},
},
ExpectedError: cstest.FileNotFoundMessage,
ExpectedError: "open does_not_exist: " + cstest.FileNotFoundMessage,
ExpectedLen: 0,
},
{
@ -281,13 +277,17 @@ func TestLoadAcquisitionFromFile(t *testing.T) {
ExpectedError: "while configuring datasource of type file from test_files/bad_filetype.yaml",
},
}
for _, test := range tests {
dss, err := LoadAcquisitionFromFile(&test.Config)
cstest.RequireErrorContains(t, err, test.ExpectedError)
for _, tc := range tests {
tc := tc
t.Run(tc.TestName, func(t *testing.T) {
dss, err := LoadAcquisitionFromFile(&tc.Config)
cstest.RequireErrorContains(t, err, tc.ExpectedError)
if tc.ExpectedError != "" {
return
}
if len(dss) != test.ExpectedLen {
t.Fatalf("%s : expected %d datasources got %d", test.TestName, test.ExpectedLen, len(dss))
}
assert.Len(t, dss, tc.ExpectedLen)
})
}
}
@ -398,9 +398,8 @@ READLOOP:
break READLOOP
}
}
if count != 10 {
t.Fatalf("expected 10 results, got %d", count)
}
assert.Equal(t, 10, count)
}
func TestStartAcquisitionTail(t *testing.T) {
@ -426,14 +425,12 @@ READLOOP:
break READLOOP
}
}
if count != 10 {
t.Fatalf("expected 10 results, got %d", count)
}
assert.Equal(t, 10, count)
acquisTomb.Kill(nil)
time.Sleep(1 * time.Second)
if acquisTomb.Err() != nil {
t.Fatalf("unexpected tomb error %s (should be dead)", acquisTomb.Err())
}
require.NoError(t, acquisTomb.Err(), "tomb is not dead")
}
type MockTailError struct {
@ -473,14 +470,10 @@ READLOOP:
break READLOOP
}
}
if count != 10 {
t.Fatalf("expected 10 results, got %d", count)
}
assert.Equal(t, 10, count)
//acquisTomb.Kill(nil)
time.Sleep(1 * time.Second)
if acquisTomb.Err().Error() != "got error (tomb)" {
t.Fatalf("didn't got expected error, got '%s'", acquisTomb.Err().Error())
}
cstest.RequireErrorContains(t, acquisTomb.Err(), "got error (tomb)")
}
type MockSourceByDSN struct {
@ -541,12 +534,13 @@ func TestConfigureByDSN(t *testing.T) {
AcquisitionSources = append(AcquisitionSources, mock)
}
for _, test := range tests {
srcs, err := LoadAcquisitionFromDSN(test.dsn, map[string]string{"type": "test_label"})
cstest.AssertErrorContains(t, err, test.ExpectedError)
for _, tc := range tests {
tc := tc
t.Run(tc.dsn, func(t *testing.T) {
srcs, err := LoadAcquisitionFromDSN(tc.dsn, map[string]string{"type": "test_label"})
cstest.RequireErrorContains(t, err, tc.ExpectedError)
if len(srcs) != test.ExpectedResLen {
t.Fatalf("expected %d results, got %d", test.ExpectedResLen, len(srcs))
}
assert.Len(t, srcs, tc.ExpectedResLen)
})
}
}

View file

@ -5,12 +5,13 @@ import (
"os"
"path/filepath"
"github.com/crowdsecurity/crowdsec/pkg/types"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/crowdsecurity/crowdsec/pkg/types"
)
/*Configurations needed for crowdsec to load parser/scenarios/... + acquisition*/
// CrowdsecServiceCfg contains the location of parsers/scenarios/... and acquisition files
type CrowdsecServiceCfg struct {
Enable *bool `yaml:"enable"`
AcquisitionFilePath string `yaml:"acquisition_path,omitempty"`
@ -21,10 +22,10 @@ type CrowdsecServiceCfg struct {
BucketsRoutinesCount int `yaml:"buckets_routines"`
OutputRoutinesCount int `yaml:"output_routines"`
SimulationConfig *SimulationConfig `yaml:"-"`
LintOnly bool `yaml:"-"` //if set to true, exit after loading configs
BucketStateFile string `yaml:"state_input_file,omitempty"` //if we need to unserialize buckets at start
BucketStateDumpDir string `yaml:"state_output_dir,omitempty"` //if we need to unserialize buckets on shutdown
BucketsGCEnabled bool `yaml:"-"` //we need to garbage collect buckets when in forensic mode
LintOnly bool `yaml:"-"` // if set to true, exit after loading configs
BucketStateFile string `yaml:"state_input_file,omitempty"` // if we need to unserialize buckets at start
BucketStateDumpDir string `yaml:"state_output_dir,omitempty"` // if we need to unserialize buckets on shutdown
BucketsGCEnabled bool `yaml:"-"` // we need to garbage collect buckets when in forensic mode
HubDir string `yaml:"-"`
DataDir string `yaml:"-"`
@ -35,8 +36,9 @@ type CrowdsecServiceCfg struct {
func (c *Config) LoadCrowdsec() error {
var err error
// Configuration paths are dependency to load crowdsec configuration
if err := c.LoadConfigurationPaths(); err != nil {
if err = c.LoadConfigurationPaths(); err != nil {
return err
}
@ -57,19 +59,27 @@ func (c *Config) LoadCrowdsec() error {
return nil
}
if c.Crowdsec.AcquisitionFiles == nil {
c.Crowdsec.AcquisitionFiles = []string{}
}
if c.Crowdsec.AcquisitionFilePath != "" {
log.Debugf("non-empty acquisition file path %s", c.Crowdsec.AcquisitionFilePath)
if _, err := os.Stat(c.Crowdsec.AcquisitionFilePath); err != nil {
return errors.Wrapf(err, "while checking acquisition path %s", c.Crowdsec.AcquisitionFilePath)
log.Debugf("non-empty acquisition_path %s", c.Crowdsec.AcquisitionFilePath)
if _, err = os.Stat(c.Crowdsec.AcquisitionFilePath); err != nil {
return fmt.Errorf("while checking acquisition_path: %w", err)
}
c.Crowdsec.AcquisitionFiles = append(c.Crowdsec.AcquisitionFiles, c.Crowdsec.AcquisitionFilePath)
}
if c.Crowdsec.AcquisitionDirPath != "" {
c.Crowdsec.AcquisitionDirPath, err = filepath.Abs(c.Crowdsec.AcquisitionDirPath)
if err != nil {
return errors.Wrapf(err, "can't get absolute path of '%s'", c.Crowdsec.AcquisitionDirPath)
}
files, err := filepath.Glob(c.Crowdsec.AcquisitionDirPath + "/*.yaml")
var files []string
files, err = filepath.Glob(c.Crowdsec.AcquisitionDirPath + "/*.yaml")
if err != nil {
return errors.Wrap(err, "while globbing acquis_dir")
}
@ -81,10 +91,16 @@ func (c *Config) LoadCrowdsec() error {
}
c.Crowdsec.AcquisitionFiles = append(c.Crowdsec.AcquisitionFiles, files...)
}
if c.Crowdsec.AcquisitionDirPath == "" && c.Crowdsec.AcquisitionFilePath == "" {
log.Warning("no acquisition_path nor acquisition_dir")
log.Warning("no acquisition_path or acquisition_dir specified")
}
if err := c.LoadSimulation(); err != nil {
if len(c.Crowdsec.AcquisitionFiles) == 0 {
log.Warning("no acquisition file found")
}
if err = c.LoadSimulation(); err != nil {
return errors.Wrap(err, "load error (simulation)")
}
@ -92,6 +108,7 @@ func (c *Config) LoadCrowdsec() error {
c.Crowdsec.DataDir = c.ConfigPaths.DataDir
c.Crowdsec.HubDir = c.ConfigPaths.HubDir
c.Crowdsec.HubIndexFile = c.ConfigPaths.HubIndexFile
if c.Crowdsec.ParserRoutinesCount <= 0 {
c.Crowdsec.ParserRoutinesCount = 1
}
@ -107,6 +124,7 @@ func (c *Config) LoadCrowdsec() error {
var crowdsecCleanup = []*string{
&c.Crowdsec.AcquisitionFilePath,
}
for _, k := range crowdsecCleanup {
if *k == "" {
continue
@ -116,6 +134,8 @@ func (c *Config) LoadCrowdsec() error {
return errors.Wrapf(err, "failed to get absolute path of '%s'", *k)
}
}
// Convert relative paths to absolute paths
for i, file := range c.Crowdsec.AcquisitionFiles {
f, err := filepath.Abs(file)
if err != nil {
@ -127,8 +147,10 @@ func (c *Config) LoadCrowdsec() error {
if err := c.LoadAPIClient(); err != nil {
return fmt.Errorf("loading api client: %s", err)
}
if err := c.LoadHub(); err != nil {
return errors.Wrap(err, "while loading hub")
}
return nil
}

View file

@ -3,59 +3,45 @@ package csconfig
import (
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/crowdsecurity/crowdsec/pkg/cstest"
"github.com/crowdsecurity/crowdsec/pkg/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadCrowdsec(t *testing.T) {
falseBoolPtr := false
acquisFullPath, err := filepath.Abs("./tests/acquis.yaml")
if err != nil {
t.Fatalf(err.Error())
}
require.NoError(t, err)
acquisInDirFullPath, err := filepath.Abs("./tests/acquis/acquis.yaml")
if err != nil {
t.Fatalf(err.Error())
}
require.NoError(t, err)
acquisDirFullPath, err := filepath.Abs("./tests/acquis")
if err != nil {
t.Fatalf(err.Error())
}
require.NoError(t, err)
hubFullPath, err := filepath.Abs("./hub")
if err != nil {
t.Fatalf(err.Error())
}
require.NoError(t, err)
dataFullPath, err := filepath.Abs("./data")
if err != nil {
t.Fatalf(err.Error())
}
require.NoError(t, err)
configDirFullPath, err := filepath.Abs("./tests")
if err != nil {
t.Fatalf(err.Error())
}
require.NoError(t, err)
hubIndexFileFullPath, err := filepath.Abs("./hub/.index.json")
if err != nil {
t.Fatalf(err.Error())
}
require.NoError(t, err)
tests := []struct {
name string
Input *Config
input *Config
expectedResult *CrowdsecServiceCfg
err string
expectedErr string
}{
{
name: "basic valid configuration",
Input: &Config{
input: &Config{
ConfigPaths: &ConfigurationPaths{
ConfigDir: "./tests",
DataDir: "./data",
@ -91,7 +77,7 @@ func TestLoadCrowdsec(t *testing.T) {
},
{
name: "basic valid configuration with acquisition dir",
Input: &Config{
input: &Config{
ConfigPaths: &ConfigurationPaths{
ConfigDir: "./tests",
DataDir: "./data",
@ -128,7 +114,7 @@ func TestLoadCrowdsec(t *testing.T) {
},
{
name: "no acquisition file and dir",
Input: &Config{
input: &Config{
ConfigPaths: &ConfigurationPaths{
ConfigDir: "./tests",
DataDir: "./data",
@ -143,13 +129,17 @@ func TestLoadCrowdsec(t *testing.T) {
},
expectedResult: &CrowdsecServiceCfg{
Enable: types.BoolPtr(true),
BucketsRoutinesCount: 1,
ParserRoutinesCount: 1,
OutputRoutinesCount: 1,
AcquisitionDirPath: "",
AcquisitionFilePath: "",
ConfigDir: configDirFullPath,
HubIndexFile: hubIndexFileFullPath,
DataDir: dataFullPath,
HubDir: hubFullPath,
BucketsRoutinesCount: 1,
ParserRoutinesCount: 1,
OutputRoutinesCount: 1,
AcquisitionFiles: []string{},
SimulationFilePath: "",
SimulationConfig: &SimulationConfig{
Simulation: &falseBoolPtr,
},
@ -157,7 +147,7 @@ func TestLoadCrowdsec(t *testing.T) {
},
{
name: "non existing acquisition file",
Input: &Config{
input: &Config{
ConfigPaths: &ConfigurationPaths{
ConfigDir: "./tests",
DataDir: "./data",
@ -172,17 +162,11 @@ func TestLoadCrowdsec(t *testing.T) {
AcquisitionFilePath: "./tests/acquis_not_exist.yaml",
},
},
expectedResult: &CrowdsecServiceCfg{
Enable: types.BoolPtr(true),
AcquisitionFilePath: "./tests/acquis_not_exist.yaml",
BucketsRoutinesCount: 0,
ParserRoutinesCount: 0,
OutputRoutinesCount: 0,
},
expectedErr: cstest.FileNotFoundMessage,
},
{
name: "agent disabled",
Input: &Config{
input: &Config{
ConfigPaths: &ConfigurationPaths{
ConfigDir: "./tests",
DataDir: "./data",
@ -193,23 +177,17 @@ func TestLoadCrowdsec(t *testing.T) {
},
}
for idx, test := range tests {
fmt.Printf("TEST '%s'\n", test.name)
err := test.Input.LoadCrowdsec()
if err == nil && test.err != "" {
t.Fatalf("%d/%d expected error, didn't get it", idx, len(tests))
} else if test.err != "" {
if !strings.HasPrefix(fmt.Sprintf("%s", err), test.err) {
t.Fatalf("%d/%d expected '%s' got '%s'", idx, len(tests),
test.err,
fmt.Sprintf("%s", err))
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
fmt.Printf("TEST '%s'\n", tc.name)
err := tc.input.LoadCrowdsec()
cstest.RequireErrorContains(t, err, tc.expectedErr)
if tc.expectedErr != "" {
return
}
}
isOk := assert.Equal(t, test.expectedResult, test.Input.Crowdsec)
if !isOk {
t.Fatalf("test '%s' failed", test.name)
}
require.Equal(t, tc.expectedResult, tc.input.Crowdsec)
})
}
}

View file

@ -129,3 +129,51 @@ teardown() {
run -0 ./instance-crowdsec stop
}
@test "crowdsec (error if the acquisition_path file is defined but missing)" {
ACQUIS_YAML=$(config_get '.crowdsec_service.acquisition_path')
rm -f "$ACQUIS_YAML"
run -1 --separate-stderr timeout 2s "${CROWDSEC}"
assert_stderr_line --partial "acquis.yaml: no such file or directory"
}
@test "crowdsec (error if acquisition_path is not defined and acquisition_dir is empty)" {
ACQUIS_YAML=$(config_get '.crowdsec_service.acquisition_path')
rm -f "$ACQUIS_YAML"
config_set '.crowdsec_service.acquisition_path=""'
ACQUIS_DIR=$(config_get '.crowdsec_service.acquisition_dir')
rm -f "$ACQUIS_DIR"
config_set '.common.log_media="stdout"'
run -124 --separate-stderr timeout 2s "${CROWDSEC}"
# check warning
assert_stderr_line --partial "no acquisition file found"
}
@test "crowdsec (error if acquisition_path and acquisition_dir are not defined)" {
ACQUIS_YAML=$(config_get '.crowdsec_service.acquisition_path')
rm -f "$ACQUIS_YAML"
config_set '.crowdsec_service.acquisition_path=""'
ACQUIS_DIR=$(config_get '.crowdsec_service.acquisition_dir')
rm -f "$ACQUIS_DIR"
config_set '.crowdsec_service.acquisition_dir=""'
config_set '.common.log_media="stdout"'
run -124 --separate-stderr timeout 2s "${CROWDSEC}"
# check warning
assert_stderr_line --partial "no acquisition_path or acquisition_dir specified"
}
@test "crowdsec (no error if acquisition_path is empty string but acquisition_dir is not empty)" {
ACQUIS_YAML=$(config_get '.crowdsec_service.acquisition_path')
rm -f "$ACQUIS_YAML"
config_set '.crowdsec_service.acquisition_path=""'
ACQUIS_DIR=$(config_get '.crowdsec_service.acquisition_dir')
mkdir -p "$ACQUIS_DIR"
touch "$ACQUIS_DIR"/foo.yaml
run -124 --separate-stderr timeout 2s "${CROWDSEC}"
}

View file

@ -38,6 +38,8 @@ DATA_DIR="${LOCAL_DIR}/${REL_DATA_DIR}"
export DATA_DIR
CONFIG_DIR="${LOCAL_DIR}/${REL_CONFIG_DIR}"
export CONFIG_DIR
HUB_DIR="${CONFIG_DIR}/hub"
export HUB_DIR
if [[ $(uname) == "OpenBSD" ]]; then
TAR=gtar
@ -81,11 +83,12 @@ config_generate() {
.config_paths.config_dir=strenv(CONFIG_DIR) |
.config_paths.data_dir=strenv(DATA_DIR) |
.config_paths.simulation_path=strenv(CONFIG_DIR)+"/simulation.yaml" |
.config_paths.hub_dir=strenv(CONFIG_DIR)+"/hub/" |
.config_paths.index_path=strenv(CONFIG_DIR)+"/hub/.index.json" |
.config_paths.notification_dir=strenv(CONFIG_DIR)+"/notifications/" |
.config_paths.hub_dir=strenv(HUB_DIR) |
.config_paths.index_path=strenv(HUB_DIR)+"/.index.json" |
.config_paths.notification_dir=strenv(CONFIG_DIR)+"/notifications" |
.config_paths.plugin_dir=strenv(PLUGIN_DIR) |
.crowdsec_service.acquisition_path=strenv(CONFIG_DIR)+"/acquis.yaml" |
.crowdsec_service.acquisition_dir=strenv(CONFIG_DIR)+"/acquis.d" |
.db_config.db_path=strenv(DATA_DIR)+"/crowdsec.db" |
.db_config.use_wal=true |
.api.client.credentials_path=strenv(CONFIG_DIR)+"/local_api_credentials.yaml" |