diff --git a/cmd/describe_stacks.go b/cmd/describe_stacks.go index 3fd38ef4e..8b6d87fb1 100644 --- a/cmd/describe_stacks.go +++ b/cmd/describe_stacks.go @@ -45,5 +45,7 @@ func init() { describeStacksCmd.PersistentFlags().Bool("process-templates", true, "Enable/disable Go template processing in Atmos stack manifests when executing the command: atmos describe stacks --process-templates=false") + describeStacksCmd.PersistentFlags().Bool("include-empty-stacks", false, "Include stacks with no components in the output: atmos describe stacks --include-empty-stacks") + describeCmd.AddCommand(describeStacksCmd) } diff --git a/internal/exec/atmos.go b/internal/exec/atmos.go index fe3d29b22..446c42ca1 100644 --- a/internal/exec/atmos.go +++ b/internal/exec/atmos.go @@ -41,7 +41,7 @@ func ExecuteAtmosCmd() error { // Get a map of stacks and components in the stacks // Don't process `Go` templates in Atmos stack manifests since we just need to display the stack and component names in the TUI - stacksMap, err := ExecuteDescribeStacks(cliConfig, "", nil, nil, nil, false, false) + stacksMap, err := ExecuteDescribeStacks(cliConfig, "", nil, nil, nil, false, false, false) if err != nil { return err } diff --git a/internal/exec/describe_affected_utils.go b/internal/exec/describe_affected_utils.go index ec7ca3f14..1f8d30511 100644 --- a/internal/exec/describe_affected_utils.go +++ b/internal/exec/describe_affected_utils.go @@ -408,7 +408,7 @@ func executeDescribeAffected( u.LogTrace(cliConfig, fmt.Sprintf("Current HEAD: %s", localRepoHead)) u.LogTrace(cliConfig, fmt.Sprintf("BASE: %s", remoteRepoHead)) - currentStacks, err := ExecuteDescribeStacks(cliConfig, stack, nil, nil, nil, false, true) + currentStacks, err := ExecuteDescribeStacks(cliConfig, stack, nil, nil, nil, false, true, false) if err != nil { return nil, nil, nil, err } @@ -444,7 +444,7 @@ func executeDescribeAffected( return nil, nil, nil, err } - remoteStacks, err := ExecuteDescribeStacks(cliConfig, stack, nil, nil, nil, true, true) + remoteStacks, err := ExecuteDescribeStacks(cliConfig, stack, nil, nil, nil, true, true, false) if err != nil { return nil, nil, nil, err } diff --git a/internal/exec/describe_dependents.go b/internal/exec/describe_dependents.go index c3ba57083..647f11185 100644 --- a/internal/exec/describe_dependents.go +++ b/internal/exec/describe_dependents.go @@ -78,7 +78,7 @@ func ExecuteDescribeDependents( var ok bool // Get all stacks with all components - stacks, err := ExecuteDescribeStacks(cliConfig, "", nil, nil, nil, false, true) + stacks, err := ExecuteDescribeStacks(cliConfig, "", nil, nil, nil, false, true, false) if err != nil { return nil, err } diff --git a/internal/exec/describe_stacks.go b/internal/exec/describe_stacks.go index c45d18541..8da25fa9c 100644 --- a/internal/exec/describe_stacks.go +++ b/internal/exec/describe_stacks.go @@ -55,6 +55,11 @@ func ExecuteDescribeStacksCmd(cmd *cobra.Command, args []string) error { return err } + includeEmptyStacks, err := cmd.Flags().GetBool("include-empty-stacks") + if err != nil { + return err + } + componentsCsv, err := flags.GetString("components") if err != nil { return err @@ -97,6 +102,7 @@ func ExecuteDescribeStacksCmd(cmd *cobra.Command, args []string) error { sections, false, processTemplates, + includeEmptyStacks, ) if err != nil { return err @@ -119,6 +125,7 @@ func ExecuteDescribeStacks( sections []string, ignoreMissingFiles bool, processTemplates bool, + includeEmptyStacks bool, ) (map[string]any, error) { stacksMap, _, err := FindStacksMap(cliConfig, ignoreMissingFiles) @@ -127,6 +134,7 @@ func ExecuteDescribeStacks( } finalStacksMap := make(map[string]any) + processedStacks := make(map[string]bool) var varsSection map[string]any var metadataSection map[string]any var settingsSection map[string]any @@ -136,12 +144,48 @@ func ExecuteDescribeStacks( var backendSection map[string]any var backendTypeSection string var stackName string - context := schema.Context{} for stackFileName, stackSection := range stacksMap { + var context schema.Context + // Delete the stack-wide imports delete(stackSection.(map[string]any), "imports") + // Check if components section exists and has explicit components + hasExplicitComponents := false + if componentsSection, ok := stackSection.(map[string]any)["components"]; ok { + if componentsSection != nil { + if terraformSection, ok := componentsSection.(map[string]any)["terraform"].(map[string]any); ok { + hasExplicitComponents = len(terraformSection) > 0 + } + if helmfileSection, ok := componentsSection.(map[string]any)["helmfile"].(map[string]any); ok { + hasExplicitComponents = hasExplicitComponents || len(helmfileSection) > 0 + } + } + } + + // Also check for imports + hasImports := false + if importsSection, ok := stackSection.(map[string]any)["import"].([]any); ok { + hasImports = len(importsSection) > 0 + } + + // Skip stacks without components or imports when includeEmptyStacks is false + if !includeEmptyStacks && !hasExplicitComponents && !hasImports { + continue + } + + stackName = stackFileName + if processedStacks[stackName] { + continue + } + processedStacks[stackName] = true + + if !u.MapKeyExists(finalStacksMap, stackName) { + finalStacksMap[stackName] = make(map[string]any) + finalStacksMap[stackName].(map[string]any)["components"] = make(map[string]any) + } + if componentsSection, ok := stackSection.(map[string]any)["components"].(map[string]any); ok { if len(componentTypes) == 0 || u.SliceContainsString(componentTypes, "terraform") { @@ -244,11 +288,11 @@ func ExecuteDescribeStacks( stackName = stackFileName } + // Only create the stack entry if it doesn't exist if !u.MapKeyExists(finalStacksMap, stackName) { finalStacksMap[stackName] = make(map[string]any) } - configAndStacksInfo.Stack = stackName configAndStacksInfo.ComponentSection["atmos_component"] = componentName configAndStacksInfo.ComponentSection["atmos_stack"] = stackName configAndStacksInfo.ComponentSection["stack"] = stackName @@ -432,6 +476,7 @@ func ExecuteDescribeStacks( stackName = stackFileName } + // Only create the stack entry if it doesn't exist if !u.MapKeyExists(finalStacksMap, stackName) { finalStacksMap[stackName] = make(map[string]any) } @@ -511,13 +556,59 @@ func ExecuteDescribeStacks( } } } + } + + // Filter out empty stacks after processing all stack files + if !includeEmptyStacks { + for stackName := range finalStacksMap { + if stackName == "" { + delete(finalStacksMap, stackName) + continue + } + + stackEntry, ok := finalStacksMap[stackName].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid stack entry type for stack %s", stackName) + } + componentsSection, hasComponents := stackEntry["components"].(map[string]any) + + if !hasComponents { + delete(finalStacksMap, stackName) + continue + } - // Filter out empty stacks (stacks without any components) - if st, ok := finalStacksMap[stackName].(map[string]any); ok { - if len(st) == 0 { + // Check if any component type (terraform/helmfile) has components + hasNonEmptyComponents := false + for _, components := range componentsSection { + if compTypeMap, ok := components.(map[string]any); ok { + for _, comp := range compTypeMap { + if compContent, ok := comp.(map[string]any); ok { + // Check for any meaningful content + relevantSections := []string{"vars", "metadata", "settings", "env", "workspace"} + for _, section := range relevantSections { + if _, hasSection := compContent[section]; hasSection { + hasNonEmptyComponents = true + break + } + } + } + } + } + if hasNonEmptyComponents { + break + } + } + + if !hasNonEmptyComponents { delete(finalStacksMap, stackName) + continue } } + } else { + // Process stacks normally without special handling for any prefixes + for stackName, stackConfig := range finalStacksMap { + finalStacksMap[stackName] = stackConfig + } } return finalStacksMap, nil diff --git a/pkg/describe/describe_stacks.go b/pkg/describe/describe_stacks.go index e3aabc7b9..7cea75cee 100644 --- a/pkg/describe/describe_stacks.go +++ b/pkg/describe/describe_stacks.go @@ -13,7 +13,8 @@ func ExecuteDescribeStacks( componentTypes []string, sections []string, ignoreMissingFiles bool, + includeEmptyStacks bool, ) (map[string]any, error) { - return e.ExecuteDescribeStacks(cliConfig, filterByStack, components, componentTypes, sections, ignoreMissingFiles, true) + return e.ExecuteDescribeStacks(cliConfig, filterByStack, components, componentTypes, sections, ignoreMissingFiles, true, includeEmptyStacks) } diff --git a/pkg/describe/describe_stacks_test.go b/pkg/describe/describe_stacks_test.go index 977d8ab7d..eecf17fa9 100644 --- a/pkg/describe/describe_stacks_test.go +++ b/pkg/describe/describe_stacks_test.go @@ -16,7 +16,7 @@ func TestDescribeStacks(t *testing.T) { cliConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) assert.Nil(t, err) - stacks, err := ExecuteDescribeStacks(cliConfig, "", nil, nil, nil, false) + stacks, err := ExecuteDescribeStacks(cliConfig, "", nil, nil, nil, false, false) assert.Nil(t, err) dependentsYaml, err := u.ConvertToYAML(stacks) @@ -32,7 +32,7 @@ func TestDescribeStacksWithFilter1(t *testing.T) { stack := "tenant1-ue2-dev" - stacks, err := ExecuteDescribeStacks(cliConfig, stack, nil, nil, nil, false) + stacks, err := ExecuteDescribeStacks(cliConfig, stack, nil, nil, nil, false, false) assert.Nil(t, err) dependentsYaml, err := u.ConvertToYAML(stacks) @@ -49,7 +49,7 @@ func TestDescribeStacksWithFilter2(t *testing.T) { stack := "tenant1-ue2-dev" components := []string{"infra/vpc"} - stacks, err := ExecuteDescribeStacks(cliConfig, stack, components, nil, nil, false) + stacks, err := ExecuteDescribeStacks(cliConfig, stack, components, nil, nil, false, false) assert.Nil(t, err) dependentsYaml, err := u.ConvertToYAML(stacks) @@ -66,7 +66,7 @@ func TestDescribeStacksWithFilter3(t *testing.T) { stack := "tenant1-ue2-dev" sections := []string{"vars"} - stacks, err := ExecuteDescribeStacks(cliConfig, stack, nil, nil, sections, false) + stacks, err := ExecuteDescribeStacks(cliConfig, stack, nil, nil, sections, false, false) assert.Nil(t, err) dependentsYaml, err := u.ConvertToYAML(stacks) @@ -83,7 +83,7 @@ func TestDescribeStacksWithFilter4(t *testing.T) { componentTypes := []string{"terraform"} sections := []string{"none"} - stacks, err := ExecuteDescribeStacks(cliConfig, "", nil, componentTypes, sections, false) + stacks, err := ExecuteDescribeStacks(cliConfig, "", nil, componentTypes, sections, false, false) assert.Nil(t, err) dependentsYaml, err := u.ConvertToYAML(stacks) @@ -101,7 +101,7 @@ func TestDescribeStacksWithFilter5(t *testing.T) { components := []string{"top-level-component1"} sections := []string{"vars"} - stacks, err := ExecuteDescribeStacks(cliConfig, "", components, componentTypes, sections, false) + stacks, err := ExecuteDescribeStacks(cliConfig, "", components, componentTypes, sections, false, false) assert.Nil(t, err) assert.Equal(t, 8, len(stacks)) @@ -133,7 +133,7 @@ func TestDescribeStacksWithFilter6(t *testing.T) { components := []string{"top-level-component1"} sections := []string{"workspace"} - stacks, err := ExecuteDescribeStacks(cliConfig, "tenant1-ue2-dev", components, componentTypes, sections, false) + stacks, err := ExecuteDescribeStacks(cliConfig, "tenant1-ue2-dev", components, componentTypes, sections, false, false) assert.Nil(t, err) assert.Equal(t, 1, len(stacks)) @@ -160,7 +160,7 @@ func TestDescribeStacksWithFilter7(t *testing.T) { components := []string{"test/test-component-override-3"} sections := []string{"workspace"} - stacks, err := ExecuteDescribeStacks(cliConfig, stack, components, componentTypes, sections, false) + stacks, err := ExecuteDescribeStacks(cliConfig, stack, components, componentTypes, sections, false, false) assert.Nil(t, err) assert.Equal(t, 1, len(stacks)) @@ -175,3 +175,94 @@ func TestDescribeStacksWithFilter7(t *testing.T) { assert.Nil(t, err) t.Log(stacksYaml) } + +func TestDescribeStacksWithEmptyStacks(t *testing.T) { + configAndStacksInfo := schema.ConfigAndStacksInfo{} + + cliConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) + assert.Nil(t, err) + + stacks, err := ExecuteDescribeStacks(cliConfig, "", nil, nil, nil, false, false) + assert.Nil(t, err) + + initialStackCount := len(stacks) + + stacksWithEmpty, err := ExecuteDescribeStacks(cliConfig, "", nil, nil, nil, false, true) + assert.Nil(t, err) + + assert.Greater(t, len(stacksWithEmpty), initialStackCount, "Should include more stacks when empty stacks are included") + + foundEmptyStack := false + for _, stackContent := range stacksWithEmpty { + if components, ok := stackContent.(map[string]any)["components"].(map[string]any); ok { + if len(components) == 0 { + foundEmptyStack = true + break + } + if len(components) == 1 { + if terraformComps, hasTerraform := components["terraform"].(map[string]any); hasTerraform { + if len(terraformComps) == 0 { + foundEmptyStack = true + break + } + } + } + } + } + assert.True(t, foundEmptyStack, "Should find at least one empty stack") +} + +func TestDescribeStacksWithVariousEmptyStacks(t *testing.T) { + configAndStacksInfo := schema.ConfigAndStacksInfo{} + + cliConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) + assert.Nil(t, err) + + stacksWithoutEmpty, err := ExecuteDescribeStacks(cliConfig, "", nil, nil, nil, false, false) + assert.Nil(t, err) + initialCount := len(stacksWithoutEmpty) + + stacksWithEmpty, err := ExecuteDescribeStacks(cliConfig, "", nil, nil, nil, false, true) + assert.Nil(t, err) + + assert.Greater(t, len(stacksWithEmpty), initialCount, "Should have more stacks when including empty ones") + + var ( + emptyStacks []string + nonEmptyStacks []string + ) + + for stackName, stackContent := range stacksWithEmpty { + if stack, ok := stackContent.(map[string]any); ok { + if components, hasComponents := stack["components"].(map[string]any); hasComponents { + // Check for completely empty components + if len(components) == 0 { + emptyStacks = append(emptyStacks, stackName) + continue + } + + // Check if only terraform exists and is empty + if len(components) == 1 { + if terraformComps, hasTerraform := components["terraform"].(map[string]any); hasTerraform { + if len(terraformComps) == 0 { + emptyStacks = append(emptyStacks, stackName) + continue + } + } + } + + // If we have any components at all, consider it non-empty + for _, compType := range components { + if compMap, ok := compType.(map[string]any); ok && len(compMap) > 0 { + nonEmptyStacks = append(nonEmptyStacks, stackName) + break + } + } + } + } + } + + // Verify we found both types of stacks + assert.NotEmpty(t, emptyStacks, "Should find at least one empty stack") + assert.NotEmpty(t, nonEmptyStacks, "Should find at least one non-empty stack") +}