diff --git a/README.md b/README.md index 7e3b125a..453b85ef 100644 --- a/README.md +++ b/README.md @@ -187,8 +187,11 @@ func main() { "DEBUG_MODE": "passthrough:true", // Non-secret values pass through } - // Resolve all secrets + // Resolve all envvars with secrets resolved, err := murmur.ResolveAll(secrets) + // --OR-- + // Resolve only secrets envvars + resolved, err := murmur.ResolveSecrets(secrets) if err != nil { log.Fatal(err) } diff --git a/pkg/murmur/resolve.go b/pkg/murmur/resolve.go index 612fdbbb..d0f639c3 100644 --- a/pkg/murmur/resolve.go +++ b/pkg/murmur/resolve.go @@ -26,26 +26,58 @@ type variable struct { } // ResolveAll resolves all secret references in the input map and returns the resolved values. -// +// // Input map keys are preserved. Values that contain valid murmur queries (format: "provider:ref|filter:rule") // are resolved by fetching from the appropriate secret store. Values without valid queries pass through unchanged. // // The resolution process: -// 1. Parse each value to identify secret queries -// 2. Group queries by provider for concurrent resolution -// 3. Apply filters (like JSONPath) to transform resolved secrets -// 4. Return final environment-ready values +// 1. Parse each value to identify secret queries +// 2. Group queries by provider for concurrent resolution +// 3. Apply filters (like JSONPath) to transform resolved secrets +// 4. Return final environment-ready values // // Example: -// input := map[string]string{ -// "DB_PASSWORD": "awssm:prod/database#AWSCURRENT", -// "API_KEY": "gcpsm:my-project/api-key|jsonpath:{.token}", -// "DEBUG": "true", // passes through unchanged -// } -// resolved, err := ResolveAll(input) +// +// input := map[string]string{ +// "DB_PASSWORD": "awssm:prod/database#AWSCURRENT", +// "API_KEY": "gcpsm:my-project/api-key|jsonpath:{.token}", +// "DEBUG": "true", // passes through unchanged +// } +// resolved, err := ResolveAll(input) // // Returns an error if any secret resolution fails. Partial results are not returned on error. func ResolveAll(vars map[string]string) (map[string]string, error) { + return resolve(vars, false) +} + +// ResolveSecrets resolves only secret references in the input map and returns the resolved values. +// +// Unlike ResolveAll, this function filters out environment variables that don't contain valid +// murmur queries, returning only the resolved secret values. This is useful for export scenarios +// where only secrets should be written to output files. +// +// Input map keys are preserved for variables containing valid queries. Values that don't contain +// valid murmur queries are excluded from the result. +// +// Example: +// +// input := map[string]string{ +// "DB_PASSWORD": "awssm:prod/database#AWSCURRENT", +// "API_KEY": "gcpsm:my-project/api-key|jsonpath:{.token}", +// "DEBUG": "true", // excluded from result +// } +// resolved, err := ResolveSecrets(input) +// // resolved contains only DB_PASSWORD and API_KEY +// +// Returns an error if any secret resolution fails. Partial results are not returned on error. +func ResolveSecrets(vars map[string]string) (map[string]string, error) { + return resolve(vars, true) +} + +// resolve is the core resolution function that handles both all-variables and secrets-only modes. +// When onlySecrets is true, only variables with valid queries are processed and returned. +// When onlySecrets is false, all variables are processed (current ResolveAll behavior). +func resolve(vars map[string]string, onlySecrets bool) (map[string]string, error) { var ( rawVars = make(chan variable, len(vars)) parsed = make(chan variable, len(vars)) @@ -68,7 +100,7 @@ func ResolveAll(vars map[string]string) (map[string]string, error) { // Next, launch the first step of the pipeline: parsing. go func() { - parseVariables(rawVars, parsed, done) + parseVariables(rawVars, parsed, done, onlySecrets) close(parsed) }() @@ -106,12 +138,16 @@ func ResolveAll(vars map[string]string) (map[string]string, error) { return newVars, nil } -func parseVariables(rawVars <-chan variable, parsed, done chan<- variable) { +func parseVariables(rawVars <-chan variable, parsed, done chan<- variable, onlySecrets bool) { for v := range rawVars { q, err := parseQuery(v.rawValue) if err != nil { - // The variable's value is not a murmur query, so we should leave - // it as is. + // The variable's value is not a murmur query. + if onlySecrets { + // In secrets-only mode, skip non-secret variables + continue + } + // In all-variables mode, pass through unchanged v.finalValue = v.rawValue done <- v continue @@ -119,6 +155,11 @@ func parseVariables(rawVars <-chan variable, parsed, done chan<- variable) { if _, known := ProviderFactories[q.providerID]; !known { // The variable's value looks like a query but the provider is // unknown. It probably isn't a query. + if onlySecrets { + // In secrets-only mode, skip non-secret variables + continue + } + // In all-variables mode, pass through unchanged // ?(busser): should we log a message here? v.finalValue = v.rawValue done <- v @@ -127,6 +168,11 @@ func parseVariables(rawVars <-chan variable, parsed, done chan<- variable) { if _, known := Filters[q.filterID]; q.filterID != "" && !known { // The variable's value looks like a query but the filter is // unknown. It probably isn't a query. + if onlySecrets { + // In secrets-only mode, skip non-secret variables + continue + } + // In all-variables mode, pass through unchanged // ?(busser): should we log a message here? v.finalValue = v.rawValue done <- v diff --git a/pkg/murmur/resolve_test.go b/pkg/murmur/resolve_test.go index 27578f49..6e20f421 100644 --- a/pkg/murmur/resolve_test.go +++ b/pkg/murmur/resolve_test.go @@ -155,6 +155,178 @@ func TestResolveAll(t *testing.T) { } } +func TestResolveSecrets(t *testing.T) { + tt := []struct { + name string + providers map[string]MockProvider + variables map[string]string + want map[string]string + }{ + { + name: "no secrets", + variables: map[string]string{ + "A": "A", + "B": "B", + "C": "bar:C", // looks like query but provider unknown + }, + want: map[string]string{}, // empty result - no valid secrets + }, + { + name: "mixed secrets and non-secrets", + providers: map[string]MockProvider{ + "foo": mock.New(), + "bar": mock.New(), + "json": jsonmock.New(), + }, + variables: map[string]string{ + "NOT_A_SECRET": "My app listens on port 3000", + "ALSO_NOT": "The cloud is awesome", + "SECRET_ONE": "foo:database password", + "SECRET_TWO": "bar:api key", + "JSON_SECRET": "json:cloud credentials|jsonpath:{ ." + jsonmock.Key + " }", + "LOOKS_LIKE_ONE": "baz:but isn't a secret", // unknown provider + }, + want: map[string]string{ + "SECRET_ONE": mock.ValueFor("database password"), + "SECRET_TWO": mock.ValueFor("api key"), + "JSON_SECRET": "cloud credentials", + }, + }, + { + name: "only secrets", + providers: map[string]MockProvider{ + "foo": mock.New(), + "json": jsonmock.New(), + }, + variables: map[string]string{ + "A": "foo:A", + "B": "foo:B", + "C": "json:C|jsonpath:{ ." + jsonmock.Key + " }", + }, + want: map[string]string{ + "A": mock.ValueFor("A"), + "B": mock.ValueFor("B"), + "C": "C", + }, + }, + { + name: "caching with secrets only", + providers: map[string]MockProvider{ + "json": jsonmock.New(), + }, + variables: map[string]string{ + "NOT_SECRET": "plain value", + "A": "json:A|jsonpath:{ ." + jsonmock.Key + " }", + "B": "json:A|jsonpath:ref={ ." + jsonmock.Key + " }", + "C": "json:A|jsonpath:is my ref { ." + jsonmock.Key + " }?", + }, + want: map[string]string{ + "A": "A", + "B": "ref=A", + "C": "is my ref A?", + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + factories := make(map[string]ProviderFactory) + for prefix, provider := range tc.providers { + provider := provider + factories[prefix] = func() (Provider, error) { return provider, nil } + } + + // Replace murmur's clients with mocks for the duration of the test. + originalProviderFactories := ProviderFactories + defer func() { ProviderFactories = originalProviderFactories }() + ProviderFactories = factories + + actual, err := ResolveSecrets(tc.variables) + if err != nil { + t.Fatalf("ResolveSecrets() returned an error: %v", err) + } + + for prefix, provider := range tc.providers { + if !provider.Closed() { + t.Errorf("%q provider not closed", prefix) + } + if slices.Duplicates(provider.ResolvedRefs()) != 0 { + t.Errorf("%q provider resolved the same reference more than once, is caching broken?", prefix) + t.Logf("%q provider resolved: %q", prefix, provider.ResolvedRefs()) + } + } + + if diff := cmp.Diff(tc.want, actual); diff != "" { + t.Errorf("ResolveSecrets() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestResolveSecretsWithError(t *testing.T) { + tt := []struct { + name string + providers map[string]MockProvider + variables map[string]string + wantFailed []string + }{ + { + name: "mixed with errors", + providers: map[string]MockProvider{ + "foo": mock.New(), + "json": jsonmock.New(), + }, + variables: map[string]string{ + "NOT_A_SECRET": "My app listens on port 3000", + "OK_SECRET": "foo:database password", + "BROKEN_SECRET": "foo:FAIL", + "JSON_ERR": "json:cloud credentials|jsonpath:{ .missing }", + "NOT_JSON": "foo:api key|jsonpath:{ .foo }", + "LOOKS_LIKE_ONE": "baz:FAIL", // unknown provider, should be ignored + }, + wantFailed: []string{"BROKEN_SECRET", "JSON_ERR", "NOT_JSON"}, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + factories := make(map[string]ProviderFactory) + for prefix, provider := range tc.providers { + provider := provider + factories[prefix] = func() (Provider, error) { return provider, nil } + } + + // Replace murmur's clients with mocks for the duration of the test. + originalProviderFactories := ProviderFactories + defer func() { ProviderFactories = originalProviderFactories }() + ProviderFactories = factories + + _, err := ResolveSecrets(tc.variables) + if err == nil { + t.Fatal("ResolveSecrets() returned no error but it should have") + } + + for prefix, provider := range tc.providers { + if !provider.Closed() { + t.Errorf("%q provider not closed", prefix) + } + if slices.Duplicates(provider.ResolvedRefs()) != 0 { + t.Errorf("%q provider resolved the same reference more than once, is caching broken?", prefix) + t.Logf("%q provider resolved: %q", prefix, provider.ResolvedRefs()) + } + } + + errMsg := err.Error() + + for _, s := range tc.wantFailed { + if !strings.Contains(errMsg, s) { + t.Errorf("Error message %q should mention %q", errMsg, s) + } + } + }) + } +} + func TestResolveAllWithError(t *testing.T) { tt := []struct { name string