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 components view #828

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions cmd/list_components.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ package cmd
import (
"fmt"

"github.com/fatih/color"
"github.com/spf13/cobra"

e "github.com/cloudposse/atmos/internal/exec"
"github.com/cloudposse/atmos/pkg/config"
l "github.com/cloudposse/atmos/pkg/list"
"github.com/cloudposse/atmos/pkg/schema"
u "github.com/cloudposse/atmos/pkg/utils"
"github.com/fatih/color"
"github.com/spf13/cobra"
)

// listComponentsCmd lists atmos components
Expand Down Expand Up @@ -38,7 +39,7 @@ var listComponentsCmd = &cobra.Command{
return
}

output, err := l.FilterAndListComponents(stackFlag, stacksMap)
output, err := l.FilterAndListComponents(stackFlag, stacksMap, cliConfig.Components.List)
if err != nil {
u.PrintMessageInColor(fmt.Sprintf("Error: %v"+"\n", err), color.New(color.FgYellow))
return
Expand Down
10 changes: 10 additions & 0 deletions examples/quick-start-advanced/atmos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@
base_path: "."

components:
list:
columns:
- name: Component
value: '{{ .atmos_component }}'
- name: Stack
value: '{{ .atmos_stack }}'
- name: Tenant
value: '{{ .vars.tenant }}'
- name: Region
value: '{{ .vars.region }}'
terraform:
# Optional `command` specifies the executable to be called by `atmos` when running Terraform commands
# If not defined, `terraform` is used
Expand Down
244 changes: 227 additions & 17 deletions pkg/list/list_components.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@ package list

import (
"fmt"
"regexp"
"sort"
"strings"

"github.com/charmbracelet/lipgloss"
"github.com/samber/lo"

"github.com/cloudposse/atmos/pkg/schema"
)

type tableData struct {
header []string
rows [][]string
colWidths []int
}

// getStackComponents extracts Terraform components from the final map of stacks
func getStackComponents(stackData any) ([]string, error) {
func getStackComponents(stackData any, listFields []string) ([]string, error) {
stackMap, ok := stackData.(map[string]any)
if !ok {
return nil, fmt.Errorf("could not parse stacks")
Expand All @@ -25,41 +35,241 @@ func getStackComponents(stackData any) ([]string, error) {
return nil, fmt.Errorf("could not parse Terraform components")
}

return lo.Keys(terraformComponents), nil
uniqueKeys := lo.Keys(terraformComponents)
result := make([]string, 0)

for _, dataKey := range uniqueKeys {
data := terraformComponents[dataKey]
dataMap, ok := data.(map[string]any)
if !ok {
return nil, fmt.Errorf("unexpected data type for component '%s'", dataKey)
}
rowData := make([]string, 0)
for _, key := range listFields {
value, found := resolveKey(dataMap, key)
if !found {
value = "-"
}
rowData = append(rowData, fmt.Sprintf("%s", value))
}
result = append(result, strings.Join(rowData, "\t\t"))
}
return result, nil
}

// FilterAndListComponents filters and lists components based on the given stack
func FilterAndListComponents(stackFlag string, stacksMap map[string]any) (string, error) {
components := []string{}
// resolveKey resolves a key from a map, supporting nested keys with dot notation
func resolveKey(data map[string]any, key string) (any, bool) {
// Remove leading dot from the key (e.g., `.vars.tenant` -> `vars.tenant`)
key = strings.TrimPrefix(key, ".")

// Split key on `.`
parts := strings.Split(key, ".")
current := data

// Traverse the map for each part
for i, part := range parts {
if i == len(parts)-1 {
// Return the value for the last part
if value, exists := current[part]; exists {
return value, true
}
return nil, false
}

// Traverse deeper
if nestedMap, ok := current[part].(map[string]any); ok {
current = nestedMap
} else {
return nil, false
}
}

return nil, false
}

// parseColumns extracts the header and list fields from the listConfig
func parseColumns(listConfig schema.ListConfig) ([]string, []string, error) {
header := make([]string, 0)
listFields := make([]string, 0)
re := regexp.MustCompile(`\{\{\s*(.*?)\s*\}\}`)

for _, col := range listConfig.Columns {
if col.Value == "" {
return nil, nil, fmt.Errorf("empty value for column name %s", col.Name)
}
header = append(header, col.Name)
match := re.FindStringSubmatch(col.Value)
if len(match) > 1 {
listFields = append(listFields, match[1])
} else {
return nil, nil, fmt.Errorf("invalid value format for column name %s", col.Name)
}
}
return header, listFields, nil
}
pkbhowmick marked this conversation as resolved.
Show resolved Hide resolved

// collectComponents gathers components for the specified stack or all stacks
func collectComponents(stackFlag string, stacksMap map[string]any, listFields []string) ([][]string, error) {
components := [][]string{}

if stackFlag != "" {
// Filter components for the specified stack
if stackData, ok := stacksMap[stackFlag]; ok {
stackComponents, err := getStackComponents(stackData)
stackComponents, err := getStackComponents(stackData, listFields)
if err != nil {
return "", fmt.Errorf("error processing stack '%s': %w", stackFlag, err)
return nil, fmt.Errorf("error processing stack '%s': %w", stackFlag, err)
}
for _, c := range stackComponents {
components = append(components, strings.Fields(c))
}
components = append(components, stackComponents...)
} else {
return "", fmt.Errorf("stack '%s' not found", stackFlag)
return nil, fmt.Errorf("stack '%s' not found", stackFlag)
}
} else {
// Get all components from all stacks
// Collect components from all stacks
var errors []string
for _, stackData := range stacksMap {
stackComponents, err := getStackComponents(stackData)
stackComponents, err := getStackComponents(stackData, listFields)
if err != nil {
errors = append(errors, err.Error())
continue // Skip invalid stacks
}
components = append(components, stackComponents...)
for _, c := range stackComponents {
components = append(components, strings.Fields(c))
}
}
if len(errors) > 0 {
return components, fmt.Errorf("errors processing stacks: %s", strings.Join(errors, "; "))
}
}
return components, nil
}

// processComponents deduplicates, sorts, and calculates column widths
func processComponents(header []string, components [][]string) ([][]string, []int) {
uniqueComponents := lo.UniqBy(components, func(item []string) string {
return strings.Join(item, "\t")
})
sort.Slice(uniqueComponents, func(i, j int) bool {
return strings.Join(uniqueComponents[i], "\t") < strings.Join(uniqueComponents[j], "\t")
})

colWidths := make([]int, len(header))
for i, h := range header {
colWidths[i] = len(h)
}
for _, row := range uniqueComponents {
for i, field := range row {
if len(field) > colWidths[i] {
colWidths[i] = len(field)
}
}
}

// Remove duplicates and sort components
components = lo.Uniq(components)
sort.Strings(components)
return uniqueComponents, colWidths
}

func generateTable(data tableData) {
// Style definitions
purple := lipgloss.Color("5")
headerStyle := lipgloss.NewStyle().
Foreground(purple).
Bold(true).
Align(lipgloss.Center)
cellStyle := lipgloss.NewStyle().
Padding(0, 1)
borderStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(purple).
Padding(0)

// Calculate the maximum width for each column
colWidths := make([]int, len(data.header))
for i, header := range data.header {
colWidths[i] = len(header)
}
for _, row := range data.rows {
for i, cell := range row {
if len(cell) > colWidths[i] {
colWidths[i] = len(cell)
}
}
}

// Standardize column widths across all rows
for i, width := range colWidths {
colWidths[i] = width + 2 // Add padding to each column
}

// Build formatted table rows
var tableRows []string

// Add the header
headerRow := formatRow(data.header, colWidths, headerStyle)
tableRows = append(tableRows, headerRow)

// Add a separator
separator := createSeparator(colWidths)
tableRows = append(tableRows, separator)

// Add data rows
for _, row := range data.rows {
formattedRow := formatRow(row, colWidths, cellStyle)
tableRows = append(tableRows, formattedRow)
}

if len(components) == 0 {
// Combine rows into a single string
table := strings.Join(tableRows, "\n")

// Apply border and print the table
fmt.Println(borderStyle.Render(table))
}

// formatRow creates a single formatted row
func formatRow(row []string, colWidths []int, style lipgloss.Style) string {
formattedCells := make([]string, len(row))
for i, cell := range row {
formattedCells[i] = style.Width(colWidths[i]).Render(cell)
}
return strings.Join(formattedCells, "│")
}

// createSeparator generates a horizontal separator for the table
func createSeparator(colWidths []int) string {
segments := make([]string, len(colWidths))
for i, width := range colWidths {
segments[i] = strings.Repeat("─", width)
}
return strings.Join(segments, "┼")
}

// FilterAndListComponents orchestrates the process
func FilterAndListComponents(stackFlag string, stacksMap map[string]any, listConfig schema.ListConfig) (string, error) {
// Step 1: Parse columns
header, listFields, err := parseColumns(listConfig)
if err != nil {
return "", err
}

// Step 2: Collect components
components, err := collectComponents(stackFlag, stacksMap, listFields)
if err != nil {
return "", err
}

// Step 3: Process components
processedComponents, colWidths := processComponents(header, components)
if len(processedComponents) == 0 {
return "No components found", nil
}
return strings.Join(components, "\n") + "\n", nil

// Step 4: Format and display table
data := tableData{
header: header,
rows: processedComponents,
colWidths: colWidths,
}
generateTable(data)

return "", nil
}
9 changes: 8 additions & 1 deletion pkg/list/list_components_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ func TestListComponents(t *testing.T) {
nil, false, false, false)
assert.Nil(t, err)

output, err := FilterAndListComponents("", stacksMap)
listConfig := schema.ListConfig{
Columns: []schema.ListColumnConfig{
{Name: "Component", Value: "{{ .atmos_component }}"},
{Name: "Stack", Value: "{{ .atmos_stack }}"},
{Name: "Folder", Value: "{{ .vars.tenant }}"},
},
}
output, err := FilterAndListComponents("", stacksMap, listConfig)
assert.Nil(t, err)
dependentsYaml, err := u.ConvertToYAML(output)
assert.Nil(t, err)
Expand Down
14 changes: 12 additions & 2 deletions pkg/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,18 @@ type Helmfile struct {
}

type Components struct {
Terraform Terraform `yaml:"terraform" json:"terraform" mapstructure:"terraform"`
Helmfile Helmfile `yaml:"helmfile" json:"helmfile" mapstructure:"helmfile"`
Terraform Terraform `yaml:"terraform" json:"terraform" mapstructure:"terraform"`
Helmfile Helmfile `yaml:"helmfile" json:"helmfile" mapstructure:"helmfile"`
List ListConfig `yaml:"list" json:"list" mapstructure:"list"`
}

type ListConfig struct {
Columns []ListColumnConfig `yaml:"columns" json:"columns" mapstructure:"columns"`
}
pkbhowmick marked this conversation as resolved.
Show resolved Hide resolved

type ListColumnConfig struct {
Name string `yaml:"name" json:"name" mapstructure:"name"`
Value string `yaml:"value" json:"value" mapstructure:"value"`
}

type Stacks struct {
Expand Down
Loading