From 8311350e3cc0f130d91474a914916d7b4cd5774c Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Tue, 28 Jan 2025 20:27:52 +0000 Subject: [PATCH 01/22] feat: add output format options for `atmos list stacks` command --- cmd/list_stacks.go | 13 ++++++++++--- pkg/list/list_stacks_test.go | 22 ++++++++++++++++++++-- pkg/schema/schema.go | 11 ++++++----- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/cmd/list_stacks.go b/cmd/list_stacks.go index 9dcd8ac91..3b1b90f0b 100644 --- a/cmd/list_stacks.go +++ b/cmd/list_stacks.go @@ -19,7 +19,10 @@ var listStacksCmd = &cobra.Command{ Short: "List all Atmos stacks or stacks for a specific component", Long: "This command lists all Atmos stacks, or filters the list to show only the stacks associated with a specified component.", Example: "atmos list stacks\n" + - "atmos list stacks -c ", + "atmos list stacks -c \n" + + "atmos list stacks --format json\n" + + "atmos list stacks --format csv --delimiter ','\n" + + "atmos list stacks --format table", FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false}, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { @@ -27,6 +30,8 @@ var listStacksCmd = &cobra.Command{ checkAtmosConfig() componentFlag, _ := cmd.Flags().GetString("component") + formatFlag, _ := cmd.Flags().GetString("format") + delimiterFlag, _ := cmd.Flags().GetString("delimiter") configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := config.InitCliConfig(configAndStacksInfo, true) @@ -41,7 +46,7 @@ var listStacksCmd = &cobra.Command{ return } - output, err := l.FilterAndListStacks(stacksMap, componentFlag) + output, err := l.FilterAndListStacks(stacksMap, componentFlag, atmosConfig.Stacks.List, formatFlag, delimiterFlag) if err != nil { u.PrintMessageInColor(fmt.Sprintf("Error filtering stacks: %v", err), theme.Colors.Error) return @@ -52,6 +57,8 @@ var listStacksCmd = &cobra.Command{ func init() { listStacksCmd.DisableFlagParsing = false - listStacksCmd.PersistentFlags().StringP("component", "c", "", "atmos list stacks -c ") + listStacksCmd.PersistentFlags().StringP("component", "c", "", "Filter stacks by component") + listStacksCmd.PersistentFlags().StringP("format", "f", "", "Output format (table, json, csv)") + listStacksCmd.PersistentFlags().StringP("delimiter", "d", "\t", "Delimiter for table and csv formats") listCmd.AddCommand(listStacksCmd) } diff --git a/pkg/list/list_stacks_test.go b/pkg/list/list_stacks_test.go index 87b17d6a7..4972190ee 100644 --- a/pkg/list/list_stacks_test.go +++ b/pkg/list/list_stacks_test.go @@ -25,7 +25,16 @@ func TestListStacks(t *testing.T) { nil, false, false, false) assert.Nil(t, err) - output, err := FilterAndListStacks(stacksMap, "") + // Create test list config + listConfig := schema.ListConfig{ + Format: "", + Columns: []schema.ListColumnConfig{ + {Name: "Stack", Value: "{{ .atmos_stack }}"}, + {Name: "File", Value: "{{ .atmos_stack_file }}"}, + }, + } + + output, err := FilterAndListStacks(stacksMap, "", listConfig, "", "\t") assert.Nil(t, err) dependentsYaml, err := u.ConvertToYAML(output) assert.NotEmpty(t, dependentsYaml) @@ -42,7 +51,16 @@ func TestListStacksWithComponent(t *testing.T) { nil, false, false, false) assert.Nil(t, err) - output, err := FilterAndListStacks(stacksMap, component) + // Create test list config + listConfig := schema.ListConfig{ + Format: "", + Columns: []schema.ListColumnConfig{ + {Name: "Stack", Value: "{{ .atmos_stack }}"}, + {Name: "File", Value: "{{ .atmos_stack_file }}"}, + }, + } + + output, err := FilterAndListStacks(stacksMap, component, listConfig, "", "\t") assert.Nil(t, err) dependentsYaml, err := u.ConvertToYAML(output) assert.Nil(t, err) diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index d864b8050..bcb9efa4c 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -149,11 +149,12 @@ type Components struct { } type Stacks struct { - BasePath string `yaml:"base_path" json:"base_path" mapstructure:"base_path"` - IncludedPaths []string `yaml:"included_paths" json:"included_paths" mapstructure:"included_paths"` - ExcludedPaths []string `yaml:"excluded_paths" json:"excluded_paths" mapstructure:"excluded_paths"` - NamePattern string `yaml:"name_pattern" json:"name_pattern" mapstructure:"name_pattern"` - NameTemplate string `yaml:"name_template" json:"name_template" mapstructure:"name_template"` + BasePath string `yaml:"base_path" json:"base_path" mapstructure:"base_path"` + IncludedPaths []string `yaml:"included_paths" json:"included_paths" mapstructure:"included_paths"` + ExcludedPaths []string `yaml:"excluded_paths" json:"excluded_paths" mapstructure:"excluded_paths"` + NamePattern string `yaml:"name_pattern" json:"name_pattern" mapstructure:"name_pattern"` + NameTemplate string `yaml:"name_template" json:"name_template" mapstructure:"name_template"` + List ListConfig `yaml:"list" json:"list" mapstructure:"list"` } type Workflows struct { From faeec5106e9790d8080f97b01d603101c15c048f Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Tue, 28 Jan 2025 20:29:43 +0000 Subject: [PATCH 02/22] feat(list): enhance stack listing with configurable formats and columns --- pkg/list/list_components_test.go | 11 +- pkg/list/list_stacks.go | 210 +++++++++++++++++++++++++++++-- 2 files changed, 208 insertions(+), 13 deletions(-) diff --git a/pkg/list/list_components_test.go b/pkg/list/list_components_test.go index 8cb86ade8..981fb4c14 100644 --- a/pkg/list/list_components_test.go +++ b/pkg/list/list_components_test.go @@ -45,7 +45,16 @@ func TestListComponentsWithStack(t *testing.T) { nil, false, false, false) assert.Nil(t, err) - output, err := FilterAndListStacks(stacksMap, testStack) + // Create test list config + listConfig := schema.ListConfig{ + Format: "", + Columns: []schema.ListColumnConfig{ + {Name: "Stack", Value: "{{ .atmos_stack }}"}, + {Name: "File", Value: "{{ .atmos_stack_file }}"}, + }, + } + + output, err := FilterAndListStacks(stacksMap, testStack, listConfig, "", "\t") assert.Nil(t, err) dependentsYaml, err := u.ConvertToYAML(output) assert.Nil(t, err) diff --git a/pkg/list/list_stacks.go b/pkg/list/list_stacks.go index 6370512b6..011c52340 100644 --- a/pkg/list/list_stacks.go +++ b/pkg/list/list_stacks.go @@ -1,18 +1,37 @@ package list import ( + "encoding/json" "fmt" "sort" "strings" + "text/template" - "github.com/samber/lo" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/cloudposse/atmos/internal/exec" + "github.com/cloudposse/atmos/pkg/schema" + "github.com/cloudposse/atmos/pkg/ui/theme" + "github.com/cloudposse/atmos/pkg/utils" ) -// FilterAndListStacks filters stacks by the given component -func FilterAndListStacks(stacksMap map[string]any, component string) (string, error) { +// FilterAndListStacks filters and lists stacks based on the given configuration +func FilterAndListStacks(stacksMap map[string]any, component string, listConfig schema.ListConfig, format string, delimiter string) (string, error) { + if err := ValidateFormat(format); err != nil { + return "", err + } + + if format == "" && listConfig.Format != "" { + if err := ValidateFormat(listConfig.Format); err != nil { + return "", err + } + format = listConfig.Format + } + + var filteredStacks []map[string]any + if component != "" { // Filter stacks by component - filteredStacks := []string{} for stackName, stackData := range stacksMap { v2, ok := stackData.(map[string]any) if !ok { @@ -27,19 +46,186 @@ func FilterAndListStacks(stacksMap map[string]any, component string) (string, er continue } if _, exists := terraform[component]; exists { - filteredStacks = append(filteredStacks, stackName) + stackInfo := map[string]any{ + "atmos_stack": stackName, + "vars": v2["vars"], + } + + // Safely get tenant and environment from vars + if vars, ok := v2["vars"].(map[string]any); ok { + tenant, _ := vars["tenant"].(string) + environment, _ := vars["environment"].(string) + stage, _ := vars["stage"].(string) + stackInfo["atmos_stack_file"] = fmt.Sprintf("orgs/acme/%s/%s/%s", tenant, environment, stage) + } else { + stackInfo["atmos_stack_file"] = fmt.Sprintf("stacks/deploy/%s.yaml", stackName) + } + + // Add component vars if they exist + if components, ok := v2["components"].(map[string]any); ok { + if terraform, ok := components["terraform"].(map[string]any); ok { + for _, comp := range terraform { + if compSection, ok := comp.(map[string]any); ok { + if compVars, ok := compSection["vars"].(map[string]any); ok { + // Merge component vars with stack vars + if stackInfo["vars"] == nil { + stackInfo["vars"] = make(map[string]any) + } + for k, v := range compVars { + stackInfo["vars"].(map[string]any)[k] = v + } + } + } + } + } + } + filteredStacks = append(filteredStacks, stackInfo) + } + } + } else { + // List all stacks + for stackName, stackData := range stacksMap { + v2, ok := stackData.(map[string]any) + if !ok { + continue + } + stackInfo := map[string]any{ + "atmos_stack": stackName, + "vars": v2["vars"], + } + + // Safely get tenant and environment from vars + if vars, ok := v2["vars"].(map[string]any); ok { + tenant, _ := vars["tenant"].(string) + environment, _ := vars["environment"].(string) + stage, _ := vars["stage"].(string) + stackInfo["atmos_stack_file"] = fmt.Sprintf("orgs/acme/%s/%s/%s", tenant, environment, stage) + } else { + stackInfo["atmos_stack_file"] = fmt.Sprintf("stacks/deploy/%s.yaml", stackName) } + + // Add component vars if they exist + if components, ok := v2["components"].(map[string]any); ok { + if terraform, ok := components["terraform"].(map[string]any); ok { + for _, comp := range terraform { + if compSection, ok := comp.(map[string]any); ok { + if compVars, ok := compSection["vars"].(map[string]any); ok { + // Merge component vars with stack vars + if stackInfo["vars"] == nil { + stackInfo["vars"] = make(map[string]any) + } + for k, v := range compVars { + stackInfo["vars"].(map[string]any)[k] = v + } + } + } + } + } + } + filteredStacks = append(filteredStacks, stackInfo) } + } - if len(filteredStacks) == 0 { + if len(filteredStacks) == 0 { + if component != "" { return fmt.Sprintf("No stacks found for component '%s'"+"\n", component), nil } - sort.Strings(filteredStacks) - return strings.Join(filteredStacks, "\n") + "\n", nil + return "No stacks found\n", nil + } + + // Sort stacks by name + sort.Slice(filteredStacks, func(i, j int) bool { + return filteredStacks[i]["atmos_stack"].(string) < filteredStacks[j]["atmos_stack"].(string) + }) + + // If no columns are configured, use default columns + if len(listConfig.Columns) == 0 { + listConfig.Columns = []schema.ListColumnConfig{ + {Name: "Stack", Value: "{{ .atmos_stack }}"}, + {Name: "Tenant", Value: "{{ index .vars \"tenant\" }}"}, + {Name: "Environment", Value: "{{ index .vars \"environment\" }}"}, + {Name: "File", Value: "{{ .atmos_stack_file }}"}, + } + } + + // Prepare headers and rows + headers := make([]string, len(listConfig.Columns)) + rows := make([][]string, len(filteredStacks)) + + for i, col := range listConfig.Columns { + headers[i] = col.Name + } + + // Process each stack and populate rows + for i, stack := range filteredStacks { + row := make([]string, len(listConfig.Columns)) + for j, col := range listConfig.Columns { + tmpl, err := template.New("column").Parse(col.Value) + if err != nil { + return "", fmt.Errorf("error parsing template for column %s: %w", col.Name, err) + } + + var buf strings.Builder + if err := tmpl.Execute(&buf, stack); err != nil { + return "", fmt.Errorf("error executing template for column %s: %w", col.Name, err) + } + row[j] = buf.String() + } + rows[i] = row } - // List all stacks - stacks := lo.Keys(stacksMap) - sort.Strings(stacks) - return strings.Join(stacks, "\n") + "\n", nil + // Handle different output formats + switch format { + case FormatJSON: + var result []map[string]string + for _, row := range rows { + item := make(map[string]string) + for i, header := range headers { + item[header] = row[i] + } + result = append(result, item) + } + jsonBytes, err := json.MarshalIndent(result, "", " ") + if err != nil { + return "", fmt.Errorf("error formatting JSON output: %w", err) + } + return string(jsonBytes), nil + + case FormatCSV: + var output strings.Builder + output.WriteString(strings.Join(headers, delimiter) + utils.GetLineEnding()) + for _, row := range rows { + output.WriteString(strings.Join(row, delimiter) + utils.GetLineEnding()) + } + return output.String(), nil + + default: + // If format is empty or "table", use table format + if format == "" && exec.CheckTTYSupport() { + // Create a styled table for TTY + t := table.New(). + Border(lipgloss.ThickBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorBorder))). + StyleFunc(func(row, col int) lipgloss.Style { + style := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1) + if row == 0 { + // Apply CommandName style to all header cells + return style.Inherit(theme.Styles.CommandName) + } + return style.Inherit(theme.Styles.Description) + }). + Headers(headers...). + Rows(rows...) + + return t.String() + utils.GetLineEnding(), nil + } + + // Default to simple tabular format for non-TTY or when format is explicitly "table" + var output strings.Builder + output.WriteString(strings.Join(headers, delimiter) + utils.GetLineEnding()) + for _, row := range rows { + output.WriteString(strings.Join(row, delimiter) + utils.GetLineEnding()) + } + return output.String(), nil + } } From 80d540cbf97f112cf86252ed63325fcb5694343f Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Tue, 28 Jan 2025 21:01:46 +0000 Subject: [PATCH 03/22] Add stack list columns configuration to atmos.yaml --- atmos.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/atmos.yaml b/atmos.yaml index 5b115043b..1dc9960fc 100644 --- a/atmos.yaml +++ b/atmos.yaml @@ -76,6 +76,16 @@ stacks: - "**/_defaults.yaml" # Can also be set using 'ATMOS_STACKS_NAME_PATTERN' ENV var name_pattern: "{tenant}-{environment}-{stage}" + list: + columns: + - name: Stack + value: '{{ .atmos_stack }}' + - name: Tenant + value: '{{ index .vars "tenant" }}' + - name: Environment + value: '{{ index .vars "environment" }}' + - name: File + value: '{{ .atmos_stack_file }}' workflows: # Can also be set using 'ATMOS_WORKFLOWS_BASE_PATH' ENV var, or '--workflows-dir' command-line argument From baa42e587ab70406f101d7f2825a494da23dc742 Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Tue, 28 Jan 2025 21:39:03 +0000 Subject: [PATCH 04/22] refactor: improve table formatting for non-TTY output --- pkg/list/list_stacks.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/list/list_stacks.go b/pkg/list/list_stacks.go index 011c52340..95b4fe5fb 100644 --- a/pkg/list/list_stacks.go +++ b/pkg/list/list_stacks.go @@ -200,7 +200,7 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig return output.String(), nil default: - // If format is empty or "table", use table format + // Check for TTY support if format == "" && exec.CheckTTYSupport() { // Create a styled table for TTY t := table.New(). @@ -220,11 +220,17 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig return t.String() + utils.GetLineEnding(), nil } - // Default to simple tabular format for non-TTY or when format is explicitly "table" var output strings.Builder - output.WriteString(strings.Join(headers, delimiter) + utils.GetLineEnding()) + // Write headers + headerRow := make([]string, len(headers)) + for i, h := range headers { + headerRow[i] = h + } + output.WriteString(strings.Join(headerRow, "\t") + utils.GetLineEnding()) + + // Write rows for _, row := range rows { - output.WriteString(strings.Join(row, delimiter) + utils.GetLineEnding()) + output.WriteString(strings.Join(row, "\t") + utils.GetLineEnding()) } return output.String(), nil } From 060d49b508f492e618b5e0c3d73a6c61d52f85cf Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Tue, 28 Jan 2025 21:51:05 +0000 Subject: [PATCH 05/22] Add list format and columns configuration to stacks config --- .../TestCLICommands_atmos_describe_config.stdout.golden | 6 +++++- ...tCLICommands_atmos_describe_config_-f_yaml.stdout.golden | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden index 3c9b7660e..4aed903d5 100644 --- a/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden @@ -31,7 +31,11 @@ "**/_defaults.yaml" ], "name_pattern": "{stage}", - "name_template": "" + "name_template": "", + "list": { + "format": "", + "columns": null + } }, "workflows": { "base_path": "", diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden b/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden index de0d09a67..f9fdafa44 100644 --- a/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden @@ -25,6 +25,9 @@ stacks: - '**/_defaults.yaml' name_pattern: '{stage}' name_template: "" + list: + format: "" + columns: [] logs: file: /dev/stderr level: Info From c29f1e8d48051a81066e62919467b8344764cb10 Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Thu, 30 Jan 2025 12:19:11 +0000 Subject: [PATCH 06/22] docs: update list-stacks command documentation and simplify default columns --- pkg/list/list_stacks.go | 2 - .../docs/cli/commands/list/list-stacks.mdx | 51 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/pkg/list/list_stacks.go b/pkg/list/list_stacks.go index 95b4fe5fb..5f94d311e 100644 --- a/pkg/list/list_stacks.go +++ b/pkg/list/list_stacks.go @@ -142,8 +142,6 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig if len(listConfig.Columns) == 0 { listConfig.Columns = []schema.ListColumnConfig{ {Name: "Stack", Value: "{{ .atmos_stack }}"}, - {Name: "Tenant", Value: "{{ index .vars \"tenant\" }}"}, - {Name: "Environment", Value: "{{ index .vars \"environment\" }}"}, {Name: "File", Value: "{{ .atmos_stack_file }}"}, } } diff --git a/website/docs/cli/commands/list/list-stacks.mdx b/website/docs/cli/commands/list/list-stacks.mdx index ae261d3f0..6ece69caf 100644 --- a/website/docs/cli/commands/list/list-stacks.mdx +++ b/website/docs/cli/commands/list/list-stacks.mdx @@ -44,3 +44,54 @@ atmos list stacks -c vpc | Flag | Description | Alias | Required | |------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------|----------| | `--component` | Atmos component | `-c` | no | + +## Display Format + +The command output can be customized using template variables in your `atmos.yaml` configuration. Here's an example of how to configure the display format: + +```yaml +stacks: + display: + columns: + - name: Stack + value: '{{ .atmos_stack }}' + - name: Tenant + value: '{{ index .vars "tenant" }}' +``` + +:::tip Using the `index` Function +When accessing variables that might not be defined in all stacks, it's recommended to use the Go template `index` function instead of direct access. For example: + +- Safe: `{{ index .vars "tenant" }}` +- Unsafe: `{{ .vars.tenant }}` + +The `index` function provides a safer way to access map values as it won't cause a template error if the key doesn't exist. This is particularly useful when dealing with optional variables or when different stacks might have different variable sets. +::: + +## Default Display + +By default, the command displays the following columns for each stack: +- Stack: The name of the stack +- File: The path to the stack file + +You can customize the displayed columns using the `list.stacks.columns` configuration in `atmos.yaml`. + +Example of default output: +```bash +Stack File +dev examples/quick-start-simple/stacks/deploy/dev.yaml +prod examples/quick-start-simple/stacks/deploy/prod.yaml +staging examples/quick-start-simple/stacks/deploy/staging.yaml +``` + +### Example Output + +```shell +$ atmos list stacks +STACK TENANT +dev acme +staging acme +prod acme-corp +``` + +This format provides a clear, tabular view of your stacks and their associated tenant information. From d53379f5bd41a7d85a963257c7d703852c1ef1fd Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Thu, 30 Jan 2025 12:19:22 +0000 Subject: [PATCH 07/22] test: add test case for FilterAndListStacks function --- pkg/list/list_stacks_test.go | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/pkg/list/list_stacks_test.go b/pkg/list/list_stacks_test.go index 4972190ee..c0f999874 100644 --- a/pkg/list/list_stacks_test.go +++ b/pkg/list/list_stacks_test.go @@ -70,3 +70,47 @@ func TestListStacksWithComponent(t *testing.T) { // Verify that only stacks with the specified component are included assert.Contains(t, dependentsYaml, testComponent) } + +func TestFilterAndListStacks(t *testing.T) { + // Mock context and config + context := map[string]any{ + "components": map[string]any{}, + "stacks": map[string]any{}, + } + stacksBasePath := "examples/quick-start-simple/stacks" + stackType := "deploy" + component := "" + + tests := []struct { + name string + config schema.ListConfig + expected []map[string]string + }{ + { + name: "default columns", + config: schema.ListConfig{}, + expected: []map[string]string{ + { + "Stack": "dev", + "File": "examples/quick-start-simple/stacks/deploy/dev.yaml", + }, + { + "Stack": "prod", + "File": "examples/quick-start-simple/stacks/deploy/prod.yaml", + }, + { + "Stack": "staging", + "File": "examples/quick-start-simple/stacks/deploy/staging.yaml", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := FilterAndListStacks(context, stacksBasePath, test.config, stackType, component) + assert.NoError(t, err) + assert.Equal(t, test.expected, result) + }) + } +} From 9e79f603c300152f19c73cf77953f56e8f585ef3 Mon Sep 17 00:00:00 2001 From: "Vinicius C." Date: Thu, 30 Jan 2025 12:26:13 +0000 Subject: [PATCH 08/22] Update website/docs/cli/commands/list/list-stacks.mdx Co-authored-by: Erik Osterman (CEO @ Cloud Posse) --- website/docs/cli/commands/list/list-stacks.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/cli/commands/list/list-stacks.mdx b/website/docs/cli/commands/list/list-stacks.mdx index 6ece69caf..af76ed5a5 100644 --- a/website/docs/cli/commands/list/list-stacks.mdx +++ b/website/docs/cli/commands/list/list-stacks.mdx @@ -47,7 +47,7 @@ atmos list stacks -c vpc ## Display Format -The command output can be customized using template variables in your `atmos.yaml` configuration. Here's an example of how to configure the display format: +The command output can be customized using template variables in your `atmos.yaml` configuration. Here's an example of how to configure the display format to add a custom field called “Tenant”: ```yaml stacks: From 6bbe5b72f875b2eb401d4b9337f2c6c17b03ee11 Mon Sep 17 00:00:00 2001 From: "Vinicius C." Date: Sun, 2 Feb 2025 11:49:01 +0000 Subject: [PATCH 09/22] Update website/docs/cli/commands/list/list-stacks.mdx Co-authored-by: Erik Osterman (CEO @ Cloud Posse) --- website/docs/cli/commands/list/list-stacks.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/cli/commands/list/list-stacks.mdx b/website/docs/cli/commands/list/list-stacks.mdx index af76ed5a5..886466f21 100644 --- a/website/docs/cli/commands/list/list-stacks.mdx +++ b/website/docs/cli/commands/list/list-stacks.mdx @@ -47,7 +47,7 @@ atmos list stacks -c vpc ## Display Format -The command output can be customized using template variables in your `atmos.yaml` configuration. Here's an example of how to configure the display format to add a custom field called “Tenant”: +The command output can be customized using template variables in your `atmos.yaml` configuration. Here's an example of how to configure the display format to adds custom fields called "Stack", "Tenant", "Environment", and "File: ```yaml stacks: From 161128529d384c57d29a1dfd18ab00e68ef00a30 Mon Sep 17 00:00:00 2001 From: "Vinicius C." Date: Sun, 2 Feb 2025 11:49:12 +0000 Subject: [PATCH 10/22] Update website/docs/cli/commands/list/list-stacks.mdx Co-authored-by: Erik Osterman (CEO @ Cloud Posse) --- website/docs/cli/commands/list/list-stacks.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/website/docs/cli/commands/list/list-stacks.mdx b/website/docs/cli/commands/list/list-stacks.mdx index 886466f21..1fc90a51e 100644 --- a/website/docs/cli/commands/list/list-stacks.mdx +++ b/website/docs/cli/commands/list/list-stacks.mdx @@ -59,6 +59,7 @@ stacks: value: '{{ index .vars "tenant" }}' ``` +This example assumes that every component defines `vars.tenant` and `vars.environment`. While [Cloud Posse's Reference Architecture](https://docs.cloudposse.com) follows this convention, any well-defined convention will work. :::tip Using the `index` Function When accessing variables that might not be defined in all stacks, it's recommended to use the Go template `index` function instead of direct access. For example: From 8690e35c347750ccdceb0f0e4c091aaaea348506 Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Sun, 2 Feb 2025 15:15:10 +0000 Subject: [PATCH 11/22] Move stack list columns configuration to quick-start-advanced example --- atmos.yaml | 11 ----------- examples/quick-start-advanced/atmos.yaml | 10 ++++++++++ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/atmos.yaml b/atmos.yaml index 1dc9960fc..943f61ab5 100644 --- a/atmos.yaml +++ b/atmos.yaml @@ -76,16 +76,6 @@ stacks: - "**/_defaults.yaml" # Can also be set using 'ATMOS_STACKS_NAME_PATTERN' ENV var name_pattern: "{tenant}-{environment}-{stage}" - list: - columns: - - name: Stack - value: '{{ .atmos_stack }}' - - name: Tenant - value: '{{ index .vars "tenant" }}' - - name: Environment - value: '{{ index .vars "environment" }}' - - name: File - value: '{{ .atmos_stack_file }}' workflows: # Can also be set using 'ATMOS_WORKFLOWS_BASE_PATH' ENV var, or '--workflows-dir' command-line argument @@ -404,4 +394,3 @@ version: enabled: true timeout: 1000 # ms frequency: 1h - diff --git a/examples/quick-start-advanced/atmos.yaml b/examples/quick-start-advanced/atmos.yaml index 93a21f94b..562608826 100644 --- a/examples/quick-start-advanced/atmos.yaml +++ b/examples/quick-start-advanced/atmos.yaml @@ -66,6 +66,16 @@ stacks: - "**/_defaults.yaml" # Can also be set using 'ATMOS_STACKS_NAME_PATTERN' ENV var name_pattern: "{tenant}-{environment}-{stage}" + list: + columns: + - name: Stack + value: '{{ .atmos_stack }}' + - name: Tenant + value: '{{ index .vars "tenant" }}' + - name: Environment + value: '{{ index .vars "environment" }}' + - name: File + value: '{{ .atmos_stack_file }}' workflows: # Can also be set using 'ATMOS_WORKFLOWS_BASE_PATH' ENV var, or '--workflows-dir' command-line argument From d7718bc26aa676d5e6db9710ced3e9a8c0bf01eb Mon Sep 17 00:00:00 2001 From: "Vinicius C." Date: Sun, 2 Feb 2025 15:16:09 +0000 Subject: [PATCH 12/22] Update website/docs/cli/commands/list/list-stacks.mdx Co-authored-by: Erik Osterman (CEO @ Cloud Posse) --- website/docs/cli/commands/list/list-stacks.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/website/docs/cli/commands/list/list-stacks.mdx b/website/docs/cli/commands/list/list-stacks.mdx index 1fc90a51e..0ca1ee760 100644 --- a/website/docs/cli/commands/list/list-stacks.mdx +++ b/website/docs/cli/commands/list/list-stacks.mdx @@ -51,12 +51,16 @@ The command output can be customized using template variables in your `atmos.yam ```yaml stacks: - display: + list: columns: - name: Stack value: '{{ .atmos_stack }}' - name: Tenant value: '{{ index .vars "tenant" }}' + - name: Environment + value: '{{ index .vars "environment" }}' + - name: File + value: '{{ .atmos_stack_file }}' ``` This example assumes that every component defines `vars.tenant` and `vars.environment`. While [Cloud Posse's Reference Architecture](https://docs.cloudposse.com) follows this convention, any well-defined convention will work. From 6c33077064015bc6e6bdce8a79d77e8ae865dedc Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Sun, 2 Feb 2025 18:53:39 +0000 Subject: [PATCH 13/22] test: enhance FilterAndListStacks test with realistic stack configurations --- pkg/list/list_stacks_test.go | 87 ++++++++++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 13 deletions(-) diff --git a/pkg/list/list_stacks_test.go b/pkg/list/list_stacks_test.go index c0f999874..831c19ba5 100644 --- a/pkg/list/list_stacks_test.go +++ b/pkg/list/list_stacks_test.go @@ -1,6 +1,7 @@ package list import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -72,23 +73,67 @@ func TestListStacksWithComponent(t *testing.T) { } func TestFilterAndListStacks(t *testing.T) { - // Mock context and config - context := map[string]any{ - "components": map[string]any{}, - "stacks": map[string]any{}, + // Mock stacks map with actual configurations from examples/quick-start-simple + stacksMap := map[string]any{ + "dev": map[string]any{ + "vars": map[string]any{ + "stage": "dev", + }, + "components": map[string]any{ + "terraform": map[string]any{ + "station": map[string]any{ + "vars": map[string]any{ + "location": "Stockholm", + "lang": "se", + }, + }, + }, + }, + }, + "staging": map[string]any{ + "vars": map[string]any{ + "stage": "staging", + }, + "components": map[string]any{ + "terraform": map[string]any{ + "station": map[string]any{ + "vars": map[string]any{ + "location": "Los Angeles", + "lang": "en", + }, + }, + }, + }, + }, + "prod": map[string]any{ + "vars": map[string]any{ + "stage": "prod", + }, + "components": map[string]any{ + "terraform": map[string]any{ + "station": map[string]any{ + "vars": map[string]any{ + "location": "Los Angeles", + "lang": "en", + }, + }, + }, + }, + }, } - stacksBasePath := "examples/quick-start-simple/stacks" - stackType := "deploy" - component := "" tests := []struct { - name string - config schema.ListConfig - expected []map[string]string + name string + config schema.ListConfig + format string + delimiter string + expected []map[string]string }{ { - name: "default columns", - config: schema.ListConfig{}, + name: "default columns", + config: schema.ListConfig{}, + format: "", + delimiter: "\t", expected: []map[string]string{ { "Stack": "dev", @@ -108,8 +153,24 @@ func TestFilterAndListStacks(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - result, err := FilterAndListStacks(context, stacksBasePath, test.config, stackType, component) + output, err := FilterAndListStacks(stacksMap, "", test.config, test.format, test.delimiter) assert.NoError(t, err) + + // Parse the output into a slice of maps for comparison + var result []map[string]string + lines := strings.Split(strings.TrimSpace(output), u.GetLineEnding()) + if len(lines) > 1 { // Skip header row + headers := strings.Split(lines[0], test.delimiter) + for _, line := range lines[1:] { + values := strings.Split(line, test.delimiter) + row := make(map[string]string) + for i, header := range headers { + row[header] = values[i] + } + result = append(result, row) + } + } + assert.Equal(t, test.expected, result) }) } From 1a871fa12a31b88269e8efd2234febf899302151 Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Sun, 2 Feb 2025 19:56:40 +0000 Subject: [PATCH 14/22] fix: use quick-start-simple path for stacks when tenant info is incomplete --- pkg/list/list_stacks.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/list/list_stacks.go b/pkg/list/list_stacks.go index 5f94d311e..bf3a2a202 100644 --- a/pkg/list/list_stacks.go +++ b/pkg/list/list_stacks.go @@ -56,9 +56,14 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig tenant, _ := vars["tenant"].(string) environment, _ := vars["environment"].(string) stage, _ := vars["stage"].(string) - stackInfo["atmos_stack_file"] = fmt.Sprintf("orgs/acme/%s/%s/%s", tenant, environment, stage) + // Only use the org/tenant path if all required variables are present + if tenant != "" && environment != "" && stage != "" { + stackInfo["atmos_stack_file"] = fmt.Sprintf("orgs/acme/%s/%s/%s", tenant, environment, stage) + } else { + stackInfo["atmos_stack_file"] = fmt.Sprintf("examples/quick-start-simple/stacks/deploy/%s.yaml", stackName) + } } else { - stackInfo["atmos_stack_file"] = fmt.Sprintf("stacks/deploy/%s.yaml", stackName) + stackInfo["atmos_stack_file"] = fmt.Sprintf("examples/quick-start-simple/stacks/deploy/%s.yaml", stackName) } // Add component vars if they exist @@ -99,9 +104,14 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig tenant, _ := vars["tenant"].(string) environment, _ := vars["environment"].(string) stage, _ := vars["stage"].(string) - stackInfo["atmos_stack_file"] = fmt.Sprintf("orgs/acme/%s/%s/%s", tenant, environment, stage) + // Only use the org/tenant path if all required variables are present + if tenant != "" && environment != "" && stage != "" { + stackInfo["atmos_stack_file"] = fmt.Sprintf("orgs/acme/%s/%s/%s", tenant, environment, stage) + } else { + stackInfo["atmos_stack_file"] = fmt.Sprintf("examples/quick-start-simple/stacks/deploy/%s.yaml", stackName) + } } else { - stackInfo["atmos_stack_file"] = fmt.Sprintf("stacks/deploy/%s.yaml", stackName) + stackInfo["atmos_stack_file"] = fmt.Sprintf("examples/quick-start-simple/stacks/deploy/%s.yaml", stackName) } // Add component vars if they exist From 7ad8f6e7df674af9805bffe9a45ebc97bb23781d Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Mon, 3 Feb 2025 11:29:23 +0000 Subject: [PATCH 15/22] refactor: simplify stack listing and improve output formatting --- pkg/list/list_stacks.go | 136 +++++++++++++--------------------------- 1 file changed, 44 insertions(+), 92 deletions(-) diff --git a/pkg/list/list_stacks.go b/pkg/list/list_stacks.go index bf3a2a202..e0571360b 100644 --- a/pkg/list/list_stacks.go +++ b/pkg/list/list_stacks.go @@ -37,54 +37,30 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig if !ok { continue } - components, ok := v2["components"].(map[string]any) - if !ok { - continue - } - terraform, ok := components["terraform"].(map[string]any) - if !ok { - continue - } - if _, exists := terraform[component]; exists { - stackInfo := map[string]any{ - "atmos_stack": stackName, - "vars": v2["vars"], - } - - // Safely get tenant and environment from vars - if vars, ok := v2["vars"].(map[string]any); ok { - tenant, _ := vars["tenant"].(string) - environment, _ := vars["environment"].(string) - stage, _ := vars["stage"].(string) - // Only use the org/tenant path if all required variables are present - if tenant != "" && environment != "" && stage != "" { - stackInfo["atmos_stack_file"] = fmt.Sprintf("orgs/acme/%s/%s/%s", tenant, environment, stage) - } else { - stackInfo["atmos_stack_file"] = fmt.Sprintf("examples/quick-start-simple/stacks/deploy/%s.yaml", stackName) + // Check if the component exists in any component type section + if components, ok := v2["components"].(map[string]any); ok { + componentFound := false + for _, componentSection := range components { + if compSection, ok := componentSection.(map[string]any); ok { + if _, exists := compSection[component]; exists { + componentFound = true + break + } } - } else { - stackInfo["atmos_stack_file"] = fmt.Sprintf("examples/quick-start-simple/stacks/deploy/%s.yaml", stackName) } + if componentFound { + // Create stack info with the entire configuration for template access + stackInfo := map[string]any{ + "atmos_stack": stackName, + "stack_file": fmt.Sprintf("%s.yaml", stackName), + } - // Add component vars if they exist - if components, ok := v2["components"].(map[string]any); ok { - if terraform, ok := components["terraform"].(map[string]any); ok { - for _, comp := range terraform { - if compSection, ok := comp.(map[string]any); ok { - if compVars, ok := compSection["vars"].(map[string]any); ok { - // Merge component vars with stack vars - if stackInfo["vars"] == nil { - stackInfo["vars"] = make(map[string]any) - } - for k, v := range compVars { - stackInfo["vars"].(map[string]any)[k] = v - } - } - } - } + // Copy all stack configuration to allow full access in templates + for k, v := range v2 { + stackInfo[k] = v } + filteredStacks = append(filteredStacks, stackInfo) } - filteredStacks = append(filteredStacks, stackInfo) } } } else { @@ -94,43 +70,15 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig if !ok { continue } + // Create stack info with the entire configuration for template access stackInfo := map[string]any{ "atmos_stack": stackName, - "vars": v2["vars"], + "stack_file": fmt.Sprintf("%s.yaml", stackName), } - // Safely get tenant and environment from vars - if vars, ok := v2["vars"].(map[string]any); ok { - tenant, _ := vars["tenant"].(string) - environment, _ := vars["environment"].(string) - stage, _ := vars["stage"].(string) - // Only use the org/tenant path if all required variables are present - if tenant != "" && environment != "" && stage != "" { - stackInfo["atmos_stack_file"] = fmt.Sprintf("orgs/acme/%s/%s/%s", tenant, environment, stage) - } else { - stackInfo["atmos_stack_file"] = fmt.Sprintf("examples/quick-start-simple/stacks/deploy/%s.yaml", stackName) - } - } else { - stackInfo["atmos_stack_file"] = fmt.Sprintf("examples/quick-start-simple/stacks/deploy/%s.yaml", stackName) - } - - // Add component vars if they exist - if components, ok := v2["components"].(map[string]any); ok { - if terraform, ok := components["terraform"].(map[string]any); ok { - for _, comp := range terraform { - if compSection, ok := comp.(map[string]any); ok { - if compVars, ok := compSection["vars"].(map[string]any); ok { - // Merge component vars with stack vars - if stackInfo["vars"] == nil { - stackInfo["vars"] = make(map[string]any) - } - for k, v := range compVars { - stackInfo["vars"].(map[string]any)[k] = v - } - } - } - } - } + // Copy all stack configuration to allow full access in templates + for k, v := range v2 { + stackInfo[k] = v } filteredStacks = append(filteredStacks, stackInfo) } @@ -152,7 +100,7 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig if len(listConfig.Columns) == 0 { listConfig.Columns = []schema.ListColumnConfig{ {Name: "Stack", Value: "{{ .atmos_stack }}"}, - {Name: "File", Value: "{{ .atmos_stack_file }}"}, + {Name: "File", Value: "{{ .stack_file }}"}, } } @@ -185,15 +133,25 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig // Handle different output formats switch format { case FormatJSON: - var result []map[string]string + // Convert to JSON format using a proper struct + type stack struct { + Stack string `json:"stack"` + File string `json:"file"` + } + var stacks []stack for _, row := range rows { - item := make(map[string]string) + s := stack{} for i, header := range headers { - item[header] = row[i] + switch header { + case "Stack": + s.Stack = row[i] + case "File": + s.File = row[i] + } } - result = append(result, item) + stacks = append(stacks, s) } - jsonBytes, err := json.MarshalIndent(result, "", " ") + jsonBytes, err := json.MarshalIndent(stacks, "", " ") if err != nil { return "", fmt.Errorf("error formatting JSON output: %w", err) } @@ -208,7 +166,7 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig return output.String(), nil default: - // Check for TTY support + // If format is empty or "table", use table format if format == "" && exec.CheckTTYSupport() { // Create a styled table for TTY t := table.New(). @@ -228,17 +186,11 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig return t.String() + utils.GetLineEnding(), nil } + // Default to simple tabular format for non-TTY or when format is explicitly "table" var output strings.Builder - // Write headers - headerRow := make([]string, len(headers)) - for i, h := range headers { - headerRow[i] = h - } - output.WriteString(strings.Join(headerRow, "\t") + utils.GetLineEnding()) - - // Write rows + output.WriteString(strings.Join(headers, delimiter) + utils.GetLineEnding()) for _, row := range rows { - output.WriteString(strings.Join(row, "\t") + utils.GetLineEnding()) + output.WriteString(strings.Join(row, delimiter) + utils.GetLineEnding()) } return output.String(), nil } From 856c0eb76703407a96fb642ebc28f683f9a29987 Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Mon, 3 Feb 2025 12:27:15 +0000 Subject: [PATCH 16/22] refactor: extract stack info creation and improve JSON output handling --- pkg/list/list_stacks.go | 53 ++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/pkg/list/list_stacks.go b/pkg/list/list_stacks.go index e0571360b..b0f592199 100644 --- a/pkg/list/list_stacks.go +++ b/pkg/list/list_stacks.go @@ -28,6 +28,19 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig format = listConfig.Format } + // Helper function to create stack info + createStackInfo := func(stackName string, v2 map[string]any) map[string]any { + stackInfo := map[string]any{ + "atmos_stack": stackName, + "stack_file": fmt.Sprintf("%s.yaml", stackName), + } + // Copy all stack configuration to allow full access in templates + for k, v := range v2 { + stackInfo[k] = v + } + return stackInfo + } + var filteredStacks []map[string]any if component != "" { @@ -49,16 +62,7 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig } } if componentFound { - // Create stack info with the entire configuration for template access - stackInfo := map[string]any{ - "atmos_stack": stackName, - "stack_file": fmt.Sprintf("%s.yaml", stackName), - } - - // Copy all stack configuration to allow full access in templates - for k, v := range v2 { - stackInfo[k] = v - } + stackInfo := createStackInfo(stackName, v2) filteredStacks = append(filteredStacks, stackInfo) } } @@ -70,16 +74,7 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig if !ok { continue } - // Create stack info with the entire configuration for template access - stackInfo := map[string]any{ - "atmos_stack": stackName, - "stack_file": fmt.Sprintf("%s.yaml", stackName), - } - - // Copy all stack configuration to allow full access in templates - for k, v := range v2 { - stackInfo[k] = v - } + stackInfo := createStackInfo(stackName, v2) filteredStacks = append(filteredStacks, stackInfo) } } @@ -133,21 +128,13 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig // Handle different output formats switch format { case FormatJSON: - // Convert to JSON format using a proper struct - type stack struct { - Stack string `json:"stack"` - File string `json:"file"` - } - var stacks []stack + // Convert to JSON format using dynamic fields + var stacks []map[string]string for _, row := range rows { - s := stack{} + s := make(map[string]string) for i, header := range headers { - switch header { - case "Stack": - s.Stack = row[i] - case "File": - s.File = row[i] - } + // Convert header to lowercase for consistent JSON field names + s[strings.ToLower(header)] = row[i] } stacks = append(stacks, s) } From d66c1c7869a88d75154c0ad8dfd8f707a18f7b92 Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Mon, 3 Feb 2025 13:08:16 +0000 Subject: [PATCH 17/22] fix: use stack file from atmos configuration instead of hardcoded value --- pkg/list/list_stacks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/list/list_stacks.go b/pkg/list/list_stacks.go index b0f592199..41b37788f 100644 --- a/pkg/list/list_stacks.go +++ b/pkg/list/list_stacks.go @@ -32,7 +32,7 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig createStackInfo := func(stackName string, v2 map[string]any) map[string]any { stackInfo := map[string]any{ "atmos_stack": stackName, - "stack_file": fmt.Sprintf("%s.yaml", stackName), + "stack_file": v2["atmos_stack_file"], } // Copy all stack configuration to allow full access in templates for k, v := range v2 { From c976cf311aa8792f0720d8e10a181258c7bbedf9 Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Wed, 5 Feb 2025 13:16:33 +0000 Subject: [PATCH 18/22] optimize: pre-parse templates for better list performance --- pkg/list/list_stacks.go | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/pkg/list/list_stacks.go b/pkg/list/list_stacks.go index 41b37788f..d8d4b2465 100644 --- a/pkg/list/list_stacks.go +++ b/pkg/list/list_stacks.go @@ -107,18 +107,28 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig headers[i] = col.Name } + // Pre-parse templates for better performance + type columnTemplate struct { + name string + template *template.Template + } + + templates := make([]columnTemplate, len(listConfig.Columns)) + for i, col := range listConfig.Columns { + tmpl, err := template.New(col.Name).Parse(col.Value) + if err != nil { + return "", fmt.Errorf("error parsing template for column %s: %w", col.Name, err) + } + templates[i] = columnTemplate{name: col.Name, template: tmpl} + } + // Process each stack and populate rows for i, stack := range filteredStacks { row := make([]string, len(listConfig.Columns)) - for j, col := range listConfig.Columns { - tmpl, err := template.New("column").Parse(col.Value) - if err != nil { - return "", fmt.Errorf("error parsing template for column %s: %w", col.Name, err) - } - + for j, tmpl := range templates { var buf strings.Builder - if err := tmpl.Execute(&buf, stack); err != nil { - return "", fmt.Errorf("error executing template for column %s: %w", col.Name, err) + if err := tmpl.template.Execute(&buf, stack); err != nil { + return "", fmt.Errorf("error executing template for column %s: %w", tmpl.name, err) } row[j] = buf.String() } From c8dd900edc7e8f055c2eff81120ffed6cc7bef1b Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Wed, 5 Feb 2025 13:43:14 +0000 Subject: [PATCH 19/22] Add atmos_stack_file to stack info and update column template --- pkg/list/list_stacks.go | 6 +++--- pkg/list/list_stacks_test.go | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/list/list_stacks.go b/pkg/list/list_stacks.go index d8d4b2465..dbd2482b5 100644 --- a/pkg/list/list_stacks.go +++ b/pkg/list/list_stacks.go @@ -31,8 +31,8 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig // Helper function to create stack info createStackInfo := func(stackName string, v2 map[string]any) map[string]any { stackInfo := map[string]any{ - "atmos_stack": stackName, - "stack_file": v2["atmos_stack_file"], + "atmos_stack": stackName, + "atmos_stack_file": v2["atmos_stack_file"], } // Copy all stack configuration to allow full access in templates for k, v := range v2 { @@ -95,7 +95,7 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig if len(listConfig.Columns) == 0 { listConfig.Columns = []schema.ListColumnConfig{ {Name: "Stack", Value: "{{ .atmos_stack }}"}, - {Name: "File", Value: "{{ .stack_file }}"}, + {Name: "File", Value: "{{ .atmos_stack_file }}"}, } } diff --git a/pkg/list/list_stacks_test.go b/pkg/list/list_stacks_test.go index b8dc66a45..4e79bfe20 100644 --- a/pkg/list/list_stacks_test.go +++ b/pkg/list/list_stacks_test.go @@ -79,6 +79,7 @@ func TestFilterAndListStacks(t *testing.T) { "vars": map[string]any{ "stage": "dev", }, + "atmos_stack_file": "examples/quick-start-simple/stacks/deploy/dev.yaml", "components": map[string]any{ "terraform": map[string]any{ "station": map[string]any{ @@ -94,6 +95,7 @@ func TestFilterAndListStacks(t *testing.T) { "vars": map[string]any{ "stage": "staging", }, + "atmos_stack_file": "examples/quick-start-simple/stacks/deploy/staging.yaml", "components": map[string]any{ "terraform": map[string]any{ "station": map[string]any{ @@ -109,6 +111,7 @@ func TestFilterAndListStacks(t *testing.T) { "vars": map[string]any{ "stage": "prod", }, + "atmos_stack_file": "examples/quick-start-simple/stacks/deploy/prod.yaml", "components": map[string]any{ "terraform": map[string]any{ "station": map[string]any{ From 98f5dfeab3fa079fcb06f66bcb9983a6e0d0b76e Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Thu, 6 Feb 2025 11:55:14 +0000 Subject: [PATCH 20/22] feat: enhance stack listing with variable extraction and dynamic columns --- cmd/list_stacks.go | 2 +- internal/exec/describe_stacks.go | 33 +++--- pkg/list/list_stacks.go | 179 +++++++++++++++++++++++++------ 3 files changed, 167 insertions(+), 47 deletions(-) diff --git a/cmd/list_stacks.go b/cmd/list_stacks.go index c0159ab6b..e96a79bf8 100644 --- a/cmd/list_stacks.go +++ b/cmd/list_stacks.go @@ -40,7 +40,7 @@ var listStacksCmd = &cobra.Command{ return } - stacksMap, err := e.ExecuteDescribeStacks(atmosConfig, "", nil, nil, nil, false, false, false, false, nil) + stacksMap, err := e.ExecuteDescribeStacks(atmosConfig, "", nil, nil, nil, false, true, true, false, nil) if err != nil { u.PrintMessageInColor(fmt.Sprintf("Error describing stacks: %v", err), theme.Colors.Error) return diff --git a/internal/exec/describe_stacks.go b/internal/exec/describe_stacks.go index 3ef202cf1..92cacfe4e 100644 --- a/internal/exec/describe_stacks.go +++ b/internal/exec/describe_stacks.go @@ -214,6 +214,7 @@ func ExecuteDescribeStacks( if !u.MapKeyExists(finalStacksMap, stackName) { finalStacksMap[stackName] = make(map[string]any) finalStacksMap[stackName].(map[string]any)["components"] = make(map[string]any) + finalStacksMap[stackName].(map[string]any)["atmos_stack_file"] = stackFileName } if componentsSection, ok := stackSection.(map[string]any)["components"].(map[string]any); ok { @@ -307,15 +308,22 @@ func ExecuteDescribeStacks( if err != nil { return nil, err } - } else { + } else if atmosConfig.Stacks.NamePattern != "" { context = cfg.GetContextFromVars(varsSection) configAndStacksInfo.Context = context stackName, err = cfg.GetContextPrefix(stackFileName, context, GetStackNamePattern(atmosConfig), stackFileName) if err != nil { return nil, err } + } else { + // If no name pattern or template is configured, use the stack file name + stackName = stackFileName } + // Update the component section with the final stack name + configAndStacksInfo.ComponentSection["atmos_stack"] = stackName + configAndStacksInfo.ComponentSection["stack"] = stackName + if filterByStack != "" && filterByStack != stackFileName && filterByStack != stackName { continue } @@ -327,18 +335,15 @@ func ExecuteDescribeStacks( // Only create the stack entry if it doesn't exist if !u.MapKeyExists(finalStacksMap, stackName) { finalStacksMap[stackName] = make(map[string]any) + finalStacksMap[stackName].(map[string]any)["components"] = make(map[string]any) + finalStacksMap[stackName].(map[string]any)["atmos_stack_file"] = stackFileName } configAndStacksInfo.ComponentSection["atmos_component"] = componentName - configAndStacksInfo.ComponentSection["atmos_stack"] = stackName - configAndStacksInfo.ComponentSection["stack"] = stackName configAndStacksInfo.ComponentSection["atmos_stack_file"] = stackFileName configAndStacksInfo.ComponentSection["atmos_manifest"] = stackFileName if len(components) == 0 || u.SliceContainsString(components, componentName) || u.SliceContainsString(derivedComponents, componentName) { - if !u.MapKeyExists(finalStacksMap[stackName].(map[string]any), "components") { - finalStacksMap[stackName].(map[string]any)["components"] = make(map[string]any) - } if !u.MapKeyExists(finalStacksMap[stackName].(map[string]any)["components"].(map[string]any), "terraform") { finalStacksMap[stackName].(map[string]any)["components"].(map[string]any)["terraform"] = make(map[string]any) } @@ -516,15 +521,22 @@ func ExecuteDescribeStacks( if err != nil { return nil, err } - } else { + } else if atmosConfig.Stacks.NamePattern != "" { context = cfg.GetContextFromVars(varsSection) configAndStacksInfo.Context = context stackName, err = cfg.GetContextPrefix(stackFileName, context, GetStackNamePattern(atmosConfig), stackFileName) if err != nil { return nil, err } + } else { + // If no name pattern or template is configured, use the stack file name + stackName = stackFileName } + // Update the component section with the final stack name + configAndStacksInfo.ComponentSection["atmos_stack"] = stackName + configAndStacksInfo.ComponentSection["stack"] = stackName + if filterByStack != "" && filterByStack != stackFileName && filterByStack != stackName { continue } @@ -536,18 +548,15 @@ func ExecuteDescribeStacks( // Only create the stack entry if it doesn't exist if !u.MapKeyExists(finalStacksMap, stackName) { finalStacksMap[stackName] = make(map[string]any) + finalStacksMap[stackName].(map[string]any)["components"] = make(map[string]any) + finalStacksMap[stackName].(map[string]any)["atmos_stack_file"] = stackFileName } configAndStacksInfo.ComponentSection["atmos_component"] = componentName - configAndStacksInfo.ComponentSection["atmos_stack"] = stackName - configAndStacksInfo.ComponentSection["stack"] = stackName configAndStacksInfo.ComponentSection["atmos_stack_file"] = stackFileName configAndStacksInfo.ComponentSection["atmos_manifest"] = stackFileName if len(components) == 0 || u.SliceContainsString(components, componentName) || u.SliceContainsString(derivedComponents, componentName) { - if !u.MapKeyExists(finalStacksMap[stackName].(map[string]any), "components") { - finalStacksMap[stackName].(map[string]any)["components"] = make(map[string]any) - } if !u.MapKeyExists(finalStacksMap[stackName].(map[string]any)["components"].(map[string]any), "helmfile") { finalStacksMap[stackName].(map[string]any)["components"].(map[string]any)["helmfile"] = make(map[string]any) } diff --git a/pkg/list/list_stacks.go b/pkg/list/list_stacks.go index dbd2482b5..476581687 100644 --- a/pkg/list/list_stacks.go +++ b/pkg/list/list_stacks.go @@ -33,47 +33,90 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig stackInfo := map[string]any{ "atmos_stack": stackName, "atmos_stack_file": v2["atmos_stack_file"], + "vars": make(map[string]any), } - // Copy all stack configuration to allow full access in templates - for k, v := range v2 { - stackInfo[k] = v - } - return stackInfo - } - var filteredStacks []map[string]any - - if component != "" { - // Filter stacks by component - for stackName, stackData := range stacksMap { - v2, ok := stackData.(map[string]any) - if !ok { - continue + // Extract variables from stack level + if stackVars, ok := v2["vars"].(map[string]any); ok { + for k, v := range stackVars { + if v != nil { + stackInfo["vars"].(map[string]any)[k] = v + } } - // Check if the component exists in any component type section - if components, ok := v2["components"].(map[string]any); ok { - componentFound := false - for _, componentSection := range components { - if compSection, ok := componentSection.(map[string]any); ok { - if _, exists := compSection[component]; exists { - componentFound = true - break + } + + // Extract variables from components + if components, ok := v2["components"].(map[string]any); ok { + // Helper function to extract vars from component section + extractComponentVars := func(componentSection map[string]any) { + for _, comp := range componentSection { + if compMap, ok := comp.(map[string]any); ok { + if vars, ok := compMap["vars"].(map[string]any); ok { + for k, v := range vars { + if _, exists := stackInfo["vars"].(map[string]any)[k]; !exists && v != nil { + stackInfo["vars"].(map[string]any)[k] = v + } + } } } } - if componentFound { - stackInfo := createStackInfo(stackName, v2) - filteredStacks = append(filteredStacks, stackInfo) + } + + // Process terraform and helmfile components + for _, section := range components { + if sectionMap, ok := section.(map[string]any); ok { + extractComponentVars(sectionMap) } } } - } else { - // List all stacks - for stackName, stackData := range stacksMap { - v2, ok := stackData.(map[string]any) - if !ok { - continue + + // Extract stage from stack name if not set in vars + if _, ok := stackInfo["vars"].(map[string]any)["stage"]; !ok { + // Only set stage from stack name if it's not already set in vars + if stackName != stackInfo["atmos_stack_file"].(string) { + stackInfo["vars"].(map[string]any)["stage"] = stackName + } + } + + // Copy other stack configuration + for k, v := range v2 { + if k != "vars" && k != "components" { + stackInfo[k] = v + } + } + + return stackInfo + } + + // Helper function to check if stack has component + hasComponent := func(components map[string]any, targetComponent string) bool { + for _, section := range components { + if compSection, ok := section.(map[string]any); ok { + if _, exists := compSection[targetComponent]; exists { + return true + } } + } + return false + } + + var filteredStacks []map[string]any + + // Filter and process stacks + for stackName, stackData := range stacksMap { + v2, ok := stackData.(map[string]any) + if !ok { + continue + } + + if component != "" { + // Only include stacks with the specified component + if components, ok := v2["components"].(map[string]any); ok && hasComponent(components, component) { + stackInfo := createStackInfo(stackName, v2) + filteredStacks = append(filteredStacks, stackInfo) + } + } else { + // Include all stacks when no component filter is specified stackInfo := createStackInfo(stackName, v2) filteredStacks = append(filteredStacks, stackInfo) } @@ -93,10 +136,58 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig // If no columns are configured, use default columns if len(listConfig.Columns) == 0 { - listConfig.Columns = []schema.ListColumnConfig{ + // Define all possible columns + allColumns := []schema.ListColumnConfig{ {Name: "Stack", Value: "{{ .atmos_stack }}"}, + {Name: "Tenant", Value: "{{ getVar .vars \"tenant\" }}"}, + {Name: "Environment", Value: "{{ getVar .vars \"environment\" }}"}, + {Name: "Stage", Value: "{{ getVar .vars \"stage\" }}"}, {Name: "File", Value: "{{ .atmos_stack_file }}"}, } + + // Helper function to check if a column has any non-empty values + hasValues := func(col schema.ListColumnConfig) bool { + // Stack and File columns are always shown + if col.Name == "Stack" || col.Name == "File" { + return true + } + + funcMap := template.FuncMap{ + "getVar": func(vars map[string]any, key string) string { + if val, ok := vars[key]; ok && val != nil { + return fmt.Sprintf("%v", val) + } + return "" + }, + } + + tmpl, err := template.New(col.Name).Funcs(funcMap).Parse(col.Value) + if err != nil { + return false + } + + // Check if any stack has a non-empty value for this column + for _, stack := range filteredStacks { + var buf strings.Builder + if err := tmpl.Execute(&buf, stack); err != nil { + continue + } + if buf.String() != "" { + return true + } + } + return false + } + + // Filter out columns with no values + var activeColumns []schema.ListColumnConfig + for _, col := range allColumns { + if hasValues(col) { + activeColumns = append(activeColumns, col) + } + } + + listConfig.Columns = activeColumns } // Prepare headers and rows @@ -115,7 +206,17 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig templates := make([]columnTemplate, len(listConfig.Columns)) for i, col := range listConfig.Columns { - tmpl, err := template.New(col.Name).Parse(col.Value) + // Add custom template functions + funcMap := template.FuncMap{ + "getVar": func(vars map[string]any, key string) string { + if val, ok := vars[key]; ok && val != nil { + return fmt.Sprintf("%v", val) + } + return "" + }, + } + + tmpl, err := template.New(col.Name).Funcs(funcMap).Parse(col.Value) if err != nil { return "", fmt.Errorf("error parsing template for column %s: %w", col.Name, err) } @@ -183,9 +284,19 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig return t.String() + utils.GetLineEnding(), nil } - // Default to simple tabular format for non-TTY or when format is explicitly "table" + // For non-TTY or when format is explicitly "table", use consistent tabular format + // that matches the column configuration of the TTY output var output strings.Builder + + // Add a separator line after headers for better readability + headerLine := make([]string, len(headers)) + for i := range headers { + headerLine[i] = strings.Repeat("-", len(headers[i])) + } + output.WriteString(strings.Join(headers, delimiter) + utils.GetLineEnding()) + output.WriteString(strings.Join(headerLine, delimiter) + utils.GetLineEnding()) + for _, row := range rows { output.WriteString(strings.Join(row, delimiter) + utils.GetLineEnding()) } From da45ed26f4fa6ac788a9b5a4c58bd2aabb5b3620 Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Thu, 6 Feb 2025 12:17:26 +0000 Subject: [PATCH 21/22] feat: improve JSON and CSV output formatting for empty values --- pkg/list/list_stacks.go | 43 ++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/pkg/list/list_stacks.go b/pkg/list/list_stacks.go index 476581687..5283146aa 100644 --- a/pkg/list/list_stacks.go +++ b/pkg/list/list_stacks.go @@ -231,6 +231,7 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig if err := tmpl.template.Execute(&buf, stack); err != nil { return "", fmt.Errorf("error executing template for column %s: %w", tmpl.name, err) } + // Just use the raw string value row[j] = buf.String() } rows[i] = row @@ -239,27 +240,55 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig // Handle different output formats switch format { case FormatJSON: - // Convert to JSON format using dynamic fields + // Convert to JSON format using only non-empty columns var stacks []map[string]string for _, row := range rows { s := make(map[string]string) for i, header := range headers { - // Convert header to lowercase for consistent JSON field names - s[strings.ToLower(header)] = row[i] + // Only include non-empty values in JSON output + if row[i] != "" { + // Use raw value without any color formatting for JSON output + s[strings.ToLower(header)] = row[i] + } } stacks = append(stacks, s) } + // Use plain JSON marshaling without any color formatting jsonBytes, err := json.MarshalIndent(stacks, "", " ") if err != nil { return "", fmt.Errorf("error formatting JSON output: %w", err) } - return string(jsonBytes), nil + return string(jsonBytes) + utils.GetLineEnding(), nil case FormatCSV: + // Only include columns that have values + var nonEmptyHeaders []string + var nonEmptyColumnIndexes []int + + // Find columns that have at least one non-empty value + for i, header := range headers { + hasValue := false + for _, row := range rows { + if row[i] != "" { + hasValue = true + break + } + } + if hasValue { + nonEmptyHeaders = append(nonEmptyHeaders, header) + nonEmptyColumnIndexes = append(nonEmptyColumnIndexes, i) + } + } + var output strings.Builder - output.WriteString(strings.Join(headers, delimiter) + utils.GetLineEnding()) + output.WriteString(strings.Join(nonEmptyHeaders, delimiter) + utils.GetLineEnding()) + for _, row := range rows { - output.WriteString(strings.Join(row, delimiter) + utils.GetLineEnding()) + var nonEmptyRow []string + for _, i := range nonEmptyColumnIndexes { + nonEmptyRow = append(nonEmptyRow, row[i]) + } + output.WriteString(strings.Join(nonEmptyRow, delimiter) + utils.GetLineEnding()) } return output.String(), nil @@ -272,7 +301,7 @@ func FilterAndListStacks(stacksMap map[string]any, component string, listConfig BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorBorder))). StyleFunc(func(row, col int) lipgloss.Style { style := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1) - if row == 0 { + if row == -1 { // Apply CommandName style to all header cells return style.Inherit(theme.Styles.CommandName) } From 6ead9fb6a9572d0043420a7327e80d4136fa9584 Mon Sep 17 00:00:00 2001 From: Cerebrovinny Date: Fri, 7 Feb 2025 07:34:29 +0000 Subject: [PATCH 22/22] Remove unused list configuration from stacks section --- .../TestCLICommands_atmos_describe_config.stdout.golden | 6 +----- ...tCLICommands_atmos_describe_config_-f_yaml.stdout.golden | 3 --- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden index 89551af68..40e44840c 100644 --- a/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden @@ -31,11 +31,7 @@ "**/_defaults.yaml" ], "name_pattern": "{stage}", - "name_template": "", - "list": { - "format": "", - "columns": null - } + "name_template": "" }, "workflows": { "base_path": "", diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden b/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden index 7ca170000..19b2c2f36 100644 --- a/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden @@ -25,9 +25,6 @@ stacks: - '**/_defaults.yaml' name_pattern: '{stage}' name_template: "" - list: - format: "" - columns: [] logs: file: /dev/stderr level: Info