Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
eade781
Initial plan
Copilot Mar 15, 2026
5e9cc2d
feat(description): add first-class description field to components an…
Copilot Mar 15, 2026
12cff3a
feat(description): address code review - fix empty stacks filter and …
Copilot Mar 15, 2026
4161d9f
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 15, 2026
637606a
fix: correct broken link in blog post (core-concepts/stacks → /stacks)
Copilot Mar 15, 2026
f5bbcbd
test: regenerate golden snapshots to include description field in des…
Copilot Mar 15, 2026
d851cfd
Merge branch 'main' into copilot/add-description-field-to-components
nitrocode Mar 15, 2026
f8895d4
test: add unit tests for setStackDescription covering all branches to…
Copilot Mar 15, 2026
d1da7b9
feat(description): move description field to metadata.description
Copilot Mar 15, 2026
df8a763
Merge branch 'main' into copilot/add-description-field-to-components …
Copilot Mar 24, 2026
d2e241d
Merge branch 'main' into copilot/add-description-field-to-components
nitrocode Mar 24, 2026
8c91961
Apply suggestion from @nitrocode
nitrocode Mar 24, 2026
fcf3e3e
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 24, 2026
69ac656
fix: address 5 code quality issues in description feature
Copilot Mar 24, 2026
9d988f6
fix: description propagation and website build failure
Copilot Mar 26, 2026
aaeb2d2
Merge branch 'main' into copilot/add-description-field-to-components
nitrocode Mar 27, 2026
d88addf
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 27, 2026
3e6f7ae
fix: add Windows-specific retry/wait for terraform state file locks i…
Copilot Mar 27, 2026
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
1 change: 1 addition & 0 deletions internal/exec/describe_component.go
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ func FilterComputedFields(componentSection map[string]any) map[string]any {
"providers": true,
"imports": true,
"dependencies": true,
"description": true,
}

