Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
76 changes: 61 additions & 15 deletions pkg/murmur/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
}()

Expand Down Expand Up @@ -106,19 +138,28 @@ 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
}
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
Expand All @@ -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
Expand Down
172 changes: 172 additions & 0 deletions pkg/murmur/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down