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 11 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
8 changes: 8 additions & 0 deletions examples/quick-start-advanced/atmos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
base_path: "."

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

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

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

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

const (
HeaderColor = "#00BFFF"
RowColor = "#FFFFFF"
)

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 +40,197 @@ 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
}

// 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
}

// FilterAndListComponents filters and lists components based on the given stack
func FilterAndListComponents(stackFlag string, stacksMap map[string]any) (string, error) {
components := []string{}
// 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)
}
}
}

return uniqueComponents, colWidths
}

// formatTable generates the formatted table
func formatTable(data tableData) {
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(HeaderColor))
rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(RowColor))

// Format and print headers
headerRow := make([]string, len(data.header))
for i, h := range data.header {
headerRow[i] = headerStyle.Render(padToWidth(h, data.colWidths[i]))
}
fmt.Println(strings.Join(headerRow, " "))

// Format and print rows
for _, row := range data.rows {
formattedRow := make([]string, len(row))
for i, field := range row {
formattedRow[i] = rowStyle.Render(padToWidth(field, data.colWidths[i]))
}
fmt.Println(strings.Join(formattedRow, " "))
}
}

// padToWidth ensures a string is padded to the given width
func padToWidth(str string, width int) string {
for len(str) < width {
str += " "
}
return str
}

// 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
}

// Remove duplicates and sort components
components = lo.Uniq(components)
sort.Strings(components)
// Step 2: Collect components
components, err := collectComponents(stackFlag, stacksMap, listFields)
if err != nil {
return "", err
}

if len(components) == 0 {
// 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,
}
formatTable(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