filtered := make(map[string]any)
Expand Down
2 changes: 1 addition & 1 deletion internal/exec/describe_component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ func TestDescribeComponentWithProvenance(t *testing.T) {
filtered := FilterComputedFields(result.ComponentSection)

// Verify filtered section only has stack-defined fields
allowedFields := []string{"vars", "settings", "env", "backend", "metadata", "overrides", "providers", "imports"}
allowedFields := []string{"vars", "settings", "env", "backend", "metadata", "overrides", "providers", "imports", "description"}
for k := range filtered {
assert.Contains(t, allowedFields, k, "Filtered component section should only contain stack-defined fields")
}
Expand Down
39 changes: 37 additions & 2 deletions internal/exec/describe_stacks_component_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ func (p *describeStacksProcessor) processStackFile(stackFileName string, stackMa
// not "imports", but keeping reads before mutations avoids implicit ordering assumptions.
stackManifestName := getStackManifestName(stackMap)

// Extract stack-level description from the top-level "description" key.
// ProcessStackConfig already promotes metadata.description to the top level of the result map.
stackDescription, _ := stackMap[cfg.DescriptionSectionName].(string)

// Delete the stack-wide imports section (not needed in output).
delete(stackMap, "imports")

Expand Down Expand Up @@ -119,6 +123,9 @@ func (p *describeStacksProcessor) processStackFile(stackFileName string, stackMa
entry[cfg.ComponentsSectionName] = make(map[string]any)
p.finalStacksMap[initialName] = entry
}
// Stamp the description onto the pre-created entry immediately. For import-only
// stacks (no components section) this is the only opportunity to attach it.
setStackDescription(p.finalStacksMap, initialName, stackDescription, p.sections)
}

componentsSection, ok := stackMap[cfg.ComponentsSectionName].(map[string]any)
Expand Down Expand Up @@ -149,7 +156,7 @@ func (p *describeStacksProcessor) processStackFile(stackFileName string, stackMa
if !ok {
continue
}
if err := p.processComponentTypeSection(stackFileName, stackManifestName, te.name, typeSection, te.opts); err != nil {
if err := p.processComponentTypeSection(stackFileName, stackManifestName, te.name, typeSection, te.opts, stackDescription); err != nil {
return err
}
}
Expand All @@ -163,6 +170,7 @@ func (p *describeStacksProcessor) processComponentTypeSection(
stackFileName, stackManifestName, typeName string,
typeSection map[string]any,
opts processComponentTypeOpts,
stackDescription string,
) error {
defer perf.Track(p.atmosConfig, "exec.describeStacksProcessor.processComponentTypeSection")()

Expand All @@ -187,7 +195,7 @@ func (p *describeStacksProcessor) processComponentTypeSection(

if err := p.processComponentEntry(
stackFileName, stackManifestName, typeName,
componentName, componentSection, typeSection, opts,
componentName, componentSection, typeSection, opts, stackDescription,
); err != nil {
return err
}
Expand All @@ -197,11 +205,13 @@ func (p *describeStacksProcessor) processComponentTypeSection(

// processComponentEntry processes a single component: resolves the stack name,
// filters, builds the ConfigAndStacksInfo, processes templates, and writes to the result map.
// stackDescription is the stack-manifest-level description (may be empty).
func (p *describeStacksProcessor) processComponentEntry( //nolint:gocognit,revive,cyclop,funlen // Orchestrator function with unavoidable branching.
stackFileName, stackManifestName, typeName,
componentName string,
componentSection, allTypeComponents map[string]any,
opts processComponentTypeOpts,
stackDescription string,
) error {
defer perf.Track(p.atmosConfig, "exec.describeStacksProcessor.processComponentEntry")()

Expand Down Expand Up @@ -284,6 +294,12 @@ func (p *describeStacksProcessor) processComponentEntry( //nolint:gocognit,reviv

ensureComponentEntryInMap(p.finalStacksMap, stackName, typeName, componentName)

// Propagate the stack-level description to the stack entry. Using setStackDescription
// here (per component, per file) rather than in a post-processing loop means the
// description is applied regardless of whether this stack name was already in
// finalStacksMap from a prior file's processing (idempotent: first non-empty wins).
setStackDescription(p.finalStacksMap, stackName, stackDescription, p.sections)

// Terraform-only: build and attach the Terraform workspace.
if opts.buildWorkspace {
workspace, wsErr := BuildTerraformWorkspace(p.atmosConfig, info)
Expand Down Expand Up @@ -800,3 +816,22 @@ func stackHasNonEmptyComponents(componentsSection map[string]any) bool {
}
return false
}

// setStackDescription sets the description field on the stack entry in finalStacksMap
// if description is non-empty and the sections filter allows it.
// The first non-empty description found wins (idempotent: existing values are not overwritten).
func setStackDescription(finalStacksMap map[string]any, stackName string, description string, sections []string) {
if len(sections) > 0 && !u.SliceContainsString(sections, cfg.DescriptionSectionName) {
return
}
if description == "" {
return
}
stackEntry, ok := finalStacksMap[stackName].(map[string]any)
if !ok {
return
}
if _, exists := stackEntry[cfg.DescriptionSectionName]; !exists {
stackEntry[cfg.DescriptionSectionName] = description
}
}
12 changes: 12 additions & 0 deletions internal/exec/describe_stacks_component_processor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,7 @@ func TestProcessComponentTypeSection_ComponentSectionNotMap(t *testing.T) {
err := p.processComponentTypeSection(
"test.yaml", "", cfg.TerraformSectionName, typeSection,
processComponentTypeOpts{},
"",
)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid")
Expand Down Expand Up @@ -884,6 +885,7 @@ func TestProcessComponentEntry_ComponentFilterExcluded(t *testing.T) {
"test.yaml", "", cfg.TerraformSectionName,
"vpc", componentSection, allTypeComponents,
processComponentTypeOpts{},
"",
)

require.NoError(t, err)
Expand Down Expand Up @@ -911,6 +913,7 @@ func TestProcessComponentEntry_EmptyStackName(t *testing.T) {
"", cfg.TerraformSectionName,
"vpc", componentSection, allTypeComponents,
processComponentTypeOpts{},
"",
)

require.NoError(t, err)
Expand Down Expand Up @@ -938,6 +941,7 @@ func TestProcessComponentEntry_ResolveStackNameError(t *testing.T) {
"test.yaml", "", cfg.TerraformSectionName,
"vpc", componentSection, allTypeComponents,
processComponentTypeOpts{},
"",
)

require.Error(t, err)
Expand All @@ -962,6 +966,7 @@ func TestProcessComponentTypeSection_DefaultsComponentKey(t *testing.T) {
err := p.processComponentTypeSection(
"test.yaml", "", cfg.TerraformSectionName, typeSection,
processComponentTypeOpts{},
"",
)

require.NoError(t, err)
Expand Down Expand Up @@ -992,6 +997,7 @@ func TestProcessComponentTypeSection_ProcessComponentEntryError(t *testing.T) {
err := p.processComponentTypeSection(
"test.yaml", "", cfg.TerraformSectionName, typeSection,
processComponentTypeOpts{},
"",
)

require.Error(t, err)
Expand Down Expand Up @@ -1023,6 +1029,7 @@ func TestProcessComponentEntry_FindComponentsDerivedError(t *testing.T) {
"test.yaml", "", cfg.TerraformSectionName,
"vpc", componentSection, allTypeComponents,
processComponentTypeOpts{},
"",
)

require.Error(t, err)
Expand Down Expand Up @@ -1055,6 +1062,7 @@ func TestProcessComponentEntry_ApplyMetadataInheritanceError(t *testing.T) {
"inherit-error-stack.yaml", "", cfg.TerraformSectionName,
"inherit-error-vpc", componentSection, allTypeComponents,
processComponentTypeOpts{applyMetadataInheritance: true},
"",
)

require.Error(t, err)
Expand All @@ -1079,6 +1087,7 @@ func TestProcessComponentEntry_BuildWorkspaceError(t *testing.T) {
"test.yaml", "", cfg.TerraformSectionName,
"vpc", componentSection, allTypeComponents,
processComponentTypeOpts{buildWorkspace: true},
"",
)

require.Error(t, err)
Expand Down Expand Up @@ -1242,6 +1251,7 @@ func TestProcessComponentEntry_ProcessTemplatesError(t *testing.T) {
"test.yaml", "", cfg.TerraformSectionName,
"vpc", componentSection, allTypeComponents,
processComponentTypeOpts{},
"",
)

require.Error(t, err)
Expand Down Expand Up @@ -1296,6 +1306,7 @@ func TestProcessComponentEntry_ProcessYAMLFunctionsError(t *testing.T) {
"yaml-func-err.yaml", "", cfg.TerraformSectionName,
"yaml-func-err", componentSection, allTypeComponents,
processComponentTypeOpts{},
"",
)

require.Error(t, err)
Expand Down Expand Up @@ -1471,6 +1482,7 @@ func TestProcessComponentEntry_NoGhostEntryWhenFiltered(t *testing.T) {
"stacks/prod.yaml", "prod", cfg.TerraformSectionName,
"vpc", componentSection, allTypeComponents,
processComponentTypeOpts{},
"",
)

require.NoError(t, err)
Expand Down
113 changes: 113 additions & 0 deletions internal/exec/describe_stacks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -743,3 +743,116 @@ func TestProcessStackFile_NoGhostEntry_FilterByStack(t *testing.T) {
_, exists := p.finalStacksMap["stacks/prod.yaml"]
assert.False(t, exists, "non-matching stack should not create an entry when filterByStack is active")
}

// ---------------------------------------------------------------------------
// setStackDescription
// ---------------------------------------------------------------------------

// TestSetStackDescription covers all branches of the setStackDescription helper.
func TestSetStackDescription(t *testing.T) {
t.Run("sections filter excludes description – no-op", func(t *testing.T) {
finalMap := map[string]any{
"my-stack": map[string]any{},
}
// sections is non-empty but does not include "description", so the early return should fire.
setStackDescription(finalMap, "my-stack", "some description", []string{"vars"})
stackEntry := finalMap["my-stack"].(map[string]any)
_, exists := stackEntry[cfg.DescriptionSectionName]
assert.False(t, exists, "description should not be set when it is absent from the sections filter")
})

t.Run("empty description – no-op", func(t *testing.T) {
finalMap := map[string]any{
"my-stack": map[string]any{},
}
setStackDescription(finalMap, "my-stack", "", nil)
stackEntry := finalMap["my-stack"].(map[string]any)
_, exists := stackEntry[cfg.DescriptionSectionName]
assert.False(t, exists, "description should not be set when value is empty string")
})

t.Run("finalStacksMap entry not a map – no-op", func(t *testing.T) {
finalMap := map[string]any{
"my-stack": "not-a-map",
}
// Should not panic; the non-map stack entry triggers the guard and returns.
setStackDescription(finalMap, "my-stack", "some description", nil)
// Entry remains unchanged.
assert.Equal(t, "not-a-map", finalMap["my-stack"])
})

t.Run("description set on first call", func(t *testing.T) {
finalMap := map[string]any{
"my-stack": map[string]any{},
}
setStackDescription(finalMap, "my-stack", "hello world", nil)
stackEntry := finalMap["my-stack"].(map[string]any)
assert.Equal(t, "hello world", stackEntry[cfg.DescriptionSectionName])
})

t.Run("idempotent – second call does not overwrite", func(t *testing.T) {
finalMap := map[string]any{
"my-stack": map[string]any{
cfg.DescriptionSectionName: "original",
},
}
setStackDescription(finalMap, "my-stack", "overwrite-attempt", nil)
stackEntry := finalMap["my-stack"].(map[string]any)
assert.Equal(t, "original", stackEntry[cfg.DescriptionSectionName], "existing description should not be overwritten")
})

t.Run("sections filter includes description – description is set", func(t *testing.T) {
finalMap := map[string]any{
"my-stack": map[string]any{},
}
setStackDescription(finalMap, "my-stack", "filtered in", []string{cfg.DescriptionSectionName})
stackEntry := finalMap["my-stack"].(map[string]any)
assert.Equal(t, "filtered in", stackEntry[cfg.DescriptionSectionName])
})
}

// TestProcessStackFile_DescriptionOnPreCreatedEntry is a regression test for the
// includeEmptyStacks description-drop bug: the existingStacks snapshot must be taken
// BEFORE the pre-creation block so that pre-created entries are recognised as "new"
// and receive the stack-level description.
func TestProcessStackFile_DescriptionOnPreCreatedEntry(t *testing.T) {
// Use a config with no NameTemplate and no NamePattern so the stack name is
// resolved from the raw file name — canResolveNameEarly is true.
atmosConfig := &schema.AtmosConfiguration{}

p := newDescribeStacksProcessor(
atmosConfig,
"", nil, nil, nil,
false, false,
true, // includeEmptyStacks — triggers the pre-creation path.
nil, nil,
)

// A stack map that has a description AND a components section.
// When includeEmptyStacks=true, processStackFile should:
// 1. Snapshot existingStacks (empty at this point).
// 2. Pre-create p.finalStacksMap["stacks/dev.yaml"] = { components: {} }.
// 3. Process the terraform component (reuses the pre-created entry).
// 4. Stamp the description onto every entry not in the snapshot.
stackMap := map[string]any{
cfg.DescriptionSectionName: "Dev stack with description.",
cfg.ComponentsSectionName: map[string]any{
cfg.TerraformSectionName: map[string]any{
"vpc": map[string]any{},
},
},
}

err := p.processStackFile("stacks/dev.yaml", stackMap)
require.NoError(t, err)

entry, exists := p.finalStacksMap["stacks/dev.yaml"]
require.True(t, exists, "stack entry must exist after processing")

stackEntry, ok := entry.(map[string]any)
require.True(t, ok)

desc, hasDesc := stackEntry[cfg.DescriptionSectionName]
assert.True(t, hasDesc, "stack entry must carry the stack-level description")
assert.Equal(t, "Dev stack with description.", desc)
}
5 changes: 5 additions & 0 deletions internal/exec/stack_processor_merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,11 @@ func mergeComponentConfigurations(atmosConfig *schema.AtmosConfiguration, opts *
cfg.OverridesSectionName: result.ComponentOverrides,
}

// Add description if present in metadata.description.
if desc, ok := finalComponentMetadata[cfg.DescriptionSectionName].(string); ok && desc != "" {
comp[cfg.DescriptionSectionName] = desc
}

// Add dependencies if present.
if len(finalComponentDependencies) > 0 {
comp[cfg.DependenciesSectionName] = finalComponentDependencies
Expand Down
17 changes: 17 additions & 0 deletions internal/exec/stack_processor_process_stacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ func ProcessStackConfig(
}
}

// Extract the stack-level 'description' field from metadata.description if present.
var stackManifestDescription string
if metaI, ok := config[cfg.MetadataSectionName]; ok {
if meta, ok := metaI.(map[string]any); ok {
if i, ok := meta[cfg.DescriptionSectionName]; ok {
if desc, ok := i.(string); ok {
stackManifestDescription = desc
}
}
}
}

globalVarsSection := map[string]any{}
globalHooksSection := map[string]any{}
globalSettingsSection := map[string]any{}
Expand Down Expand Up @@ -754,6 +766,11 @@ func ProcessStackConfig(
result[cfg.NameSectionName] = stackManifestName
}

// Include the stack-level 'description' field if it was set.
if stackManifestDescription != "" {
result[cfg.DescriptionSectionName] = stackManifestDescription
}

return result, nil
}

Expand Down
Loading