Skip to content

Commit e372a2c

Browse files
committed
feat: add explicit raw secret values
1 parent cce1d55 commit e372a2c

31 files changed

Lines changed: 841 additions & 116 deletions

cmd/secret/enumerate.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ import (
1818
// scopeEntry is a single (stack, component) instance that declares one or more secrets, paired
1919
// with its resolved component section (declarations carry their derived scope after stack merge).
2020
type scopeEntry struct {
21-
Stack string
22-
Component string
23-
Section map[string]any
21+
Stack string
22+
Component string
23+
ComponentType string
24+
Section map[string]any
2425
}
2526

2627
// enumerateScopesFn is a seam so tests can inject scope entries without real stack processing.
@@ -94,7 +95,7 @@ func secretEntriesInStack(stackName string, stackMap map[string]any, componentFi
9495
return nil
9596
}
9697
var entries []scopeEntry
97-
for _, typeRaw := range comps {
98+
for componentType, typeRaw := range comps {
9899
typeMap, ok := typeRaw.(map[string]any)
99100
if !ok {
100101
continue
@@ -110,7 +111,7 @@ func secretEntriesInStack(stackName string, stackMap map[string]any, componentFi
110111
if len(secrets.ExtractDeclarations(section)) == 0 {
111112
continue
112113
}
113-
entries = append(entries, scopeEntry{Stack: stackName, Component: compName, Section: section})
114+
entries = append(entries, scopeEntry{Stack: stackName, Component: compName, ComponentType: componentType, Section: section})
114115
}
115116
}
116117
return entries

cmd/secret/set.go

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func init() {
4141
func runSecretSet(cmd *cobra.Command, args []string) error {
4242
defer perf.Track(nil, "secret.runSecretSet")()
4343

44-
scope, err := parseScope(cmd, args)
44+
scope, err := parseSetScope(cmd, args)
4545
if err != nil {
4646
return err
4747
}
@@ -70,6 +70,75 @@ func runSecretSet(cmd *cobra.Command, args []string) error {
7070
return nil
7171
}
7272

73+
// parseSetScope permits --component to be omitted only when a positional name resolves to one
74+
// consistent global declaration in the selected stack. A component is still used internally to
75+
// load the inherited declaration, but it cannot affect the resulting global backend coordinate.
76+
func parseSetScope(cmd *cobra.Command, args []string) (secretScope, error) {
77+
scope, err := parseScopeStack(cmd, args)
78+
if err != nil {
79+
return scope, err
80+
}
81+
if scope.Component != "" || len(args) == 0 {
82+
return requireScopeComponent(scope, cmd, args)
83+
}
84+
target, err := setTargetFromArg(args[0])
85+
if err != nil {
86+
return scope, err
87+
}
88+
component, componentType, err := findGlobalSetContext(scope, target.name)
89+
if err != nil {
90+
return scope, err
91+
}
92+
scope.Component = component
93+
if scope.ComponentType == "" {
94+
scope.ComponentType = componentType
95+
}
96+
return scope, nil
97+
}
98+
99+
func findGlobalSetContext(scope secretScope, name string) (string, string, error) {
100+
entries, _, err := enumerateScopesFn(secretScope{Stack: scope.Stack, ComponentType: scope.ComponentType})
101+
if err != nil {
102+
return "", "", componentRequiredForSet(name, fmt.Sprintf("the global declaration could not be verified: %v", err))
103+
}
104+
var selected *secrets.Declaration
105+
var component, componentType string
106+
for _, entry := range entries {
107+
if entry.Stack != "" && entry.Stack != scope.Stack {
108+
continue
109+
}
110+
if scope.ComponentType != "" && entry.ComponentType != "" && entry.ComponentType != scope.ComponentType {
111+
continue
112+
}
113+
decl, ok := secrets.ExtractDeclarations(entry.Section)[name]
114+
if !ok {
115+
continue
116+
}
117+
if decl.Scope != secrets.ScopeGlobal {
118+
return "", "", componentRequiredForSet(name, "the declaration is not global")
119+
}
120+
if selected != nil && decl != *selected {
121+
return "", "", componentRequiredForSet(name, "global declarations differ between components")
122+
}
123+
copy := decl
124+
selected = &copy
125+
if component == "" {
126+
component, componentType = entry.Component, entry.ComponentType
127+
}
128+
}
129+
if selected == nil {
130+
return "", "", componentRequiredForSet(name, "no global declaration was found in the stack")
131+
}
132+
return component, componentType, nil
133+
}
134+
135+
func componentRequiredForSet(name, reason string) error {
136+
return errUtils.Build(errUtils.ErrRequiredFlagNotProvided).
137+
WithExplanationf("--component is required to set secret %q: %s", name, reason).
138+
WithHint("Omit --component only for a secret declared with `scope: global`; otherwise specify --component or -c").
139+
Err()
140+
}
141+
73142
// setSuccessMessage describes where the value was written: shared scopes (stack, global) name the
74143
// shared location so the user knows every consumer sees the new value.
75144
func setSuccessMessage(svc secretService, scope secretScope, name string) string {
@@ -96,13 +165,7 @@ type setTarget struct {
96165
// TTY, and falls back to the standard "NAME required" error in non-interactive contexts.
97166
func resolveSetName(svc secretService, args []string) (setTarget, error) {
98167
if len(args) > 0 {
99-
name, value, hasValue := strings.Cut(args[0], "=")
100-
name = strings.TrimSpace(name)
101-
if name == "" {
102-
return setTarget{}, errUtils.Build(errUtils.ErrRequiredFlagNotProvided).
103-
WithExplanation("secret NAME is required").Err()
104-
}
105-
return setTarget{name: name, value: value, hasValue: hasValue}, nil
168+
return setTargetFromArg(args[0])
106169
}
107170

108171
names := declaredNames(svc)
@@ -118,6 +181,16 @@ func resolveSetName(svc secretService, args []string) (setTarget, error) {
118181
return setTarget{name: chosen}, nil
119182
}
120183

184+
func setTargetFromArg(arg string) (setTarget, error) {
185+
name, value, hasValue := strings.Cut(arg, "=")
186+
name = strings.TrimSpace(name)
187+
if name == "" {
188+
return setTarget{}, errUtils.Build(errUtils.ErrRequiredFlagNotProvided).
189+
WithExplanation("secret NAME is required").Err()
190+
}
191+
return setTarget{name: name, value: value, hasValue: hasValue}, nil
192+
}
193+
121194
// declaredNames returns the sorted declared secret names for the service's scope.
122195
func declaredNames(svc secretService) []string {
123196
decls := svc.Declarations()

cmd/secret/set_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,57 @@ func TestRunSecretSet_SharedScopes(t *testing.T) {
8080
assert.Equal(t, "GLOBAL", svc2.setCalls[0].name)
8181
}
8282

83+
func TestRunSecretSet_GlobalScopeWithoutComponent(t *testing.T) {
84+
svc := newFakeSecretService()
85+
svc.scopes = map[string]secrets.Scope{"SHARED_TOKEN": secrets.ScopeGlobal}
86+
installService(t, svc, nil)
87+
overrideEnumerateScopes(t, []scopeEntry{
88+
{
89+
Stack: "dev",
90+
Component: "example-service",
91+
ComponentType: "helm",
92+
Section: secretDeclarationSection("SHARED_TOKEN", map[string]any{
93+
"store": "example-secrets",
94+
"scope": "global",
95+
}),
96+
},
97+
}, nil)
98+
99+
err := runSecretSubcommand(t, "set", "SHARED_TOKEN=v1", "--stack", "dev")
100+
require.NoError(t, err)
101+
require.Len(t, svc.setCalls, 1)
102+
assert.Equal(t, "SHARED_TOKEN", svc.setCalls[0].name)
103+
assert.Equal(t, "v1", svc.setCalls[0].value)
104+
}
105+
106+
func TestRunSecretSet_NonGlobalScopeStillRequiresComponent(t *testing.T) {
107+
svc := newFakeSecretService()
108+
installService(t, svc, nil)
109+
overrideEnumerateScopes(t, []scopeEntry{
110+
{
111+
Stack: "dev",
112+
Component: "example-service",
113+
ComponentType: "helm",
114+
Section: secretDeclarationSection("API_KEY", map[string]any{
115+
"store": "example-secrets",
116+
"scope": "instance",
117+
}),
118+
},
119+
}, nil)
120+
121+
err := runSecretSubcommand(t, "set", "API_KEY=v1", "--stack", "dev")
122+
require.ErrorIs(t, err, errUtils.ErrRequiredFlagNotProvided)
123+
assert.Empty(t, svc.setCalls)
124+
}
125+
126+
func secretDeclarationSection(name string, spec map[string]any) map[string]any {
127+
return map[string]any{
128+
"secrets": map[string]any{
129+
"vars": map[string]any{name: spec},
130+
},
131+
}
132+
}
133+
83134
func TestRunSecretSet_Prompt(t *testing.T) {
84135
svc := newFakeSecretService()
85136
installService(t, svc, nil)

cmd/secret/shared.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ func parseFacets(cmd *cobra.Command) (secretScope, error) {
7474
// non-interactive shells). In a non-interactive context a missing flag falls back to the standard
7575
// "required flag not provided" error, preserving today's pipeline behavior.
7676
func parseScope(cmd *cobra.Command, args []string) (secretScope, error) {
77+
scope, err := parseScopeStack(cmd, args)
78+
if err != nil {
79+
return scope, err
80+
}
81+
return requireScopeComponent(scope, cmd, args)
82+
}
83+
84+
// parseScopeStack resolves the common facets and requires only a stack. Commands that can prove
85+
// a component is irrelevant (for example, setting a uniquely global secret) use this narrower
86+
// helper and discover a declaration-bearing component context afterward.
87+
func parseScopeStack(cmd *cobra.Command, args []string) (secretScope, error) {
7788
v := viper.GetViper()
7889
if err := secretParser.BindFlagsToViper(cmd, v); err != nil {
7990
return secretScope{}, err
@@ -100,7 +111,10 @@ func parseScope(cmd *cobra.Command, args []string) (secretScope, error) {
100111
}
101112
// Make the chosen stack visible to the component completion (it filters by --stack).
102113
v.Set(cfg.StackStr, scope.Stack)
114+
return scope, nil
115+
}
103116

117+
func requireScopeComponent(scope secretScope, cmd *cobra.Command, args []string) (secretScope, error) {
104118
if scope.Component == "" {
105119
chosen, err := flags.PromptForMissingRequired("component", "Choose a component", componentCompletion, cmd, args)
106120
if err != nil {
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package helm
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
12+
13+
e "github.com/cloudposse/atmos/internal/exec"
14+
"github.com/cloudposse/atmos/pkg/auth"
15+
cfg "github.com/cloudposse/atmos/pkg/config"
16+
iolib "github.com/cloudposse/atmos/pkg/io"
17+
"github.com/cloudposse/atmos/pkg/keyring"
18+
"github.com/cloudposse/atmos/pkg/manifest"
19+
"github.com/cloudposse/atmos/pkg/schema"
20+
"github.com/cloudposse/atmos/pkg/store"
21+
storeproviders "github.com/cloudposse/atmos/pkg/store/providers"
22+
)
23+
24+
const (
25+
serviceAccountJSON = `{"type":"service_account","private_key":"-----BEGIN PRIVATE KEY-----\nAAAA\nBBBB\n-----END PRIVATE KEY-----\n","client_email":"service@example.iam.gserviceaccount.com"}`
26+
plainSecret = "single-line-example-token"
27+
privateKey = "-----BEGIN PRIVATE KEY-----\nAAAA\nBBBB\n-----END PRIVATE KEY-----\n"
28+
)
29+
30+
// jsonDecodingStore reproduces the structured Get behavior of cloud secret stores while using
31+
// the in-memory keychain backend. GetRaw preserves the opaque payload for !secret ... | raw.
32+
type jsonDecodingStore struct {
33+
store.Store
34+
}
35+
36+
func (s *jsonDecodingStore) Get(stack, component, key string) (any, error) {
37+
raw, err := s.GetRaw(stack, component, key)
38+
if err != nil {
39+
return nil, err
40+
}
41+
var decoded any
42+
if err := json.Unmarshal([]byte(raw), &decoded); err == nil {
43+
return decoded, nil
44+
}
45+
return raw, nil
46+
}
47+
48+
func (s *jsonDecodingStore) GetRaw(stack, component, key string) (string, error) {
49+
return s.Store.(store.RawStore).GetRaw(stack, component, key)
50+
}
51+
52+
func TestNativeHelmSecretRawAndStructuredValuesMaskIndentedMultilineValues(t *testing.T) {
53+
fixture, err := filepath.Abs(filepath.Join("..", "..", "..", "tests", "fixtures", "scenarios", "helm-secret-values"))
54+
require.NoError(t, err)
55+
t.Chdir(fixture)
56+
t.Setenv("ATMOS_CLI_CONFIG_PATH", ".")
57+
t.Setenv("ATMOS_BASE_PATH", ".")
58+
59+
info := schema.ConfigAndStacksInfo{
60+
ComponentFromArg: "secret-values",
61+
ComponentType: cfg.HelmComponentType,
62+
Stack: "dev",
63+
SubCommand: "template",
64+
SecretsMaskOnly: true,
65+
}
66+
atmosConfig, err := cfg.InitCliConfig(info, true)
67+
require.NoError(t, err)
68+
require.Equal(t, store.KindGCPSecret, atmosConfig.StoresConfig["gcp-secrets"].Kind)
69+
70+
memoryStore, err := storeproviders.NewKeychainStore(&storeproviders.KeychainStoreOptions{Backend: keyring.TypeMemory})
71+
require.NoError(t, err)
72+
offlineStore := &jsonDecodingStore{Store: memoryStore}
73+
atmosConfig.Stores["gcp-secrets"] = offlineStore
74+
75+
// Top-level declarations are stack scoped, so the component segment is intentionally empty.
76+
require.NoError(t, offlineStore.Set("dev", "", "service-account-json", serviceAccountJSON))
77+
require.NoError(t, offlineStore.Set("dev", "", "plain-token", plainSecret))
78+
require.NoError(t, offlineStore.Set("dev", "", "signing-key", privateKey))
79+
80+
info.SecretsMaskOnly = false
81+
info, err = e.ProcessStacks(&atmosConfig, info, true, true, true, nil, auth.AuthManager(nil))
82+
require.NoError(t, err)
83+
84+
values, ok := info.ComponentSection[cfg.ValuesSectionName].(map[string]any)
85+
require.True(t, ok)
86+
structured, ok := values["structured_service_account"].(map[string]any)
87+
require.True(t, ok, "bare !secret must preserve the existing structured store contract")
88+
assert.Equal(t, "service_account", structured["type"])
89+
env, ok := values["env"].([]any)
90+
require.True(t, ok)
91+
require.Len(t, env, 4)
92+
assert.Equal(t, serviceAccountJSON, envValue(t, env, "SERVICE_ACCOUNT_JSON"))
93+
assert.Equal(t, "service@example.iam.gserviceaccount.com", envValue(t, env, "CLIENT_EMAIL"))
94+
assert.Equal(t, plainSecret, envValue(t, env, "PLAIN_TOKEN"))
95+
assert.Equal(t, privateKey, envValue(t, env, "SIGNING_KEY"))
96+
97+
componentPath := filepath.Join(fixture, "components", "helm", "secret-values")
98+
spec, err := buildChartSpec(&atmosConfig, &info, componentPath)
99+
require.NoError(t, err)
100+
rendered, err := renderManifest(context.Background(), spec)
101+
require.NoError(t, err)
102+
103+
objects, err := manifest.DecodeObjects([]byte(rendered))
104+
require.NoError(t, err)
105+
require.Len(t, objects, 1)
106+
renderedEnv, found, err := unstructured.NestedSlice(objects[0].Object, "spec", "template", "spec", "containers", "0", "env")
107+
if err != nil || !found {
108+
// NestedSlice cannot index arrays by string; extract the container first.
109+
containers, containersFound, containersErr := unstructured.NestedSlice(objects[0].Object, "spec", "template", "spec", "containers")
110+
require.NoError(t, containersErr)
111+
require.True(t, containersFound)
112+
container := containers[0].(map[string]any)
113+
renderedEnv = container["env"].([]any)
114+
}
115+
assert.Equal(t, serviceAccountJSON, envValue(t, renderedEnv, "SERVICE_ACCOUNT_JSON"), "Helm env.value must remain a scalar string")
116+
117+
masker := iolib.GetContext().Masker()
118+
t.Cleanup(func() {
119+
masker.Clear()
120+
masker.SetEnabled(true)
121+
})
122+
masked := masker.Mask(rendered)
123+
assert.NotContains(t, masked, "BEGIN PRIVATE KEY")
124+
assert.NotContains(t, masked, "AAAA")
125+
assert.NotContains(t, masked, "BBBB")
126+
assert.NotContains(t, masked, plainSecret)
127+
assert.Contains(t, masked, masker.Replacement())
128+
129+
masker.SetEnabled(false)
130+
unmasked := masker.Mask(rendered)
131+
assert.Contains(t, unmasked, "BEGIN PRIVATE KEY")
132+
assert.Contains(t, unmasked, plainSecret)
133+
}
134+
135+
func envValue(t *testing.T, env []any, name string) any {
136+
t.Helper()
137+
for _, item := range env {
138+
entry, ok := item.(map[string]any)
139+
if ok && entry["name"] == name {
140+
return entry["value"]
141+
}
142+
}
143+
t.Fatalf("environment entry %q not found", name)
144+
return nil
145+
}

0 commit comments

Comments
 (0)