From 79a133f6fd0be4ab1fd0cece68a3397f49c62f54 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Thu, 23 Jan 2025 16:04:22 +0200 Subject: [PATCH 01/10] Basic Secret Source Implementation Add extensible Secret Sources that can be used to get secrets that will be redacted from logs. The in core secret sources are a file and mock based one. One is reading from key=value file, the other takes secrets as part of its argument. This likely will be extended in the future with more secure ways to do secrets. All secret values are being redacted from the logs of k6 before they go anywhere. Removing them from other places is not in scope. Closes #4139 --- cmd/state/state.go | 7 ++ ext/ext.go | 4 + internal/cmd/root.go | 70 ++++++++++++++++ internal/cmd/test_load.go | 4 +- internal/cmd/tests/test_state.go | 2 + internal/secretsource/file/file.go | 82 +++++++++++++++++++ internal/secretsource/init.go | 7 ++ internal/secretsource/mock/mock.go | 60 ++++++++++++++ js/modules/k6/k6.go | 40 ++++++++- js/modules/k6/k6_test.go | 127 +++++++++++++++++++++++++++++ lib/test_state.go | 2 + secretsource/doc.go | 4 + secretsource/extension.go | 27 ++++++ secretsource/hook.go | 69 ++++++++++++++++ secretsource/interface.go | 8 ++ secretsource/manager.go | 70 ++++++++++++++++ 16 files changed, 579 insertions(+), 4 deletions(-) create mode 100644 internal/secretsource/file/file.go create mode 100644 internal/secretsource/init.go create mode 100644 internal/secretsource/mock/mock.go create mode 100644 secretsource/doc.go create mode 100644 secretsource/extension.go create mode 100644 secretsource/hook.go create mode 100644 secretsource/interface.go create mode 100644 secretsource/manager.go diff --git a/cmd/state/state.go b/cmd/state/state.go index ae2fabc88ac..6a94cb4c555 100644 --- a/cmd/state/state.go +++ b/cmd/state/state.go @@ -14,7 +14,9 @@ import ( "go.k6.io/k6/internal/event" "go.k6.io/k6/internal/ui/console" + "go.k6.io/k6/internal/usage" "go.k6.io/k6/lib/fsext" + "go.k6.io/k6/secretsource" ) const defaultConfigFileName = "config.json" @@ -55,6 +57,9 @@ type GlobalState struct { Logger *logrus.Logger //nolint:forbidigo //TODO:change to FieldLogger FallbackLogger logrus.FieldLogger + + SecretsManager *secretsource.SecretsManager + Usage *usage.Usage } // NewGlobalState returns a new GlobalState with the given ctx. @@ -131,6 +136,7 @@ func NewGlobalState(ctx context.Context) *GlobalState { Hooks: make(logrus.LevelHooks), Level: logrus.InfoLevel, }, + Usage: usage.New(), } } @@ -142,6 +148,7 @@ type GlobalFlags struct { Address string ProfilingEnabled bool LogOutput string + SecretSource []string LogFormat string Verbose bool } diff --git a/ext/ext.go b/ext/ext.go index 4ce9eec6095..1de98b0cc08 100644 --- a/ext/ext.go +++ b/ext/ext.go @@ -25,6 +25,7 @@ type ExtensionType uint8 const ( JSExtension ExtensionType = iota + 1 OutputExtension + SecretSourceExtension ) func (e ExtensionType) String() string { @@ -34,6 +35,8 @@ func (e ExtensionType) String() string { s = "js" case OutputExtension: s = "output" + case SecretSourceExtension: + s = "secret-source" } return s } @@ -157,4 +160,5 @@ func extractModuleInfo(mod interface{}) (path, version string) { func init() { extensions[JSExtension] = make(map[string]*Extension) extensions[OutputExtension] = make(map[string]*Extension) + extensions[SecretSourceExtension] = make(map[string]*Extension) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ce5ae0fcb15..70207c158bf 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -19,7 +19,11 @@ import ( "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/errext/exitcodes" + "go.k6.io/k6/ext" "go.k6.io/k6/internal/log" + "go.k6.io/k6/secretsource" + + _ "go.k6.io/k6/internal/secretsource" // import it to register internal secret sources ) const waitLoggerCloseTimeout = time.Second * 5 @@ -162,6 +166,10 @@ func rootCmdPersistentFlagSet(gs *state.GlobalState) *pflag.FlagSet { // `gs.DefaultFlags.`, so that the `k6 --help` message is // not messed up... + // TODO(@mstoykov): likely needs work - no env variables and such. No config.json. + flags.StringArrayVar(&gs.Flags.SecretSource, "secret-source", gs.Flags.SecretSource, + "setting secret sources for k6 file[=./path.fileformat],") + flags.StringVar(&gs.Flags.LogOutput, "log-output", gs.Flags.LogOutput, "change the output for k6 logs, possible values are stderr,stdout,none,loki[=host:port],file[=./path.fileformat]") flags.Lookup("log-output").DefValue = gs.DefaultFlags.LogOutput @@ -257,6 +265,22 @@ func (c *rootCommand) setupLoggers(stop <-chan struct{}) error { c.globalState.Logger.Debug("Logger format: TEXT") } + secretsources, err := createSecretSources(c.globalState) + if err != nil { + return err + } + // it is important that we add this hook first as hooks are executed in order of addition + // and this means no other hook will get secrets + var secretsHook logrus.Hook + c.globalState.SecretsManager, secretsHook, err = secretsource.NewSecretsManager(secretsources) + if err != nil { + return err + } + if len(secretsources) != 0 { + // don't actually filter anything if there will be no secrets + c.globalState.Logger.AddHook(secretsHook) + } + cancel := func() {} // noop as default if hook != nil { ctx := context.Background() @@ -289,3 +313,49 @@ func (c *rootCommand) setLoggerHook(ctx context.Context, h log.AsyncHook) { c.globalState.Logger.AddHook(h) c.globalState.Logger.SetOutput(io.Discard) // don't output to anywhere else } + +func createSecretSources(gs *state.GlobalState) (map[string]secretsource.SecretSource, error) { + baseParams := secretsource.Params{ + Logger: gs.Logger, + Environment: gs.Env, + FS: gs.FS, + Usage: gs.Usage, + } + + result := make(map[string]secretsource.SecretSource) + for _, line := range gs.Flags.SecretSource { + t, config, ok := strings.Cut(line, "=") + if !ok { + return nil, fmt.Errorf("couldn't parse secret source configuration %q", line) + } + secretSources := ext.Get(ext.SecretSourceExtension) + found, ok := secretSources[t] + if !ok { + return nil, fmt.Errorf("no secret source for type %q for configuration %q", t, line) + } + c := found.Module.(secretsource.Constructor) //nolint:forcetypeassert + params := baseParams + params.ConfigArgument = config + // TODO(@mstoykov): make it not configurable just from cmd line + // params.JSONConfig = test.derivedConfig.Collectors[outputType] + + secretSource, err := c(params) + if err != nil { + return nil, err + } + name := secretSource.Name() + _, alreadRegistered := result[name] + if alreadRegistered { + return nil, fmt.Errorf("secret source for name %q already registered before configuration %q", t, line) + } + result[name] = secretSource + } + + if len(result) == 1 { + for _, l := range result { + result["default"] = l + } + } + + return result, nil +} diff --git a/internal/cmd/test_load.go b/internal/cmd/test_load.go index 9910e872972..6e4684b59e8 100644 --- a/internal/cmd/test_load.go +++ b/internal/cmd/test_load.go @@ -18,7 +18,6 @@ import ( "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/internal/js" "go.k6.io/k6/internal/loader" - "go.k6.io/k6/internal/usage" "go.k6.io/k6/js/modules" "go.k6.io/k6/lib" "go.k6.io/k6/lib/fsext" @@ -84,7 +83,8 @@ func loadLocalTest(gs *state.GlobalState, cmd *cobra.Command, args []string) (*l val, ok := gs.Env[key] return val, ok }, - Usage: usage.New(), + Usage: gs.Usage, + SecretsManager: gs.SecretsManager, } test := &loadedTest{ diff --git a/internal/cmd/tests/test_state.go b/internal/cmd/tests/test_state.go index b2d5dd55759..521628b679e 100644 --- a/internal/cmd/tests/test_state.go +++ b/internal/cmd/tests/test_state.go @@ -18,6 +18,7 @@ import ( "go.k6.io/k6/internal/event" "go.k6.io/k6/internal/lib/testutils" "go.k6.io/k6/internal/ui/console" + "go.k6.io/k6/internal/usage" "go.k6.io/k6/lib/fsext" ) @@ -111,6 +112,7 @@ func NewGlobalTestState(tb testing.TB) *GlobalTestState { SignalStop: signal.Stop, Logger: logger, FallbackLogger: testutils.NewLogger(tb).WithField("fallback", true), + Usage: usage.New(), } return ts diff --git a/internal/secretsource/file/file.go b/internal/secretsource/file/file.go new file mode 100644 index 00000000000..ca2293f8bae --- /dev/null +++ b/internal/secretsource/file/file.go @@ -0,0 +1,82 @@ +// Package file implements secret source that reads the secrets from a file as key=value pairs one per line +package file + +import ( + "bufio" + "errors" + "fmt" + "strings" + + "go.k6.io/k6/secretsource" +) + +func init() { + secretsource.RegisterExtension("file", func(params secretsource.Params) (secretsource.SecretSource, error) { + fss := &fileSecretSource{} + err := fss.parseArg(params.ConfigArgument) + if err != nil { + return nil, err + } + + f, err := params.FS.Open(fss.filename) + if err != nil { + return nil, err + } + scanner := bufio.NewScanner(f) + + fss.internal = make(map[string]string) + for scanner.Scan() { + line := scanner.Text() + k, v, ok := strings.Cut(line, "=") + if !ok { + return nil, fmt.Errorf("parsing %q, needs =", line) + } + + fss.internal[k] = v + } + return fss, nil + }) +} + +func (fss *fileSecretSource) parseArg(config string) error { + list := strings.Split(config, ",") + if len(list) >= 1 { + for _, kv := range list { + k, v, ok := strings.Cut(kv, "=") + if !ok { + fss.filename = kv + } + switch k { + case "filename": + fss.filename = v + case "name": + fss.name = v + default: + return fmt.Errorf("unknown configuration key for file secret source %q", k) + } + } + } + return nil +} + +type fileSecretSource struct { + internal map[string]string + name string + filename string +} + +func (fss *fileSecretSource) Name() string { + return fss.name +} + +func (fss *fileSecretSource) Description() string { + return fmt.Sprintf("file source from %s", fss.filename) +} + +func (fss *fileSecretSource) Get(key string) (string, error) { + v, ok := fss.internal[key] + if !ok { + return "", errors.New("no value") + } + return v, nil +} diff --git a/internal/secretsource/init.go b/internal/secretsource/init.go new file mode 100644 index 00000000000..de150cb7591 --- /dev/null +++ b/internal/secretsource/init.go @@ -0,0 +1,7 @@ +// Package secretsource registers all the internal secret sources when imported +package secretsource + +import ( + _ "go.k6.io/k6/internal/secretsource/file" // import them for init + _ "go.k6.io/k6/internal/secretsource/mock" // import them for init +) diff --git a/internal/secretsource/mock/mock.go b/internal/secretsource/mock/mock.go new file mode 100644 index 00000000000..10c1bb5a98b --- /dev/null +++ b/internal/secretsource/mock/mock.go @@ -0,0 +1,60 @@ +// Package mock implements a secret source that is just taking secrets on the cli +package mock + +import ( + "errors" + "fmt" + "strings" + + "go.k6.io/k6/secretsource" +) + +func init() { + secretsource.RegisterExtension("mock", func(params secretsource.Params) (secretsource.SecretSource, error) { + list := strings.Split(params.ConfigArgument, ",") + secrets := make(map[string]string, len(list)) + name := "mock" + for _, kv := range list { + k, v, ok := strings.Cut(kv, "=") + if !ok { + return nil, fmt.Errorf("parsing %q, needs =", kv) + } + if k == "name" { + name = k + continue + } + + secrets[k] = v + } + return NewMockSecretSource(name, secrets), nil + }) +} + +// NewMockSecretSource returns a new secret source mock with the provided name and map of secrets +func NewMockSecretSource(name string, secrets map[string]string) secretsource.SecretSource { + return &mockSecretSource{ + internal: secrets, + name: name, + } +} + +type mockSecretSource struct { + internal map[string]string + name string +} + +func (mss *mockSecretSource) Name() string { + return mss.name +} + +func (mss *mockSecretSource) Description() string { + return "this is a mock secret source" +} + +func (mss *mockSecretSource) Get(key string) (string, error) { + v, ok := mss.internal[key] + if !ok { + return "", errors.New("no value") + } + return v, nil +} diff --git a/js/modules/k6/k6.go b/js/modules/k6/k6.go index 822c25ec4be..159cfe908f1 100644 --- a/js/modules/k6/k6.go +++ b/js/modules/k6/k6.go @@ -13,6 +13,7 @@ import ( "go.k6.io/k6/js/modules" "go.k6.io/k6/lib" "go.k6.io/k6/metrics" + "go.k6.io/k6/secretsource" ) var ( @@ -30,7 +31,8 @@ type ( // K6 represents an instance of the k6 module. K6 struct { - vu modules.VU + vu modules.VU + secretsManager *secretsource.SecretsManager } ) @@ -47,11 +49,15 @@ func New() *RootModule { // NewModuleInstance implements the modules.Module interface to return // a new instance for each VU. func (*RootModule) NewModuleInstance(vu modules.VU) modules.Instance { - return &K6{vu: vu} + return &K6{vu: vu, secretsManager: vu.InitEnv().SecretsManager} } // Exports returns the exports of the k6 module. func (mi *K6) Exports() modules.Exports { + s, err := mi.secrets() + if err != nil { + common.Throw(mi.vu.Runtime(), err) + } return modules.Exports{ Named: map[string]interface{}{ "check": mi.Check, @@ -59,10 +65,40 @@ func (mi *K6) Exports() modules.Exports { "group": mi.Group, "randomSeed": mi.RandomSeed, "sleep": mi.Sleep, + "secrets": s, }, } } +func (mi *K6) secrets() (*sobek.Object, error) { + obj, err := secretSourceObjectForSourceName(mi.vu.Runtime(), mi.secretsManager, secretsource.DefaultSourceName) + if err != nil { + return nil, err + } + + err = obj.Set("source", func(sourceName string) (*sobek.Object, error) { + return secretSourceObjectForSourceName(mi.vu.Runtime(), mi.secretsManager, sourceName) + }) + if err != nil { + return nil, err + } + + return obj, nil +} + +func secretSourceObjectForSourceName( + rt *sobek.Runtime, manager *secretsource.SecretsManager, sourceName string, +) (*sobek.Object, error) { + obj := rt.NewObject() + err := obj.Set("get", func(key string) (string, error) { + return manager.Get(sourceName, key) + }) + if err != nil { + return nil, err + } + return obj, nil +} + // Fail is a fancy way of saying `throw "something"`. func (*K6) Fail(msg string) (sobek.Value, error) { return sobek.Undefined(), errors.New(msg) diff --git a/js/modules/k6/k6_test.go b/js/modules/k6/k6_test.go index dda4805adeb..6d57a23b44d 100644 --- a/js/modules/k6/k6_test.go +++ b/js/modules/k6/k6_test.go @@ -8,9 +8,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.k6.io/k6/internal/secretsource/mock" "go.k6.io/k6/js/modulestest" "go.k6.io/k6/lib" "go.k6.io/k6/metrics" + "go.k6.io/k6/secretsource" ) func TestFail(t *testing.T) { @@ -397,3 +399,128 @@ func testCaseRuntime(t testing.TB) *testCase { testRuntime: testRuntime, } } + +func testCaseRuntimeWithSecrets(t testing.TB, secretSources map[string]secretsource.SecretSource) *testCase { + testRuntime := modulestest.NewRuntime(t) + var err error + testRuntime.VU.InitEnvField.SecretsManager, _, err = secretsource.NewSecretsManager(secretSources) + require.NoError(t, err) + + m, ok := New().NewModuleInstance(testRuntime.VU).(*K6) + require.True(t, ok) + require.NoError(t, testRuntime.VU.RuntimeField.Set("k6", m.Exports().Named)) + + registry := metrics.NewRegistry() + samples := make(chan metrics.SampleContainer, 1000) + state := &lib.State{ + Options: lib.Options{ + SystemTags: &metrics.DefaultSystemTagSet, + }, + Samples: samples, + Tags: lib.NewVUStateTags(registry.RootTagSet().WithTagsFromMap(map[string]string{"group": lib.RootGroupPath})), + BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), + } + testRuntime.MoveToVUContext(state) + + return &testCase{ + samples: samples, + testRuntime: testRuntime, + } +} + +func TestSecrets(t *testing.T) { + t.Parallel() + + type secretsTest struct { + secretsources map[string]secretsource.SecretSource + script string + expectedValue any + expectedError string + } + + cases := map[string]secretsTest{ + "simple": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + }, + script: "k6.secrets.get('secret')", + expectedValue: "value", + }, + "error": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + }, + script: "k6.secrets.get('not_secret')", + expectedError: "no value", + }, + "multiple": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + "second": mock.NewMockSecretSource("some", map[string]string{ + "secret2": "value2", + }), + }, + script: "k6.secrets.get('secret')", + expectedValue: "value", + }, + "multiple get default": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + "second": mock.NewMockSecretSource("some", map[string]string{ + "secret2": "value2", + }), + }, + script: "k6.secrets.source('default').get('secret')", + expectedValue: "value", + }, + "multiple get not default": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + "second": mock.NewMockSecretSource("some", map[string]string{ + "secret2": "value2", + }), + }, + script: "k6.secrets.source('second').get('secret2')", + expectedValue: "value2", + }, + "get secret without source": { + secretsources: map[string]secretsource.SecretSource{}, + script: "k6.secrets.get('secret')", + expectedError: "no source with name default", + }, + "get none existing source": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + }, + script: "k6.secrets.source('second') != undefined", + expectedValue: true, + }, + } + + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + tc := testCaseRuntimeWithSecrets(t, testCase.secretsources) + + v, err := tc.testRuntime.RunOnEventLoop(testCase.script) + if testCase.expectedError != "" { + require.ErrorContains(t, err, testCase.expectedError) + return + } + require.NoError(t, err) + assert.Equal(t, testCase.expectedValue, v.Export()) + }) + } +} diff --git a/lib/test_state.go b/lib/test_state.go index 0a576217258..1e1e79d1654 100644 --- a/lib/test_state.go +++ b/lib/test_state.go @@ -10,6 +10,7 @@ import ( "go.k6.io/k6/internal/lib/trace" "go.k6.io/k6/internal/usage" "go.k6.io/k6/metrics" + "go.k6.io/k6/secretsource" ) // TestPreInitState contains all of the state that can be gathered and built @@ -24,6 +25,7 @@ type TestPreInitState struct { Logger logrus.FieldLogger TracerProvider *trace.TracerProvider Usage *usage.Usage + SecretsManager *secretsource.SecretsManager } // TestRunState contains the pre-init state as well as all of the state and diff --git a/secretsource/doc.go b/secretsource/doc.go new file mode 100644 index 00000000000..fe7d953f963 --- /dev/null +++ b/secretsource/doc.go @@ -0,0 +1,4 @@ +// Package secretsource is a package to provide secret source interface and common functionality +// This functionality is to be used to provide k6 with a way to get secrets and help it handl them correctly. +// Predominantly by not redact them from logs. +package secretsource diff --git a/secretsource/extension.go b/secretsource/extension.go new file mode 100644 index 00000000000..5bd58759964 --- /dev/null +++ b/secretsource/extension.go @@ -0,0 +1,27 @@ +package secretsource + +import ( + "github.com/sirupsen/logrus" + "go.k6.io/k6/ext" + "go.k6.io/k6/internal/usage" + "go.k6.io/k6/lib/fsext" +) + +// Constructor returns an instance of an output extension module. +type Constructor func(Params) (SecretSource, error) + +// Params contains all possible constructor parameters an output may need. +type Params struct { + ConfigArgument string + + Logger logrus.FieldLogger + Environment map[string]string + FS fsext.Fs + Usage *usage.Usage +} + +// RegisterExtension registers the given secret source extension constructor. +// This function panics if a module with the same name is already registered. +func RegisterExtension(name string, c Constructor) { + ext.Register(name, ext.SecretSourceExtension, c) +} diff --git a/secretsource/hook.go b/secretsource/hook.go new file mode 100644 index 00000000000..980463a6573 --- /dev/null +++ b/secretsource/hook.go @@ -0,0 +1,69 @@ +package secretsource + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +// secretsHook is a Logrus hook for hiding secrets from entries before they get logged +type secretsHook struct { + secrets []string + mx sync.RWMutex + replacer *strings.Replacer +} + +// Levels is part of the [logrus.Hook] +func (s *secretsHook) Levels() []logrus.Level { return logrus.AllLevels } + +// Add is used to add a new secret to be redacted. +// Adding the same secret multiple times will not error, but is not recommended. +// It is users job to not keep adding the same secret over time but only once. +func (s *secretsHook) add(secret string) { + s.mx.Lock() + defer s.mx.Unlock() + s.secrets = append(s.secrets, secret, "***SECRET_REDACTED***") + s.replacer = strings.NewReplacer(s.secrets...) +} + +// Fire is part of the [logrus.Hook] +func (s *secretsHook) Fire(entry *logrus.Entry) error { + s.mx.Lock() + // there is no way for us to get a secret after we got a log for it so we can use that to cache the replacer + replacer := s.replacer + s.mx.Unlock() + if replacer == nil { // no secrets no work + return nil + } + entry.Message = replacer.Replace(entry.Message) + + // replace both keys and values with + for k, v := range entry.Data { + newk := replacer.Replace(k) + if newk != k { + entry.Data[newk] = v + delete(entry.Data, k) + k = newk + } + entry.Data[k] = recursiveReplace(v, replacer) + } + + return nil +} + +func recursiveReplace(v any, replacer *strings.Replacer) any { + switch s := v.(type) { + case string: + return replacer.Replace(s) + case int, uint, int64, int32, int16, int8, uint64, uint32, uint16, uint8: + // if the secret is encodable in 64 bits ... it is probably not a great secret + return v + case time.Duration: + return v + } + // replace this with a log after more testing + panic(fmt.Sprintf("Had a logrus.fields value with type %T, please report that this is unsupported", v)) +} diff --git a/secretsource/interface.go b/secretsource/interface.go new file mode 100644 index 00000000000..b65d5cf8f61 --- /dev/null +++ b/secretsource/interface.go @@ -0,0 +1,8 @@ +package secretsource + +// SecretSource is the interface a secret source needs to implement +type SecretSource interface { + Name() string + Description() string + Get(key string) (value string, err error) +} diff --git a/secretsource/manager.go b/secretsource/manager.go new file mode 100644 index 00000000000..2ae605c0e23 --- /dev/null +++ b/secretsource/manager.go @@ -0,0 +1,70 @@ +package secretsource + +import ( + "fmt" + "sync" + + "github.com/sirupsen/logrus" +) + +// DefaultSourceName is the name for the default secret source +const DefaultSourceName = "default" + +// SecretsManager manages secrets making certain for them to be redacted from logs +type SecretsManager struct { + hook *secretsHook + sources map[string]SecretSource + cache map[string]*sync.Map +} + +// NewSecretsManager returns a new NewSecretsManager with the provided secretsHook and will redact secrets from the hook +func NewSecretsManager(sources map[string]SecretSource) (*SecretsManager, logrus.Hook, error) { + cache := make(map[string]*sync.Map, len(sources)-1) + hook := &secretsHook{} + if len(sources) == 0 { + return &SecretsManager{ + hook: hook, + cache: cache, + }, hook, nil + } + defaultSource := sources["default"] + cache["default"] = new(sync.Map) + for k, source := range sources { + if k == "default" { + continue + } + if source == defaultSource { + cache[k] = cache["default"] + continue + } + cache[k] = new(sync.Map) + } + sm := &SecretsManager{ + hook: hook, + sources: sources, + cache: cache, + } + return sm, hook, nil +} + +// Get is the way to get a secret for the provided source name and key of the secret. +// It can be used with the [DefaultSourceName]. +// This automatically starts redacting the secret before returning it. +func (sm *SecretsManager) Get(sourceName, key string) (string, error) { + sourceCache, ok := sm.cache[sourceName] + if !ok { + return "", fmt.Errorf("no source with name %s", sourceName) + } + v, ok := sourceCache.Load(key) + if ok { + return v.(string), nil //nolint:forcetypeassert + } + source := sm.sources[sourceName] + value, err := source.Get(key) + if err != nil { + return "", err + } + sourceCache.Store(key, value) + sm.hook.add(value) + return value, err +} From cc5f8b9219e6fba3d5951b565ca34f9c6e6ed134 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov <312246+mstoykov@users.noreply.github.com> Date: Thu, 27 Feb 2025 18:11:25 +0200 Subject: [PATCH 02/10] Apply suggestions from code review --- internal/cmd/root.go | 2 -- secretsource/doc.go | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 70207c158bf..c8c08b13ec5 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -336,8 +336,6 @@ func createSecretSources(gs *state.GlobalState) (map[string]secretsource.SecretS c := found.Module.(secretsource.Constructor) //nolint:forcetypeassert params := baseParams params.ConfigArgument = config - // TODO(@mstoykov): make it not configurable just from cmd line - // params.JSONConfig = test.derivedConfig.Collectors[outputType] secretSource, err := c(params) if err != nil { diff --git a/secretsource/doc.go b/secretsource/doc.go index fe7d953f963..a34a94192d4 100644 --- a/secretsource/doc.go +++ b/secretsource/doc.go @@ -1,4 +1,4 @@ // Package secretsource is a package to provide secret source interface and common functionality -// This functionality is to be used to provide k6 with a way to get secrets and help it handl them correctly. -// Predominantly by not redact them from logs. +// This functionality is to be used to provide k6 with a way to get secrets and help it handle them correctly. +// Predominantly by redacting them from logs. package secretsource From 9b5f279be1081f7a6ee704f510c883a7acdc209d Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 28 Feb 2025 12:25:50 +0200 Subject: [PATCH 03/10] Fix file secret source parsing and add tests --- internal/secretsource/file/file.go | 1 + internal/secretsource/file/file_test.go | 52 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 internal/secretsource/file/file_test.go diff --git a/internal/secretsource/file/file.go b/internal/secretsource/file/file.go index ca2293f8bae..54009d73a98 100644 --- a/internal/secretsource/file/file.go +++ b/internal/secretsource/file/file.go @@ -45,6 +45,7 @@ func (fss *fileSecretSource) parseArg(config string) error { k, v, ok := strings.Cut(kv, "=") if !ok { fss.filename = kv + continue } switch k { case "filename": diff --git a/internal/secretsource/file/file_test.go b/internal/secretsource/file/file_test.go new file mode 100644 index 00000000000..3893bd336bc --- /dev/null +++ b/internal/secretsource/file/file_test.go @@ -0,0 +1,52 @@ +package file + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseArg(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + input string + expectedName string + expectedFilename string + expectedError string + }{ + "simple": { + input: "something.secret", + expectedName: "", + expectedFilename: "something.secret", + }, + "filename": { + input: "filename=something.secret", + expectedName: "", + expectedFilename: "something.secret", + }, + "filename and name": { + input: "filename=something.secret,name=cool", + expectedName: "cool", + expectedFilename: "something.secret", + }, + "unknownfiled": { + input: "filename=something.secret,name=cool,random=bad", + expectedError: "unknown configuration key for file secret source \"random\"", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + fss := &fileSecretSource{} + err := fss.parseArg(testCase.input) + if testCase.expectedError != "" { + require.ErrorContains(t, err, testCase.expectedError) + return + } + require.NoError(t, err) + require.Equal(t, testCase.expectedFilename, fss.filename) + require.Equal(t, testCase.expectedName, fss.name) + }) + } +} From 4b6ad310c35584a5bdaac5367a7feb1c022dc5ab Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 28 Feb 2025 15:20:07 +0200 Subject: [PATCH 04/10] Move to k6/secrets and add example --- examples/secrets/file.secret | 2 + examples/secrets/secrets.test.js | 9 ++ internal/js/jsmodules.go | 2 + internal/js/modules/k6/secrets/secrets.go | 79 +++++++++++ .../js/modules/k6/secrets/secrets_test.go | 122 +++++++++++++++++ js/modules/k6/k6.go | 40 +----- js/modules/k6/k6_test.go | 127 ------------------ 7 files changed, 216 insertions(+), 165 deletions(-) create mode 100644 examples/secrets/file.secret create mode 100644 examples/secrets/secrets.test.js create mode 100644 internal/js/modules/k6/secrets/secrets.go create mode 100644 internal/js/modules/k6/secrets/secrets_test.go diff --git a/examples/secrets/file.secret b/examples/secrets/file.secret new file mode 100644 index 00000000000..93617d5cf22 --- /dev/null +++ b/examples/secrets/file.secret @@ -0,0 +1,2 @@ +cool=some +else=source diff --git a/examples/secrets/secrets.test.js b/examples/secrets/secrets.test.js new file mode 100644 index 00000000000..fb57d06322f --- /dev/null +++ b/examples/secrets/secrets.test.js @@ -0,0 +1,9 @@ +// k6 run --secret-source=file=file.secret secrets.test.js +import secrets from "k6/secrets"; + +export default () => { + const my_secret = secrets.get("cool"); // get secret from a source with the provided identifier + console.log(my_secret); + secrets.get("else"); // get secret from a source with the provided identifier + console.log(my_secret); +} diff --git a/internal/js/jsmodules.go b/internal/js/jsmodules.go index 61cf1aaeb80..1db3ae6b3d0 100644 --- a/internal/js/jsmodules.go +++ b/internal/js/jsmodules.go @@ -17,6 +17,7 @@ import ( expws "go.k6.io/k6/internal/js/modules/k6/experimental/websockets" "go.k6.io/k6/internal/js/modules/k6/grpc" "go.k6.io/k6/internal/js/modules/k6/metrics" + "go.k6.io/k6/internal/js/modules/k6/secrets" "go.k6.io/k6/internal/js/modules/k6/timers" "go.k6.io/k6/internal/js/modules/k6/webcrypto" "go.k6.io/k6/internal/js/modules/k6/ws" @@ -62,6 +63,7 @@ func getInternalJSModules() map[string]interface{} { "k6/html": html.New(), "k6/http": http.New(), "k6/metrics": metrics.New(), + "k6/secrets": secrets.New(), "k6/ws": ws.New(), "k6/experimental/grpc": newRemovedModule( "k6/experimental/grpc has been graduated, please use k6/net/grpc instead." + diff --git a/internal/js/modules/k6/secrets/secrets.go b/internal/js/modules/k6/secrets/secrets.go new file mode 100644 index 00000000000..a43d451eb77 --- /dev/null +++ b/internal/js/modules/k6/secrets/secrets.go @@ -0,0 +1,79 @@ +// Package secrets implements `k6/secrets` giving access to secrets from secret sources to js code. +package secrets + +import ( + "github.com/grafana/sobek" + + "go.k6.io/k6/js/common" + "go.k6.io/k6/js/modules" + "go.k6.io/k6/secretsource" +) + +type ( + // RootModule is the global module instance that will create module + // instances for each VU. + RootModule struct{} + + // Secrets represents an instance of the k6 module. + Secrets struct { + vu modules.VU + secretsManager *secretsource.SecretsManager + } +) + +var ( + _ modules.Module = &RootModule{} + _ modules.Instance = &Secrets{} +) + +// New returns a pointer to a new RootModule instance. +func New() *RootModule { + return &RootModule{} +} + +// NewModuleInstance implements the modules.Module interface to return +// a new instance for each VU. +func (*RootModule) NewModuleInstance(vu modules.VU) modules.Instance { + return &Secrets{vu: vu, secretsManager: vu.InitEnv().SecretsManager} +} + +// Exports returns the exports of the k6 module. +func (mi *Secrets) Exports() modules.Exports { + s, err := mi.secrets() + if err != nil { + common.Throw(mi.vu.Runtime(), err) + } + return modules.Exports{ + Default: s, + Named: make(map[string]any), // this is intentially not nil so it doesn't export anything as named expeorts + } +} + +func (mi *Secrets) secrets() (*sobek.Object, error) { + obj, err := secretSourceObjectForSourceName(mi.vu.Runtime(), mi.secretsManager, secretsource.DefaultSourceName) + if err != nil { + return nil, err + } + + err = obj.Set("source", func(sourceName string) (*sobek.Object, error) { + return secretSourceObjectForSourceName(mi.vu.Runtime(), mi.secretsManager, sourceName) + }) + if err != nil { + return nil, err + } + + return obj, nil +} + +func secretSourceObjectForSourceName( + rt *sobek.Runtime, manager *secretsource.SecretsManager, sourceName string, +) (*sobek.Object, error) { + obj := rt.NewObject() + err := obj.Set("get", func(key string) (string, error) { + return manager.Get(sourceName, key) + }) + if err != nil { + return nil, err + } + return obj, nil +} diff --git a/internal/js/modules/k6/secrets/secrets_test.go b/internal/js/modules/k6/secrets/secrets_test.go new file mode 100644 index 00000000000..a613c76da52 --- /dev/null +++ b/internal/js/modules/k6/secrets/secrets_test.go @@ -0,0 +1,122 @@ +package secrets + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/internal/secretsource/mock" + "go.k6.io/k6/js/modulestest" + "go.k6.io/k6/secretsource" +) + +func testRuntimeWithSecrets(t testing.TB, secretSources map[string]secretsource.SecretSource) *modulestest.Runtime { + testRuntime := modulestest.NewRuntime(t) + var err error + testRuntime.VU.InitEnvField.SecretsManager, _, err = secretsource.NewSecretsManager(secretSources) + require.NoError(t, err) + + m, ok := New().NewModuleInstance(testRuntime.VU).(*Secrets) + require.True(t, ok) + require.NoError(t, testRuntime.VU.RuntimeField.Set("secrets", m.Exports().Default)) + + return testRuntime +} + +func TestSecrets(t *testing.T) { + t.Parallel() + + type secretsTest struct { + secretsources map[string]secretsource.SecretSource + script string + expectedValue any + expectedError string + } + + cases := map[string]secretsTest{ + "simple": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + }, + script: "secrets.get('secret')", + expectedValue: "value", + }, + "error": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + }, + script: "secrets.get('not_secret')", + expectedError: "no value", + }, + "multiple": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + "second": mock.NewMockSecretSource("some", map[string]string{ + "secret2": "value2", + }), + }, + script: "secrets.get('secret')", + expectedValue: "value", + }, + "multiple get default": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + "second": mock.NewMockSecretSource("some", map[string]string{ + "secret2": "value2", + }), + }, + script: "secrets.source('default').get('secret')", + expectedValue: "value", + }, + "multiple get not default": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + "second": mock.NewMockSecretSource("some", map[string]string{ + "secret2": "value2", + }), + }, + script: "secrets.source('second').get('secret2')", + expectedValue: "value2", + }, + "get secret without source": { + secretsources: map[string]secretsource.SecretSource{}, + script: "secrets.get('secret')", + expectedError: "no source with name default", + }, + "get none existing source": { + secretsources: map[string]secretsource.SecretSource{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + }, + script: "secrets.source('second') != undefined", + expectedValue: true, + }, + } + + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + testruntime := testRuntimeWithSecrets(t, testCase.secretsources) + + v, err := testruntime.RunOnEventLoop(testCase.script) + if testCase.expectedError != "" { + require.ErrorContains(t, err, testCase.expectedError) + return + } + require.NoError(t, err) + assert.Equal(t, testCase.expectedValue, v.Export()) + }) + } +} diff --git a/js/modules/k6/k6.go b/js/modules/k6/k6.go index 159cfe908f1..822c25ec4be 100644 --- a/js/modules/k6/k6.go +++ b/js/modules/k6/k6.go @@ -13,7 +13,6 @@ import ( "go.k6.io/k6/js/modules" "go.k6.io/k6/lib" "go.k6.io/k6/metrics" - "go.k6.io/k6/secretsource" ) var ( @@ -31,8 +30,7 @@ type ( // K6 represents an instance of the k6 module. K6 struct { - vu modules.VU - secretsManager *secretsource.SecretsManager + vu modules.VU } ) @@ -49,15 +47,11 @@ func New() *RootModule { // NewModuleInstance implements the modules.Module interface to return // a new instance for each VU. func (*RootModule) NewModuleInstance(vu modules.VU) modules.Instance { - return &K6{vu: vu, secretsManager: vu.InitEnv().SecretsManager} + return &K6{vu: vu} } // Exports returns the exports of the k6 module. func (mi *K6) Exports() modules.Exports { - s, err := mi.secrets() - if err != nil { - common.Throw(mi.vu.Runtime(), err) - } return modules.Exports{ Named: map[string]interface{}{ "check": mi.Check, @@ -65,40 +59,10 @@ func (mi *K6) Exports() modules.Exports { "group": mi.Group, "randomSeed": mi.RandomSeed, "sleep": mi.Sleep, - "secrets": s, }, } } -func (mi *K6) secrets() (*sobek.Object, error) { - obj, err := secretSourceObjectForSourceName(mi.vu.Runtime(), mi.secretsManager, secretsource.DefaultSourceName) - if err != nil { - return nil, err - } - - err = obj.Set("source", func(sourceName string) (*sobek.Object, error) { - return secretSourceObjectForSourceName(mi.vu.Runtime(), mi.secretsManager, sourceName) - }) - if err != nil { - return nil, err - } - - return obj, nil -} - -func secretSourceObjectForSourceName( - rt *sobek.Runtime, manager *secretsource.SecretsManager, sourceName string, -) (*sobek.Object, error) { - obj := rt.NewObject() - err := obj.Set("get", func(key string) (string, error) { - return manager.Get(sourceName, key) - }) - if err != nil { - return nil, err - } - return obj, nil -} - // Fail is a fancy way of saying `throw "something"`. func (*K6) Fail(msg string) (sobek.Value, error) { return sobek.Undefined(), errors.New(msg) diff --git a/js/modules/k6/k6_test.go b/js/modules/k6/k6_test.go index 6d57a23b44d..dda4805adeb 100644 --- a/js/modules/k6/k6_test.go +++ b/js/modules/k6/k6_test.go @@ -8,11 +8,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.k6.io/k6/internal/secretsource/mock" "go.k6.io/k6/js/modulestest" "go.k6.io/k6/lib" "go.k6.io/k6/metrics" - "go.k6.io/k6/secretsource" ) func TestFail(t *testing.T) { @@ -399,128 +397,3 @@ func testCaseRuntime(t testing.TB) *testCase { testRuntime: testRuntime, } } - -func testCaseRuntimeWithSecrets(t testing.TB, secretSources map[string]secretsource.SecretSource) *testCase { - testRuntime := modulestest.NewRuntime(t) - var err error - testRuntime.VU.InitEnvField.SecretsManager, _, err = secretsource.NewSecretsManager(secretSources) - require.NoError(t, err) - - m, ok := New().NewModuleInstance(testRuntime.VU).(*K6) - require.True(t, ok) - require.NoError(t, testRuntime.VU.RuntimeField.Set("k6", m.Exports().Named)) - - registry := metrics.NewRegistry() - samples := make(chan metrics.SampleContainer, 1000) - state := &lib.State{ - Options: lib.Options{ - SystemTags: &metrics.DefaultSystemTagSet, - }, - Samples: samples, - Tags: lib.NewVUStateTags(registry.RootTagSet().WithTagsFromMap(map[string]string{"group": lib.RootGroupPath})), - BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), - } - testRuntime.MoveToVUContext(state) - - return &testCase{ - samples: samples, - testRuntime: testRuntime, - } -} - -func TestSecrets(t *testing.T) { - t.Parallel() - - type secretsTest struct { - secretsources map[string]secretsource.SecretSource - script string - expectedValue any - expectedError string - } - - cases := map[string]secretsTest{ - "simple": { - secretsources: map[string]secretsource.SecretSource{ - "default": mock.NewMockSecretSource("some", map[string]string{ - "secret": "value", - }), - }, - script: "k6.secrets.get('secret')", - expectedValue: "value", - }, - "error": { - secretsources: map[string]secretsource.SecretSource{ - "default": mock.NewMockSecretSource("some", map[string]string{ - "secret": "value", - }), - }, - script: "k6.secrets.get('not_secret')", - expectedError: "no value", - }, - "multiple": { - secretsources: map[string]secretsource.SecretSource{ - "default": mock.NewMockSecretSource("some", map[string]string{ - "secret": "value", - }), - "second": mock.NewMockSecretSource("some", map[string]string{ - "secret2": "value2", - }), - }, - script: "k6.secrets.get('secret')", - expectedValue: "value", - }, - "multiple get default": { - secretsources: map[string]secretsource.SecretSource{ - "default": mock.NewMockSecretSource("some", map[string]string{ - "secret": "value", - }), - "second": mock.NewMockSecretSource("some", map[string]string{ - "secret2": "value2", - }), - }, - script: "k6.secrets.source('default').get('secret')", - expectedValue: "value", - }, - "multiple get not default": { - secretsources: map[string]secretsource.SecretSource{ - "default": mock.NewMockSecretSource("some", map[string]string{ - "secret": "value", - }), - "second": mock.NewMockSecretSource("some", map[string]string{ - "secret2": "value2", - }), - }, - script: "k6.secrets.source('second').get('secret2')", - expectedValue: "value2", - }, - "get secret without source": { - secretsources: map[string]secretsource.SecretSource{}, - script: "k6.secrets.get('secret')", - expectedError: "no source with name default", - }, - "get none existing source": { - secretsources: map[string]secretsource.SecretSource{ - "default": mock.NewMockSecretSource("some", map[string]string{ - "secret": "value", - }), - }, - script: "k6.secrets.source('second') != undefined", - expectedValue: true, - }, - } - - for name, testCase := range cases { - t.Run(name, func(t *testing.T) { - t.Parallel() - tc := testCaseRuntimeWithSecrets(t, testCase.secretsources) - - v, err := tc.testRuntime.RunOnEventLoop(testCase.script) - if testCase.expectedError != "" { - require.ErrorContains(t, err, testCase.expectedError) - return - } - require.NoError(t, err) - assert.Equal(t, testCase.expectedValue, v.Export()) - }) - } -} From b95a49c2fbea0a5b26ae113aa153849ebd4c80bc Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 28 Feb 2025 15:27:25 +0200 Subject: [PATCH 05/10] Renames and documentaitons --- cmd/state/state.go | 2 +- internal/cmd/root.go | 6 +++--- internal/js/modules/k6/secrets/secrets.go | 4 ++-- .../js/modules/k6/secrets/secrets_test.go | 20 +++++++++---------- internal/secretsource/file/file.go | 2 +- internal/secretsource/mock/mock.go | 4 ++-- lib/test_state.go | 2 +- secretsource/extension.go | 4 ++-- secretsource/interface.go | 6 ++++-- secretsource/manager.go | 16 +++++++-------- 10 files changed, 34 insertions(+), 32 deletions(-) diff --git a/cmd/state/state.go b/cmd/state/state.go index 6a94cb4c555..13a08d596f5 100644 --- a/cmd/state/state.go +++ b/cmd/state/state.go @@ -58,7 +58,7 @@ type GlobalState struct { Logger *logrus.Logger //nolint:forbidigo //TODO:change to FieldLogger FallbackLogger logrus.FieldLogger - SecretsManager *secretsource.SecretsManager + SecretsManager *secretsource.Manager Usage *usage.Usage } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c8c08b13ec5..a0336b5a2f9 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -272,7 +272,7 @@ func (c *rootCommand) setupLoggers(stop <-chan struct{}) error { // it is important that we add this hook first as hooks are executed in order of addition // and this means no other hook will get secrets var secretsHook logrus.Hook - c.globalState.SecretsManager, secretsHook, err = secretsource.NewSecretsManager(secretsources) + c.globalState.SecretsManager, secretsHook, err = secretsource.NewManager(secretsources) if err != nil { return err } @@ -314,7 +314,7 @@ func (c *rootCommand) setLoggerHook(ctx context.Context, h log.AsyncHook) { c.globalState.Logger.SetOutput(io.Discard) // don't output to anywhere else } -func createSecretSources(gs *state.GlobalState) (map[string]secretsource.SecretSource, error) { +func createSecretSources(gs *state.GlobalState) (map[string]secretsource.Source, error) { baseParams := secretsource.Params{ Logger: gs.Logger, Environment: gs.Env, @@ -322,7 +322,7 @@ func createSecretSources(gs *state.GlobalState) (map[string]secretsource.SecretS Usage: gs.Usage, } - result := make(map[string]secretsource.SecretSource) + result := make(map[string]secretsource.Source) for _, line := range gs.Flags.SecretSource { t, config, ok := strings.Cut(line, "=") if !ok { diff --git a/internal/js/modules/k6/secrets/secrets.go b/internal/js/modules/k6/secrets/secrets.go index a43d451eb77..72df0796abd 100644 --- a/internal/js/modules/k6/secrets/secrets.go +++ b/internal/js/modules/k6/secrets/secrets.go @@ -17,7 +17,7 @@ type ( // Secrets represents an instance of the k6 module. Secrets struct { vu modules.VU - secretsManager *secretsource.SecretsManager + secretsManager *secretsource.Manager } ) @@ -66,7 +66,7 @@ func (mi *Secrets) secrets() (*sobek.Object, error) { } func secretSourceObjectForSourceName( - rt *sobek.Runtime, manager *secretsource.SecretsManager, sourceName string, + rt *sobek.Runtime, manager *secretsource.Manager, sourceName string, ) (*sobek.Object, error) { obj := rt.NewObject() err := obj.Set("get", func(key string) (string, error) { diff --git a/internal/js/modules/k6/secrets/secrets_test.go b/internal/js/modules/k6/secrets/secrets_test.go index a613c76da52..087c491fb59 100644 --- a/internal/js/modules/k6/secrets/secrets_test.go +++ b/internal/js/modules/k6/secrets/secrets_test.go @@ -11,10 +11,10 @@ import ( "go.k6.io/k6/secretsource" ) -func testRuntimeWithSecrets(t testing.TB, secretSources map[string]secretsource.SecretSource) *modulestest.Runtime { +func testRuntimeWithSecrets(t testing.TB, secretSources map[string]secretsource.Source) *modulestest.Runtime { testRuntime := modulestest.NewRuntime(t) var err error - testRuntime.VU.InitEnvField.SecretsManager, _, err = secretsource.NewSecretsManager(secretSources) + testRuntime.VU.InitEnvField.SecretsManager, _, err = secretsource.NewManager(secretSources) require.NoError(t, err) m, ok := New().NewModuleInstance(testRuntime.VU).(*Secrets) @@ -28,7 +28,7 @@ func TestSecrets(t *testing.T) { t.Parallel() type secretsTest struct { - secretsources map[string]secretsource.SecretSource + secretsources map[string]secretsource.Source script string expectedValue any expectedError string @@ -36,7 +36,7 @@ func TestSecrets(t *testing.T) { cases := map[string]secretsTest{ "simple": { - secretsources: map[string]secretsource.SecretSource{ + secretsources: map[string]secretsource.Source{ "default": mock.NewMockSecretSource("some", map[string]string{ "secret": "value", }), @@ -45,7 +45,7 @@ func TestSecrets(t *testing.T) { expectedValue: "value", }, "error": { - secretsources: map[string]secretsource.SecretSource{ + secretsources: map[string]secretsource.Source{ "default": mock.NewMockSecretSource("some", map[string]string{ "secret": "value", }), @@ -54,7 +54,7 @@ func TestSecrets(t *testing.T) { expectedError: "no value", }, "multiple": { - secretsources: map[string]secretsource.SecretSource{ + secretsources: map[string]secretsource.Source{ "default": mock.NewMockSecretSource("some", map[string]string{ "secret": "value", }), @@ -66,7 +66,7 @@ func TestSecrets(t *testing.T) { expectedValue: "value", }, "multiple get default": { - secretsources: map[string]secretsource.SecretSource{ + secretsources: map[string]secretsource.Source{ "default": mock.NewMockSecretSource("some", map[string]string{ "secret": "value", }), @@ -78,7 +78,7 @@ func TestSecrets(t *testing.T) { expectedValue: "value", }, "multiple get not default": { - secretsources: map[string]secretsource.SecretSource{ + secretsources: map[string]secretsource.Source{ "default": mock.NewMockSecretSource("some", map[string]string{ "secret": "value", }), @@ -90,12 +90,12 @@ func TestSecrets(t *testing.T) { expectedValue: "value2", }, "get secret without source": { - secretsources: map[string]secretsource.SecretSource{}, + secretsources: map[string]secretsource.Source{}, script: "secrets.get('secret')", expectedError: "no source with name default", }, "get none existing source": { - secretsources: map[string]secretsource.SecretSource{ + secretsources: map[string]secretsource.Source{ "default": mock.NewMockSecretSource("some", map[string]string{ "secret": "value", }), diff --git a/internal/secretsource/file/file.go b/internal/secretsource/file/file.go index 54009d73a98..ec76b618871 100644 --- a/internal/secretsource/file/file.go +++ b/internal/secretsource/file/file.go @@ -11,7 +11,7 @@ import ( ) func init() { - secretsource.RegisterExtension("file", func(params secretsource.Params) (secretsource.SecretSource, error) { + secretsource.RegisterExtension("file", func(params secretsource.Params) (secretsource.Source, error) { fss := &fileSecretSource{} err := fss.parseArg(params.ConfigArgument) if err != nil { diff --git a/internal/secretsource/mock/mock.go b/internal/secretsource/mock/mock.go index 10c1bb5a98b..d1358d3917f 100644 --- a/internal/secretsource/mock/mock.go +++ b/internal/secretsource/mock/mock.go @@ -10,7 +10,7 @@ import ( ) func init() { - secretsource.RegisterExtension("mock", func(params secretsource.Params) (secretsource.SecretSource, error) { + secretsource.RegisterExtension("mock", func(params secretsource.Params) (secretsource.Source, error) { list := strings.Split(params.ConfigArgument, ",") secrets := make(map[string]string, len(list)) name := "mock" @@ -31,7 +31,7 @@ func init() { } // NewMockSecretSource returns a new secret source mock with the provided name and map of secrets -func NewMockSecretSource(name string, secrets map[string]string) secretsource.SecretSource { +func NewMockSecretSource(name string, secrets map[string]string) secretsource.Source { return &mockSecretSource{ internal: secrets, name: name, diff --git a/lib/test_state.go b/lib/test_state.go index 1e1e79d1654..95a8cd9a3ad 100644 --- a/lib/test_state.go +++ b/lib/test_state.go @@ -25,7 +25,7 @@ type TestPreInitState struct { Logger logrus.FieldLogger TracerProvider *trace.TracerProvider Usage *usage.Usage - SecretsManager *secretsource.SecretsManager + SecretsManager *secretsource.Manager } // TestRunState contains the pre-init state as well as all of the state and diff --git a/secretsource/extension.go b/secretsource/extension.go index 5bd58759964..a64ef3425ed 100644 --- a/secretsource/extension.go +++ b/secretsource/extension.go @@ -8,11 +8,11 @@ import ( ) // Constructor returns an instance of an output extension module. -type Constructor func(Params) (SecretSource, error) +type Constructor func(Params) (Source, error) // Params contains all possible constructor parameters an output may need. type Params struct { - ConfigArgument string + ConfigArgument string // the string on the cli Logger logrus.FieldLogger Environment map[string]string diff --git a/secretsource/interface.go b/secretsource/interface.go index b65d5cf8f61..97fea51caf7 100644 --- a/secretsource/interface.go +++ b/secretsource/interface.go @@ -1,8 +1,10 @@ package secretsource -// SecretSource is the interface a secret source needs to implement -type SecretSource interface { +// Source is the interface a secret source needs to implement +type Source interface { + // A name to be used when k6 has multiple sources Name() string + // Human readable description to be printed on the cli Description() string Get(key string) (value string, err error) } diff --git a/secretsource/manager.go b/secretsource/manager.go index 2ae605c0e23..e212eeeb07a 100644 --- a/secretsource/manager.go +++ b/secretsource/manager.go @@ -10,19 +10,19 @@ import ( // DefaultSourceName is the name for the default secret source const DefaultSourceName = "default" -// SecretsManager manages secrets making certain for them to be redacted from logs -type SecretsManager struct { +// Manager manages secrets making certain for them to be redacted from logs +type Manager struct { hook *secretsHook - sources map[string]SecretSource + sources map[string]Source cache map[string]*sync.Map } -// NewSecretsManager returns a new NewSecretsManager with the provided secretsHook and will redact secrets from the hook -func NewSecretsManager(sources map[string]SecretSource) (*SecretsManager, logrus.Hook, error) { +// NewManager returns a new NewManager with the provided secretsHook and will redact secrets from the hook +func NewManager(sources map[string]Source) (*Manager, logrus.Hook, error) { cache := make(map[string]*sync.Map, len(sources)-1) hook := &secretsHook{} if len(sources) == 0 { - return &SecretsManager{ + return &Manager{ hook: hook, cache: cache, }, hook, nil @@ -39,7 +39,7 @@ func NewSecretsManager(sources map[string]SecretSource) (*SecretsManager, logrus } cache[k] = new(sync.Map) } - sm := &SecretsManager{ + sm := &Manager{ hook: hook, sources: sources, cache: cache, @@ -50,7 +50,7 @@ func NewSecretsManager(sources map[string]SecretSource) (*SecretsManager, logrus // Get is the way to get a secret for the provided source name and key of the secret. // It can be used with the [DefaultSourceName]. // This automatically starts redacting the secret before returning it. -func (sm *SecretsManager) Get(sourceName, key string) (string, error) { +func (sm *Manager) Get(sourceName, key string) (string, error) { sourceCache, ok := sm.cache[sourceName] if !ok { return "", fmt.Errorf("no source with name %s", sourceName) From 3e9acf0769ccb93127eee8baee54458b33ad5f91 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 28 Feb 2025 15:59:46 +0200 Subject: [PATCH 06/10] Integration tests and fix for mock's name --- internal/cmd/tests/cmd_run_test.go | 66 ++++++++++++++++++++++++++++++ internal/secretsource/mock/mock.go | 2 +- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/internal/cmd/tests/cmd_run_test.go b/internal/cmd/tests/cmd_run_test.go index 9539dbdf1e5..3d8ab60e615 100644 --- a/internal/cmd/tests/cmd_run_test.go +++ b/internal/cmd/tests/cmd_run_test.go @@ -2402,3 +2402,69 @@ func TestTypeScriptSupport(t *testing.T) { t.Log(stderr) assert.Contains(t, stderr, `something 42`) } + +func TestBasicSecrets(t *testing.T) { + // This is the example it will be nice if we can just run it ,but in this case it needs extra arguments so ... maybe not such a great idea + t.Parallel() + mainScript := ` + import secrets from "k6/secrets"; + + export default () => { + const my_secret = secrets.get("cool"); // get secret from a source with the provided identifier + console.log(my_secret); + secrets.get("else"); // get secret from a source with the provided identifier + console.log(my_secret); + } + ` + + ts := NewGlobalTestState(t) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "secrets.js"), []byte(mainScript), 0o644)) + + ts.CmdArgs = []string{"k6", "run", "--secret-source=mock=cool=something,else=source", "secrets.js"} + + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stderr := ts.Stderr.String() + t.Log(stderr) + + assert.Contains(t, stderr, `level=info msg="***SECRET_REDACTED***" source=console`) + assert.Contains(t, stderr, `level=info msg="***SECRET_REDACTED***" ***SECRET_REDACTED***=console`) +} + +func TestMultipleSecretSources(t *testing.T) { + // This is the example it will be nice if we can just run it ,but in this case it needs extra arguments so ... maybe not such a great idea + t.Parallel() + mainScript := ` + import secrets from "k6/secrets"; + + export default () => { + const my_secret = secrets.source("first").get("cool"); + console.log(my_secret); + secrets.source("second").get("else"); + console.log(my_secret); + try { + secrets.source("second").get("unkwown"); + } catch { + console.log("trigger exception on wrong key") + } + } + ` + + ts := NewGlobalTestState(t) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "secrets.js"), []byte(mainScript), 0o644)) + + ts.CmdArgs = []string{ + "k6", "run", + "--secret-source=mock=name=first,cool=something", + "--secret-source=mock=name=second,else=source", "secrets.js", + } + + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stderr := ts.Stderr.String() + t.Log(stderr) + + assert.Contains(t, stderr, `level=info msg="***SECRET_REDACTED***" source=console`) + assert.Contains(t, stderr, `level=info msg="***SECRET_REDACTED***" ***SECRET_REDACTED***=console`) + assert.Contains(t, stderr, `level=info msg="trigger exception on wrong key" ***SECRET_REDACTED***=console`) +} diff --git a/internal/secretsource/mock/mock.go b/internal/secretsource/mock/mock.go index d1358d3917f..fcaa4381085 100644 --- a/internal/secretsource/mock/mock.go +++ b/internal/secretsource/mock/mock.go @@ -20,7 +20,7 @@ func init() { return nil, fmt.Errorf("parsing %q, needs =", kv) } if k == "name" { - name = k + name = v continue } From 392f4a6ca5929c52b3c7f441b2036b7f6b82a0e2 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 28 Feb 2025 16:22:43 +0200 Subject: [PATCH 07/10] Move parsing of name and defaults out of each secret source + integration tests --- internal/cmd/root.go | 26 ++++++++++++++++++++++++- internal/cmd/tests/cmd_run_test.go | 3 ++- internal/secretsource/file/file.go | 7 ------- internal/secretsource/file/file_test.go | 9 ++------- internal/secretsource/mock/mock.go | 4 ---- secretsource/interface.go | 2 -- secretsource/manager.go | 4 +++- 7 files changed, 32 insertions(+), 23 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a0336b5a2f9..8084d02c722 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -335,18 +335,24 @@ func createSecretSources(gs *state.GlobalState) (map[string]secretsource.Source, } c := found.Module.(secretsource.Constructor) //nolint:forcetypeassert params := baseParams + name, isDefault, config := extractNameAndDefault(config) params.ConfigArgument = config secretSource, err := c(params) if err != nil { return nil, err } - name := secretSource.Name() _, alreadRegistered := result[name] if alreadRegistered { return nil, fmt.Errorf("secret source for name %q already registered before configuration %q", t, line) } result[name] = secretSource + if isDefault { + if _, ok := result["default"]; ok { + return nil, fmt.Errorf("can't have two secret sources that are default ones, second one was %q", config) + } + result["default"] = secretSource + } } if len(result) == 1 { @@ -357,3 +363,21 @@ func createSecretSources(gs *state.GlobalState) (map[string]secretsource.Source, return result, nil } + +func extractNameAndDefault(config string) (name string, isDefault bool, remaining string) { + list := strings.Split(config, ",") + remainingArray := make([]string, 0, len(list)) + for _, kv := range list { + if kv == "default" { + isDefault = true + continue + } + k, v, _ := strings.Cut(kv, "=") + if k == "name" { + name = v + continue + } + remainingArray = append(remainingArray, kv) + } + return name, isDefault, strings.Join(remainingArray, ",") +} diff --git a/internal/cmd/tests/cmd_run_test.go b/internal/cmd/tests/cmd_run_test.go index 3d8ab60e615..7935bb2a0bc 100644 --- a/internal/cmd/tests/cmd_run_test.go +++ b/internal/cmd/tests/cmd_run_test.go @@ -2447,6 +2447,7 @@ func TestMultipleSecretSources(t *testing.T) { } catch { console.log("trigger exception on wrong key") } + secrets.get("else"); // testing default setting } ` @@ -2456,7 +2457,7 @@ func TestMultipleSecretSources(t *testing.T) { ts.CmdArgs = []string{ "k6", "run", "--secret-source=mock=name=first,cool=something", - "--secret-source=mock=name=second,else=source", "secrets.js", + "--secret-source=mock=name=second,else=source,default", "secrets.js", } cmd.ExecuteWithGlobalState(ts.GlobalState) diff --git a/internal/secretsource/file/file.go b/internal/secretsource/file/file.go index ec76b618871..a8fd7fcbb34 100644 --- a/internal/secretsource/file/file.go +++ b/internal/secretsource/file/file.go @@ -50,8 +50,6 @@ func (fss *fileSecretSource) parseArg(config string) error { switch k { case "filename": fss.filename = v - case "name": - fss.name = v default: return fmt.Errorf("unknown configuration key for file secret source %q", k) } @@ -62,14 +60,9 @@ func (fss *fileSecretSource) parseArg(config string) error { type fileSecretSource struct { internal map[string]string - name string filename string } -func (fss *fileSecretSource) Name() string { - return fss.name -} - func (fss *fileSecretSource) Description() string { return fmt.Sprintf("file source from %s", fss.filename) } diff --git a/internal/secretsource/file/file_test.go b/internal/secretsource/file/file_test.go index 3893bd336bc..ad52bfd1a20 100644 --- a/internal/secretsource/file/file_test.go +++ b/internal/secretsource/file/file_test.go @@ -10,27 +10,23 @@ func TestParseArg(t *testing.T) { t.Parallel() testCases := map[string]struct { input string - expectedName string expectedFilename string expectedError string }{ "simple": { input: "something.secret", - expectedName: "", expectedFilename: "something.secret", }, "filename": { input: "filename=something.secret", - expectedName: "", expectedFilename: "something.secret", }, "filename and name": { - input: "filename=something.secret,name=cool", - expectedName: "cool", + input: "filename=something.secret", expectedFilename: "something.secret", }, "unknownfiled": { - input: "filename=something.secret,name=cool,random=bad", + input: "filename=something.secret,random=bad", expectedError: "unknown configuration key for file secret source \"random\"", }, } @@ -46,7 +42,6 @@ func TestParseArg(t *testing.T) { } require.NoError(t, err) require.Equal(t, testCase.expectedFilename, fss.filename) - require.Equal(t, testCase.expectedName, fss.name) }) } } diff --git a/internal/secretsource/mock/mock.go b/internal/secretsource/mock/mock.go index fcaa4381085..0346f50ff4b 100644 --- a/internal/secretsource/mock/mock.go +++ b/internal/secretsource/mock/mock.go @@ -19,10 +19,6 @@ func init() { if !ok { return nil, fmt.Errorf("parsing %q, needs =", kv) } - if k == "name" { - name = v - continue - } secrets[k] = v } diff --git a/secretsource/interface.go b/secretsource/interface.go index 97fea51caf7..992309a2073 100644 --- a/secretsource/interface.go +++ b/secretsource/interface.go @@ -2,8 +2,6 @@ package secretsource // Source is the interface a secret source needs to implement type Source interface { - // A name to be used when k6 has multiple sources - Name() string // Human readable description to be printed on the cli Description() string Get(key string) (value string, err error) diff --git a/secretsource/manager.go b/secretsource/manager.go index e212eeeb07a..cf20683bb37 100644 --- a/secretsource/manager.go +++ b/secretsource/manager.go @@ -28,7 +28,9 @@ func NewManager(sources map[string]Source) (*Manager, logrus.Hook, error) { }, hook, nil } defaultSource := sources["default"] - cache["default"] = new(sync.Map) + if defaultSource != nil { + cache["default"] = new(sync.Map) + } for k, source := range sources { if k == "default" { continue From 6c6d867f0b9464876ce8ccb1d886ea19afd30613 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 28 Feb 2025 17:44:02 +0200 Subject: [PATCH 08/10] Move to async get --- examples/secrets/secrets.test.js | 6 ++--- internal/cmd/tests/cmd_run_test.go | 16 +++++++------- internal/js/modules/k6/secrets/secrets.go | 22 ++++++++++++++----- .../js/modules/k6/secrets/secrets_test.go | 17 +++++++------- 4 files changed, 36 insertions(+), 25 deletions(-) diff --git a/examples/secrets/secrets.test.js b/examples/secrets/secrets.test.js index fb57d06322f..ea5848f2e9e 100644 --- a/examples/secrets/secrets.test.js +++ b/examples/secrets/secrets.test.js @@ -1,9 +1,9 @@ // k6 run --secret-source=file=file.secret secrets.test.js import secrets from "k6/secrets"; -export default () => { - const my_secret = secrets.get("cool"); // get secret from a source with the provided identifier +export default async () => { + const my_secret = await secrets.get("cool"); // get secret from a source with the provided identifier console.log(my_secret); - secrets.get("else"); // get secret from a source with the provided identifier + await secrets.get("else"); // get secret from a source with the provided identifier console.log(my_secret); } diff --git a/internal/cmd/tests/cmd_run_test.go b/internal/cmd/tests/cmd_run_test.go index 7935bb2a0bc..abe511c574a 100644 --- a/internal/cmd/tests/cmd_run_test.go +++ b/internal/cmd/tests/cmd_run_test.go @@ -2409,10 +2409,10 @@ func TestBasicSecrets(t *testing.T) { mainScript := ` import secrets from "k6/secrets"; - export default () => { - const my_secret = secrets.get("cool"); // get secret from a source with the provided identifier + export default async () => { + const my_secret = await secrets.get("cool"); // get secret from a source with the provided identifier console.log(my_secret); - secrets.get("else"); // get secret from a source with the provided identifier + await secrets.get("else"); // get secret from a source with the provided identifier console.log(my_secret); } ` @@ -2437,17 +2437,17 @@ func TestMultipleSecretSources(t *testing.T) { mainScript := ` import secrets from "k6/secrets"; - export default () => { - const my_secret = secrets.source("first").get("cool"); + export default async () => { + const my_secret = await secrets.source("first").get("cool"); console.log(my_secret); - secrets.source("second").get("else"); + await secrets.source("second").get("else"); console.log(my_secret); try { - secrets.source("second").get("unkwown"); + await secrets.source("second").get("unkwown"); } catch { console.log("trigger exception on wrong key") } - secrets.get("else"); // testing default setting + await secrets.get("else"); // testing default setting } ` diff --git a/internal/js/modules/k6/secrets/secrets.go b/internal/js/modules/k6/secrets/secrets.go index 72df0796abd..901ab8d8d5d 100644 --- a/internal/js/modules/k6/secrets/secrets.go +++ b/internal/js/modules/k6/secrets/secrets.go @@ -6,6 +6,7 @@ import ( "go.k6.io/k6/js/common" "go.k6.io/k6/js/modules" + "go.k6.io/k6/js/promises" "go.k6.io/k6/secretsource" ) @@ -50,13 +51,13 @@ func (mi *Secrets) Exports() modules.Exports { } func (mi *Secrets) secrets() (*sobek.Object, error) { - obj, err := secretSourceObjectForSourceName(mi.vu.Runtime(), mi.secretsManager, secretsource.DefaultSourceName) + obj, err := secretSourceObjectForSourceName(mi.vu, mi.secretsManager, secretsource.DefaultSourceName) if err != nil { return nil, err } err = obj.Set("source", func(sourceName string) (*sobek.Object, error) { - return secretSourceObjectForSourceName(mi.vu.Runtime(), mi.secretsManager, sourceName) + return secretSourceObjectForSourceName(mi.vu, mi.secretsManager, sourceName) }) if err != nil { return nil, err @@ -66,11 +67,20 @@ func (mi *Secrets) secrets() (*sobek.Object, error) { } func secretSourceObjectForSourceName( - rt *sobek.Runtime, manager *secretsource.Manager, sourceName string, + vu modules.VU, manager *secretsource.Manager, sourceName string, ) (*sobek.Object, error) { - obj := rt.NewObject() - err := obj.Set("get", func(key string) (string, error) { - return manager.Get(sourceName, key) + obj := vu.Runtime().NewObject() + err := obj.Set("get", func(key string) *sobek.Promise { + p, resolve, reject := promises.New(vu) + go func() { + res, err := manager.Get(sourceName, key) + if err != nil { + reject(err) + return + } + resolve(res) + }() + return p }) if err != nil { return nil, err diff --git a/internal/js/modules/k6/secrets/secrets_test.go b/internal/js/modules/k6/secrets/secrets_test.go index 087c491fb59..5c856b98024 100644 --- a/internal/js/modules/k6/secrets/secrets_test.go +++ b/internal/js/modules/k6/secrets/secrets_test.go @@ -41,7 +41,7 @@ func TestSecrets(t *testing.T) { "secret": "value", }), }, - script: "secrets.get('secret')", + script: "await secrets.get('secret')", expectedValue: "value", }, "error": { @@ -50,7 +50,7 @@ func TestSecrets(t *testing.T) { "secret": "value", }), }, - script: "secrets.get('not_secret')", + script: "await secrets.get('not_secret')", expectedError: "no value", }, "multiple": { @@ -62,7 +62,7 @@ func TestSecrets(t *testing.T) { "secret2": "value2", }), }, - script: "secrets.get('secret')", + script: "await secrets.get('secret')", expectedValue: "value", }, "multiple get default": { @@ -74,7 +74,7 @@ func TestSecrets(t *testing.T) { "secret2": "value2", }), }, - script: "secrets.source('default').get('secret')", + script: "await secrets.source('default').get('secret')", expectedValue: "value", }, "multiple get not default": { @@ -86,12 +86,12 @@ func TestSecrets(t *testing.T) { "secret2": "value2", }), }, - script: "secrets.source('second').get('secret2')", + script: "await secrets.source('second').get('secret2')", expectedValue: "value2", }, "get secret without source": { secretsources: map[string]secretsource.Source{}, - script: "secrets.get('secret')", + script: "await secrets.get('secret')", expectedError: "no source with name default", }, "get none existing source": { @@ -100,7 +100,7 @@ func TestSecrets(t *testing.T) { "secret": "value", }), }, - script: "secrets.source('second') != undefined", + script: "(await secrets.source('second')) != undefined", expectedValue: true, }, } @@ -110,12 +110,13 @@ func TestSecrets(t *testing.T) { t.Parallel() testruntime := testRuntimeWithSecrets(t, testCase.secretsources) - v, err := testruntime.RunOnEventLoop(testCase.script) + _, err := testruntime.RunOnEventLoop("(async ()=>{globalThis.result = " + testCase.script + "})()") if testCase.expectedError != "" { require.ErrorContains(t, err, testCase.expectedError) return } require.NoError(t, err) + v := testruntime.VU.Runtime().GlobalObject().Get("result") assert.Equal(t, testCase.expectedValue, v.Export()) }) } From 38e7d0678696111a59681bfec51d0d09c9187433 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Wed, 5 Mar 2025 18:55:16 +0200 Subject: [PATCH 09/10] typo --- internal/js/modules/k6/secrets/secrets.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/js/modules/k6/secrets/secrets.go b/internal/js/modules/k6/secrets/secrets.go index 901ab8d8d5d..8bf60e0c2ba 100644 --- a/internal/js/modules/k6/secrets/secrets.go +++ b/internal/js/modules/k6/secrets/secrets.go @@ -46,7 +46,7 @@ func (mi *Secrets) Exports() modules.Exports { } return modules.Exports{ Default: s, - Named: make(map[string]any), // this is intentially not nil so it doesn't export anything as named expeorts + Named: make(map[string]any), // this is intentionally not nil so it doesn't export anything as named expeorts } } From fe26d76435e8494ee36af9cff99735cc201f38b1 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Thu, 6 Mar 2025 10:24:24 +0200 Subject: [PATCH 10/10] nicer errors --- internal/js/modules/k6/secrets/secrets_test.go | 14 +++++++++++++- secretsource/manager.go | 13 ++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/internal/js/modules/k6/secrets/secrets_test.go b/internal/js/modules/k6/secrets/secrets_test.go index 5c856b98024..87a4ee7294b 100644 --- a/internal/js/modules/k6/secrets/secrets_test.go +++ b/internal/js/modules/k6/secrets/secrets_test.go @@ -89,10 +89,22 @@ func TestSecrets(t *testing.T) { script: "await secrets.source('second').get('secret2')", expectedValue: "value2", }, + "multiple get wrong": { + secretsources: map[string]secretsource.Source{ + "default": mock.NewMockSecretSource("some", map[string]string{ + "secret": "value", + }), + "second": mock.NewMockSecretSource("some", map[string]string{ + "secret2": "value2", + }), + }, + script: "await secrets.source('third').get('secret2')", + expectedError: "no secret source with name \"third\" is configured", + }, "get secret without source": { secretsources: map[string]secretsource.Source{}, script: "await secrets.get('secret')", - expectedError: "no source with name default", + expectedError: "no secret sources are configured", }, "get none existing source": { secretsources: map[string]secretsource.Source{ diff --git a/secretsource/manager.go b/secretsource/manager.go index cf20683bb37..360b8f83450 100644 --- a/secretsource/manager.go +++ b/secretsource/manager.go @@ -1,6 +1,7 @@ package secretsource import ( + "errors" "fmt" "sync" @@ -53,9 +54,12 @@ func NewManager(sources map[string]Source) (*Manager, logrus.Hook, error) { // It can be used with the [DefaultSourceName]. // This automatically starts redacting the secret before returning it. func (sm *Manager) Get(sourceName, key string) (string, error) { + if len(sm.cache) == 0 { + return "", errors.New("no secret sources are configured") + } sourceCache, ok := sm.cache[sourceName] if !ok { - return "", fmt.Errorf("no source with name %s", sourceName) + return "", UnknownSourceError(sourceName) } v, ok := sourceCache.Load(key) if ok { @@ -70,3 +74,10 @@ func (sm *Manager) Get(sourceName, key string) (string, error) { sm.hook.add(value) return value, err } + +// UnknownSourceError is returned when a unknown source is requested +type UnknownSourceError string + +func (u UnknownSourceError) Error() string { + return fmt.Sprintf("no secret source with name %q is configured", (string)(u)) +}