Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve atmos list stacks #979

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8311350
feat: add output format options for `atmos list stacks` command
Cerebrovinny Jan 28, 2025
faeec51
feat(list): enhance stack listing with configurable formats and columns
Cerebrovinny Jan 28, 2025
80d540c
Add stack list columns configuration to atmos.yaml
Cerebrovinny Jan 28, 2025
baa42e5
refactor: improve table formatting for non-TTY output
Cerebrovinny Jan 28, 2025
060d49b
Add list format and columns configuration to stacks config
Cerebrovinny Jan 28, 2025
c29f1e8
docs: update list-stacks command documentation and simplify default c…
Cerebrovinny Jan 30, 2025
d53379f
test: add test case for FilterAndListStacks function
Cerebrovinny Jan 30, 2025
9e79f60
Update website/docs/cli/commands/list/list-stacks.mdx
Cerebrovinny Jan 30, 2025
6bbe5b7
Update website/docs/cli/commands/list/list-stacks.mdx
Cerebrovinny Feb 2, 2025
1611285
Update website/docs/cli/commands/list/list-stacks.mdx
Cerebrovinny Feb 2, 2025
8690e35
Move stack list columns configuration to quick-start-advanced example
Cerebrovinny Feb 2, 2025
d7718bc
Update website/docs/cli/commands/list/list-stacks.mdx
Cerebrovinny Feb 2, 2025
6c33077
test: enhance FilterAndListStacks test with realistic stack configura…
Cerebrovinny Feb 2, 2025
1a871fa
fix: use quick-start-simple path for stacks when tenant info is incom…
Cerebrovinny Feb 2, 2025
7ad8f6e
refactor: simplify stack listing and improve output formatting
Cerebrovinny Feb 3, 2025
856c0eb
refactor: extract stack info creation and improve JSON output handling
Cerebrovinny Feb 3, 2025
d66c1c7
fix: use stack file from atmos configuration instead of hardcoded value
Cerebrovinny Feb 3, 2025
6b257de
Merge branch 'main' into DEV-2804
Cerebrovinny Feb 5, 2025
c976cf3
optimize: pre-parse templates for better list performance
Cerebrovinny Feb 5, 2025
c8dd900
Add atmos_stack_file to stack info and update column template
Cerebrovinny Feb 5, 2025
14e477f
Merge branch 'main' into DEV-2804
osterman Feb 6, 2025
98f5dfe
feat: enhance stack listing with variable extraction and dynamic columns
Cerebrovinny Feb 6, 2025
da45ed2
feat: improve JSON and CSV output formatting for empty values
Cerebrovinny Feb 6, 2025
aab2f73
Merge branch 'main' into DEV-2804
osterman Feb 6, 2025
6ead9fb
Remove unused list configuration from stacks section
Cerebrovinny Feb 7, 2025
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: 0 additions & 1 deletion atmos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -394,4 +394,3 @@ version:
enabled: true
timeout: 1000 # ms
frequency: 1h

13 changes: 10 additions & 3 deletions cmd/list_stacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,19 @@ 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 <component>",
"atmos list stacks -c <component>\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) {
// Check Atmos configuration
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)
Expand All @@ -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
Expand All @@ -52,6 +57,8 @@ var listStacksCmd = &cobra.Command{

func init() {
listStacksCmd.DisableFlagParsing = false
listStacksCmd.PersistentFlags().StringP("component", "c", "", "atmos list stacks -c <component>")
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)
}
10 changes: 10 additions & 0 deletions examples/quick-start-advanced/atmos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion pkg/list/list_components_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,16 @@ func TestListComponentsWithStack(t *testing.T) {
nil, false, false, false, false, nil)
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)
Expand Down
185 changes: 167 additions & 18 deletions pkg/list/list_stacks.go
Original file line number Diff line number Diff line change
@@ -1,45 +1,194 @@
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
}

// Helper function to create stack info
createStackInfo := func(stackName string, v2 map[string]any) map[string]any {
stackInfo := map[string]any{
"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 {
stackInfo[k] = v
}
return stackInfo
}

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 {
continue
}
components, ok := v2["components"].(map[string]any)
if !ok {
continue
// 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
}
}
}
if componentFound {
stackInfo := createStackInfo(stackName, v2)
filteredStacks = append(filteredStacks, stackInfo)
}
}
terraform, ok := components["terraform"].(map[string]any)
}
} else {
// List all stacks
for stackName, stackData := range stacksMap {
v2, ok := stackData.(map[string]any)
if !ok {
continue
}
if _, exists := terraform[component]; exists {
filteredStacks = append(filteredStacks, stackName)
}
stackInfo := createStackInfo(stackName, v2)
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: "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
}

// 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, tmpl := range templates {
var buf strings.Builder
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()
}
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:
// Convert to JSON format using dynamic fields
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]
}
stacks = append(stacks, s)
}
jsonBytes, err := json.MarshalIndent(stacks, "", " ")
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
}
}
Loading