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
19 changes: 12 additions & 7 deletions cmd/secret/enumerate.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import (
// scopeEntry is a single (stack, component) instance that declares one or more secrets, paired
// with its resolved component section (declarations carry their derived scope after stack merge).
type scopeEntry struct {
Stack string
Component string
Section map[string]any
Stack string
Component string
ComponentType string
Section map[string]any
}

// enumerateScopesFn is a seam so tests can inject scope entries without real stack processing.
Expand Down Expand Up @@ -66,7 +67,8 @@ func enumerateSecretScopes(facet secretScope) ([]scopeEntry, *schema.AtmosConfig

// collectSecretScopeEntries traverses the describe-stacks map
// (stack -> components -> <type> -> component -> section) and keeps the instances that declare
// secrets, optionally narrowed to a single component. Entries are sorted by stack then component.
// secrets, optionally narrowed to a single component. Entries are sorted by stack, component,
// then component type so a name shared across component types has deterministic ordering.
func collectSecretScopeEntries(stacksMap map[string]any, componentFilter string) []scopeEntry {
var entries []scopeEntry
for stackName, raw := range stacksMap {
Expand All @@ -81,7 +83,10 @@ func collectSecretScopeEntries(stacksMap map[string]any, componentFilter string)
if entries[i].Stack != entries[j].Stack {
return entries[i].Stack < entries[j].Stack
}
return entries[i].Component < entries[j].Component
if entries[i].Component != entries[j].Component {
return entries[i].Component < entries[j].Component
}
return entries[i].ComponentType < entries[j].ComponentType
})
return entries
}
Expand All @@ -94,7 +99,7 @@ func secretEntriesInStack(stackName string, stackMap map[string]any, componentFi
return nil
}
var entries []scopeEntry
for _, typeRaw := range comps {
for componentType, typeRaw := range comps {
typeMap, ok := typeRaw.(map[string]any)
if !ok {
continue
Expand All @@ -110,7 +115,7 @@ func secretEntriesInStack(stackName string, stackMap map[string]any, componentFi
if len(secrets.ExtractDeclarations(section)) == 0 {
continue
}
entries = append(entries, scopeEntry{Stack: stackName, Component: compName, Section: section})
entries = append(entries, scopeEntry{Stack: stackName, Component: compName, ComponentType: componentType, Section: section})
}
}
return entries
Expand Down
15 changes: 15 additions & 0 deletions cmd/secret/enumerate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,21 @@ func TestCollectSecretScopeEntries_ComponentFilter(t *testing.T) {
assert.Equal(t, "api", entries[0].Component)
}

func TestCollectSecretScopeEntries_SortsSharedNameByComponentType(t *testing.T) {
stacksMap := map[string]any{
"dev": map[string]any{
cfg.ComponentsSectionName: map[string]any{
"terraform": map[string]any{"example-service": declaringSection("SHARED_TOKEN")},
"helm": map[string]any{"example-service": declaringSection("SHARED_TOKEN")},
},
},
}

entries := collectSecretScopeEntries(stacksMap, "")
require.Len(t, entries, 2)
assert.Equal(t, []string{"helm", "terraform"}, []string{entries[0].ComponentType, entries[1].ComponentType})
}

// TestSecretEntriesInStack covers the per-stack edge cases: a missing components section, a
// non-map component-type node, and a section that is not a map.
func TestSecretEntriesInStack(t *testing.T) {
Expand Down
100 changes: 92 additions & 8 deletions cmd/secret/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func init() {
func runSecretSet(cmd *cobra.Command, args []string) error {
defer perf.Track(nil, "secret.runSecretSet")()

scope, err := parseScope(cmd, args)
scope, err := parseSetScope(cmd, args)
if err != nil {
return err
}
Expand Down Expand Up @@ -70,6 +70,86 @@ func runSecretSet(cmd *cobra.Command, args []string) error {
return nil
}

// parseSetScope permits --component to be omitted only when a positional name resolves to one
// consistent global declaration in the selected stack. A component is still used internally to
// load the inherited declaration, but it cannot affect the resulting global backend coordinate.
func parseSetScope(cmd *cobra.Command, args []string) (secretScope, error) {
scope, err := parseScopeStack(cmd, args)
if err != nil {
return scope, err
}
if scope.Component != "" || len(args) == 0 {
return requireScopeComponent(scope, cmd, args)
}
target, err := setTargetFromArg(args[0])
if err != nil {
return scope, err
}
component, componentType, err := findGlobalSetContext(scope, target.name)
if err != nil {
return scope, err
}
scope.Component = component
if scope.ComponentType == "" {
scope.ComponentType = componentType
}
return scope, nil
}

func findGlobalSetContext(scope secretScope, name string) (string, string, error) {
entries, _, err := enumerateScopesFn(secretScope{Stack: scope.Stack, ComponentType: scope.ComponentType})
if err != nil {
return "", "", componentRequiredForSet(name, fmt.Sprintf("the global declaration could not be verified: %v", err))
}
var selected *secrets.Declaration
var component, componentType string
for _, entry := range entries {
if entry.Stack != "" && entry.Stack != scope.Stack {
continue
}
if scope.ComponentType != "" && entry.ComponentType != "" && entry.ComponentType != scope.ComponentType {
continue
}
decl, ok := secrets.ExtractDeclarations(entry.Section)[name]
if !ok {
continue
}
if decl.Scope != secrets.ScopeGlobal {
return "", "", componentRequiredForSet(name, "the declaration is not global")
}
// Component-less writes must never select one component's backend address arbitrarily.
// Enumeration normally renders templates per component, so different results are caught by
// the declaration equality check below. Reject an unresolved component template as well so
// an identical raw declaration cannot bypass that guarantee.
if componentDependentReference(decl.Reference) {
return "", "", componentRequiredForSet(name, "the global declaration reference depends on the component context")
}
if selected != nil && decl != *selected {
return "", "", componentRequiredForSet(name, "global declarations differ between components")
}
copy := decl
selected = &copy
if component == "" {
component, componentType = entry.Component, entry.ComponentType
}
}
if selected == nil {
return "", "", componentRequiredForSet(name, "no global declaration was found in the stack")
}
return component, componentType, nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func componentDependentReference(reference string) bool {
return strings.Contains(reference, "{{") && strings.Contains(reference, "atmos_component")
}

func componentRequiredForSet(name, reason string) error {
return errUtils.Build(errUtils.ErrRequiredFlagNotProvided).
WithExplanationf("--component is required to set secret %q: %s", name, reason).
WithHint("Omit --component only for a secret declared with `scope: global`; otherwise specify --component or -c").
Err()
}

// setSuccessMessage describes where the value was written: shared scopes (stack, global) name the
// shared location so the user knows every consumer sees the new value.
func setSuccessMessage(svc secretService, scope secretScope, name string) string {
Expand All @@ -96,13 +176,7 @@ type setTarget struct {
// TTY, and falls back to the standard "NAME required" error in non-interactive contexts.
func resolveSetName(svc secretService, args []string) (setTarget, error) {
if len(args) > 0 {
name, value, hasValue := strings.Cut(args[0], "=")
name = strings.TrimSpace(name)
if name == "" {
return setTarget{}, errUtils.Build(errUtils.ErrRequiredFlagNotProvided).
WithExplanation("secret NAME is required").Err()
}
return setTarget{name: name, value: value, hasValue: hasValue}, nil
return setTargetFromArg(args[0])
}

names := declaredNames(svc)
Expand All @@ -118,6 +192,16 @@ func resolveSetName(svc secretService, args []string) (setTarget, error) {
return setTarget{name: chosen}, nil
}

func setTargetFromArg(arg string) (setTarget, error) {
name, value, hasValue := strings.Cut(arg, "=")
name = strings.TrimSpace(name)
if name == "" {
return setTarget{}, errUtils.Build(errUtils.ErrRequiredFlagNotProvided).
WithExplanation("secret NAME is required").Err()
}
return setTarget{name: name, value: value, hasValue: hasValue}, nil
}

// declaredNames returns the sorted declared secret names for the service's scope.
func declaredNames(svc secretService) []string {
decls := svc.Declarations()
Expand Down
Loading