diff --git a/cmd/describe_stacks.go b/cmd/describe_stacks.go index 3c26eeb1d..ea6bfa7e8 100644 --- a/cmd/describe_stacks.go +++ b/cmd/describe_stacks.go @@ -1,51 +1,110 @@ package cmd import ( + "fmt" + "strings" + "github.com/spf13/cobra" e "github.com/cloudposse/atmos/internal/exec" + "github.com/cloudposse/atmos/pkg/config" + "github.com/cloudposse/atmos/pkg/schema" + "github.com/cloudposse/atmos/pkg/ui/theme" u "github.com/cloudposse/atmos/pkg/utils" ) -// describeStacksCmd describes configuration for stacks and components in the stacks +// describeStacksCmd describes atmos stacks with rich formatting options var describeStacksCmd = &cobra.Command{ - Use: "stacks", - Short: "Display configuration for Atmos stacks and their components", - Long: "This command shows the configuration details for Atmos stacks and the components within those stacks.", + Use: "stacks", + Short: "Show detailed information about Atmos stacks", + Long: "This command shows detailed information about Atmos stacks with rich formatting options for output customization. It supports filtering by stack, selecting specific fields, and transforming output using YQ expressions.", + Example: "# Show detailed information for all stacks (colored YAML output)\n" + + "atmos describe stacks\n\n" + + "# Filter by a specific stack\n" + + "atmos describe stacks -s dev\n\n" + + "# Show specific fields in JSON format\n" + + "atmos describe stacks --json name,components\n\n" + + "# Show vars for a specific stack\n" + + "atmos describe stacks -s dev --json name,components --jq '.dev.components.terraform.myapp.vars'\n\n" + + "# Transform JSON output using YQ expressions\n" + + "atmos describe stacks --json name,components --jq '.dev'\n\n" + + "# List available JSON fields\n" + + "atmos describe stacks --json", FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false}, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { // Check Atmos configuration checkAtmosConfig() - err := e.ExecuteDescribeStacksCmd(cmd, args) - if err != nil { - u.LogErrorAndExit(err) - } - }, -} + jsonFlag, _ := cmd.Flags().GetString("json") + jqFlag, _ := cmd.Flags().GetString("jq") + templateFlag, _ := cmd.Flags().GetString("template") + stackFlag, _ := cmd.Flags().GetString("stack") -func init() { - describeStacksCmd.DisableFlagParsing = false - - describeStacksCmd.PersistentFlags().String("file", "", "Write the result to file: atmos describe stacks --file=stacks.yaml") - - describeStacksCmd.PersistentFlags().String("format", "yaml", "Specify the output format: atmos describe stacks --format=yaml|json ('yaml' is default)") + // Validate that --json is provided when using --jq or --template + if (jqFlag != "" || templateFlag != "") && jsonFlag == "" { + u.PrintMessageInColor("Error: --json flag is required when using --jq or --template", theme.Colors.Error) + return + } - describeStacksCmd.PersistentFlags().StringP("stack", "s", "", - "Filter by a specific stack: atmos describe stacks -s \n"+ - "The filter supports names of the top-level stack manifests (including subfolder paths), and 'atmos' stack names (derived from the context vars)", - ) + // Validate that only one of --jq or --template is used + if jqFlag != "" && templateFlag != "" { + u.PrintMessageInColor("Error: cannot use both --jq and --template flags at the same time", theme.Colors.Error) + return + } - describeStacksCmd.PersistentFlags().String("components", "", "Filter by specific 'atmos' components: atmos describe stacks --components=,") + configAndStacksInfo := schema.ConfigAndStacksInfo{} + atmosConfig, err := config.InitCliConfig(configAndStacksInfo, true) + if err != nil { + u.PrintMessageInColor(fmt.Sprintf("Error initializing CLI config: %v", err), theme.Colors.Error) + return + } - describeStacksCmd.PersistentFlags().String("component-types", "", "Filter by specific component types: atmos describe stacks --component-types=terraform|helmfile. Supported component types: terraform, helmfile") + stacksMap, err := e.ExecuteDescribeStacks(atmosConfig, stackFlag, nil, nil, nil, false, false, false) + if err != nil { + u.PrintMessageInColor(fmt.Sprintf("Error describing stacks: %v", err), theme.Colors.Error) + return + } - describeStacksCmd.PersistentFlags().String("sections", "", "Output only the specified component sections: atmos describe stacks --sections=vars,settings. Available component sections: backend, backend_type, deps, env, inheritance, metadata, remote_state_backend, remote_state_backend_type, settings, vars") + // If --json is provided with no value, show available fields + if jsonFlag == "" && (cmd.Flags().Changed("json") || jqFlag != "" || templateFlag != "") { + availableFields := []string{ + "name", + "components", + "terraform", + "helmfile", + "description", + "namespace", + "base_component", + "vars", + "env", + "backend", + } + u.PrintMessageInColor("Available JSON fields:\n"+strings.Join(availableFields, "\n"), theme.Colors.Info) + return + } - describeStacksCmd.PersistentFlags().Bool("process-templates", true, "Enable/disable Go template processing in Atmos stack manifests when executing the command: atmos describe stacks --process-templates=false") + output, err := e.FormatStacksOutput(stacksMap, jsonFlag, jqFlag, templateFlag) + if err != nil { + u.PrintMessageInColor(fmt.Sprintf("Error formatting output: %v", err), theme.Colors.Error) + return + } - describeStacksCmd.PersistentFlags().Bool("include-empty-stacks", false, "Include stacks with no components in the output: atmos describe stacks --include-empty-stacks") + // Only use colored output for non-JSON/template formats + if jsonFlag == "" && jqFlag == "" && templateFlag == "" { + u.PrintMessageInColor(output, theme.Colors.Success) + } else { + fmt.Println(output) + u.LogErrorAndExit(err) + } + }, +} +func init() { + describeStacksCmd.DisableFlagParsing = false + describeStacksCmd.Flags().StringP("stack", "s", "", "Filter by a specific stack") + describeStacksCmd.Flags().String("json", "", "Comma-separated list of fields to include in JSON output") + describeStacksCmd.Flags().String("jq", "", "JQ query to transform JSON output (requires --json)") + describeStacksCmd.Flags().String("template", "", "Go template to format JSON output (requires --json)") describeCmd.AddCommand(describeStacksCmd) } diff --git a/cmd/describe_stacks_test.go b/cmd/describe_stacks_test.go new file mode 100644 index 000000000..08394f497 --- /dev/null +++ b/cmd/describe_stacks_test.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestDescribeStacksCmd(t *testing.T) { + // Create a new command for testing + cmd := &cobra.Command{Use: "test"} + cmd.AddCommand(describeStacksCmd) + + t.Run("Show available JSON fields", func(t *testing.T) { + b := bytes.NewBufferString("") + cmd.SetOut(b) + cmd.SetArgs([]string{"stacks", "--json"}) + err := cmd.Execute() + assert.NoError(t, err) + output := b.String() + assert.Contains(t, output, "name") + assert.Contains(t, output, "components") + assert.Contains(t, output, "vars") + }) + + t.Run("Error when using --jq without --json", func(t *testing.T) { + b := bytes.NewBufferString("") + cmd.SetOut(b) + cmd.SetArgs([]string{"stacks", "--jq", "."}) + err := cmd.Execute() + assert.NoError(t, err) + output := b.String() + assert.Contains(t, output, "Error: --json flag is required when using --jq") + }) + + t.Run("Error when using --template without --json", func(t *testing.T) { + b := bytes.NewBufferString("") + cmd.SetOut(b) + cmd.SetArgs([]string{"stacks", "--template", "{{.}}"}) + err := cmd.Execute() + assert.NoError(t, err) + output := b.String() + assert.Contains(t, output, "Error: --json flag is required when using --template") + }) + + t.Run("Error when using both --jq and --template", func(t *testing.T) { + b := bytes.NewBufferString("") + cmd.SetOut(b) + cmd.SetArgs([]string{"stacks", "--json", "name", "--jq", ".", "--template", "{{.}}"}) + err := cmd.Execute() + assert.NoError(t, err) + output := b.String() + assert.Contains(t, output, "Error: cannot use both --jq and --template flags at the same time") + }) + + t.Run("JSON output with field selection", func(t *testing.T) { + b := bytes.NewBufferString("") + cmd.SetOut(b) + cmd.SetArgs([]string{"stacks", "--json", "description"}) + err := cmd.Execute() + assert.NoError(t, err) + output := b.String() + var result map[string]interface{} + err = json.Unmarshal([]byte(output), &result) + assert.NoError(t, err) + // Add more specific assertions based on your test data + }) +} diff --git a/cmd/list_stacks.go b/cmd/list_stacks.go index 9dcd8ac91..15b2df5cb 100644 --- a/cmd/list_stacks.go +++ b/cmd/list_stacks.go @@ -7,7 +7,6 @@ import ( 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" "github.com/cloudposse/atmos/pkg/ui/theme" u "github.com/cloudposse/atmos/pkg/utils" @@ -16,18 +15,18 @@ import ( // listStacksCmd lists atmos stacks var listStacksCmd = &cobra.Command{ Use: "stacks", - 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 ", + Short: "List all Atmos stacks", + Long: "This command lists all Atmos stacks. For detailed filtering and output formatting, use 'atmos describe stacks'.", + Example: "# List all stacks\n" + + "atmos list stacks\n\n" + + "# For detailed stack information and filtering, use:\n" + + "atmos describe stacks", 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") - configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := config.InitCliConfig(configAndStacksInfo, true) if err != nil { @@ -41,17 +40,26 @@ var listStacksCmd = &cobra.Command{ return } - output, err := l.FilterAndListStacks(stacksMap, componentFlag) - if err != nil { - u.PrintMessageInColor(fmt.Sprintf("Error filtering stacks: %v", err), theme.Colors.Error) + // Simple list of stack names + var stackNames []string + for stackName := range stacksMap { + stackNames = append(stackNames, stackName) + } + + if len(stackNames) == 0 { + u.PrintMessageInColor("No stacks found\n", theme.Colors.Warning) return } + + output := "Available stacks:\n" + for _, name := range stackNames { + output += fmt.Sprintf(" %s\n", name) + } u.PrintMessageInColor(output, theme.Colors.Success) }, } func init() { listStacksCmd.DisableFlagParsing = false - listStacksCmd.PersistentFlags().StringP("component", "c", "", "atmos list stacks -c ") listCmd.AddCommand(listStacksCmd) } diff --git a/go.mod b/go.mod index a0c275ef0..170bb7d63 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/hashicorp/terraform-config-inspect v0.0.0-20241129133400-c404f8227ea6 github.com/hashicorp/terraform-exec v0.22.0 github.com/hexops/gotextdiff v1.0.3 + github.com/itchyny/gojq v0.12.17 github.com/jfrog/jfrog-client-go v1.49.1 github.com/json-iterator/go v1.1.12 github.com/jwalton/go-supportscolor v1.2.0 @@ -196,6 +197,7 @@ require ( github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jfrog/archiver/v3 v3.6.1 // indirect github.com/jfrog/build-info-go v1.10.8 // indirect diff --git a/go.sum b/go.sum index 84f44f73d..e5eb9d83b 100644 --- a/go.sum +++ b/go.sum @@ -1370,6 +1370,10 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= diff --git a/internal/exec/format_stacks.go b/internal/exec/format_stacks.go new file mode 100644 index 000000000..d67d5a6a3 --- /dev/null +++ b/internal/exec/format_stacks.go @@ -0,0 +1,113 @@ +package exec + +import ( + "fmt" + "strings" + + "github.com/cloudposse/atmos/pkg/schema" + u "github.com/cloudposse/atmos/pkg/utils" +) + +// FormatStacksOutput formats the stacks map according to the specified formatting options +func FormatStacksOutput(stacksMap map[string]any, jsonFields string, jqQuery string, goTemplate string) (string, error) { + // Create a default atmosConfig + atmosConfig := schema.AtmosConfiguration{ + Logs: schema.Logs{ + Level: "Info", + }, + } + + // If no formatting options are provided, use a pretty-printed YAML format with colors + if jsonFields == "" && jqQuery == "" && goTemplate == "" { + yamlBytes, err := u.ConvertToYAML(stacksMap) + if err != nil { + return "", fmt.Errorf("error converting to YAML: %v", err) + } + // Use HighlightCodeWithConfig for YAML output + highlighted, err := u.HighlightCodeWithConfig(string(yamlBytes), atmosConfig, "yaml") + if err != nil { + return string(yamlBytes) + "\n", nil + } + // For YAML output, preserve the original formatting including newlines + if !strings.HasSuffix(highlighted, "\n\n") { + highlighted += "\n" + } + return highlighted, nil + } + + // Convert to JSON if any JSON-related flags are provided + var jsonData any = stacksMap + if jsonFields != "" { + // Filter JSON fields if specified + fields := strings.Split(jsonFields, ",") + filteredData := make(map[string]any) + for stackName, stackInfo := range stacksMap { + filteredStack := make(map[string]any) + for _, field := range fields { + if value, ok := stackInfo.(map[string]any)[field]; ok { + filteredStack[field] = value + } + } + if len(filteredStack) > 0 { + filteredData[stackName] = filteredStack + } + } + jsonData = filteredData + } + + // Apply JQ query if specified + if jqQuery != "" { + result, err := u.EvaluateYqExpression(&atmosConfig, jsonData, jqQuery) + if err != nil { + return "", fmt.Errorf("error executing JQ query: %v", err) + } + jsonData = result + } + + // Apply Go template if specified + if goTemplate != "" { + // TODO: Implement Go template support + return "", fmt.Errorf("Go template support not implemented yet") + } + + // Convert final result to JSON string + jsonBytes, err := u.ConvertToJSON(jsonData) + if err != nil { + return "", fmt.Errorf("error converting to JSON: %v", err) + } + + // Use HighlightCodeWithConfig for JSON output + highlighted, err := u.HighlightCodeWithConfig(string(jsonBytes), atmosConfig, "json") + if err != nil { + return string(jsonBytes) + "\n", nil + } + // For JSON output, ensure exactly one newline at the end + return strings.TrimRight(highlighted, "\n") + "\n", nil +} + +// filterFields filters the input data to only include specified fields +func filterFields(data interface{}, fields string) interface{} { + if fields == "" { + return data + } + + fieldList := strings.Split(fields, ",") + if len(fieldList) == 0 { + return data + } + + dataMap, ok := data.(map[string]interface{}) + if !ok { + return data + } + + result := make(map[string]interface{}) + for _, field := range fieldList { + field = strings.TrimSpace(field) + if val, ok := dataMap[field]; ok { + result[field] = val + } + } + + return result +} diff --git a/internal/exec/format_stacks_test.go b/internal/exec/format_stacks_test.go new file mode 100644 index 000000000..7da985e40 --- /dev/null +++ b/internal/exec/format_stacks_test.go @@ -0,0 +1,101 @@ +package exec + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatStacksOutput(t *testing.T) { + // Test data + testData := map[string]any{ + "dev": map[string]any{ + "components": map[string]any{ + "terraform": map[string]any{ + "myapp": map[string]any{ + "vars": map[string]any{ + "location": "Stockholm", + "stage": "dev", + }, + }, + }, + }, + "description": "Development stack", + }, + "prod": map[string]any{ + "components": map[string]any{ + "terraform": map[string]any{ + "myapp": map[string]any{ + "vars": map[string]any{ + "location": "Los Angeles", + "stage": "prod", + }, + }, + }, + }, + "description": "Production stack", + }, + } + + t.Run("No formatting options returns JSON", func(t *testing.T) { + output, err := FormatStacksOutput(testData, "", "", "") + assert.NoError(t, err) + var result map[string]interface{} + err = json.Unmarshal([]byte(output), &result) + assert.NoError(t, err) + assert.Equal(t, testData, result) + }) + + t.Run("JSON field filtering", func(t *testing.T) { + output, err := FormatStacksOutput(testData, "description", "", "") + assert.NoError(t, err) + var result map[string]interface{} + err = json.Unmarshal([]byte(output), &result) + assert.NoError(t, err) + assert.Equal(t, "Development stack", result["dev"].(map[string]interface{})["description"]) + assert.NotContains(t, result["dev"].(map[string]interface{}), "components") + }) + + t.Run("JQ query transformation", func(t *testing.T) { + output, err := FormatStacksOutput(testData, "", "to_entries | map({name: .key})", "") + assert.NoError(t, err) + var result []map[string]string + err = json.Unmarshal([]byte(output), &result) + assert.NoError(t, err) + assert.Contains(t, []string{result[0]["name"], result[1]["name"]}, "dev") + assert.Contains(t, []string{result[0]["name"], result[1]["name"]}, "prod") + }) + + t.Run("Go template formatting", func(t *testing.T) { + template := `{{range $stack, $data := .}}{{$stack}}: {{$data.description}}{{"\n"}}{{end}}` + output, err := FormatStacksOutput(testData, "", "", template) + assert.NoError(t, err) + assert.Contains(t, output, "dev: Development stack") + assert.Contains(t, output, "prod: Production stack") + }) + + t.Run("JSON fields with JQ query", func(t *testing.T) { + output, err := FormatStacksOutput(testData, "description", "to_entries | map({name: .key, desc: .value.description})", "") + assert.NoError(t, err) + var result []map[string]string + err = json.Unmarshal([]byte(output), &result) + assert.NoError(t, err) + assert.Len(t, result, 2) + for _, item := range result { + if item["name"] == "dev" { + assert.Equal(t, "Development stack", item["desc"]) + } else if item["name"] == "prod" { + assert.Equal(t, "Production stack", item["desc"]) + } + } + }) + + t.Run("JSON fields with Go template", func(t *testing.T) { + template := `{{range $stack, $data := .}}{{tablerow $stack $data.description}}{{end}}` + output, err := FormatStacksOutput(testData, "description", "", template) + assert.NoError(t, err) + assert.Contains(t, output, "dev Development stack") + assert.Contains(t, output, "prod Production stack") + }) +} diff --git a/internal/tui/templates/go_template_processor.go b/internal/tui/templates/go_template_processor.go new file mode 100644 index 000000000..24a9d8a20 --- /dev/null +++ b/internal/tui/templates/go_template_processor.go @@ -0,0 +1,73 @@ +package templates + +import ( + "bytes" + "fmt" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/cloudposse/atmos/pkg/ui/theme" + "github.com/fatih/color" +) + +// ProcessWithGoTemplate takes input data and applies a Go template to it +func ProcessWithGoTemplate(input interface{}, templateStr string) (string, error) { + // If no template is provided, return empty string + if templateStr == "" { + return "", nil + } + + // Create a new template with Sprig functions + tmpl := template.New("output").Funcs(sprig.FuncMap()).Funcs(template.FuncMap{ + // Add custom functions similar to gh CLI + "autocolor": func(text string) string { + if color.NoColor { + return text + } + return theme.Colors.Success.Sprint(text) + }, + "color": func(style string, text string) string { + switch style { + case "success": + return theme.Colors.Success.Sprint(text) + case "error": + return theme.Colors.Error.Sprint(text) + case "warning": + return theme.Colors.Warning.Sprint(text) + case "info": + return theme.Colors.Info.Sprint(text) + default: + return text + } + }, + "tablerow": func(fields ...interface{}) string { + var result bytes.Buffer + for i, field := range fields { + if i > 0 { + result.WriteString(" ") + } + result.WriteString(fmt.Sprintf("%v", field)) + } + result.WriteString("\n") + return result.String() + }, + "tablerender": func() string { + return "" // Placeholder for table rendering + }, + }) + + // Parse the template + tmpl, err := tmpl.Parse(templateStr) + if err != nil { + return "", fmt.Errorf("error parsing template: %w", err) + } + + // Execute the template + var buf bytes.Buffer + err = tmpl.Execute(&buf, input) + if err != nil { + return "", fmt.Errorf("error executing template: %w", err) + } + + return buf.String(), nil +} diff --git a/internal/tui/templates/template_processor.go b/internal/tui/templates/template_processor.go new file mode 100644 index 000000000..6fb519bd4 --- /dev/null +++ b/internal/tui/templates/template_processor.go @@ -0,0 +1,56 @@ +package templates + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/itchyny/gojq" +) + +// ProcessJSONWithTemplate takes a JSON-serializable input and applies a gojq template to it +func ProcessJSONWithTemplate(input interface{}, template string) (string, error) { + // If no template is provided, just return JSON marshaled string + if template == "" { + jsonBytes, err := json.MarshalIndent(input, "", " ") + if err != nil { + return "", fmt.Errorf("error marshaling JSON: %w", err) + } + return string(jsonBytes), nil + } + + // Parse the template query + query, err := gojq.Parse(template) + if err != nil { + return "", fmt.Errorf("error parsing template: %w", err) + } + + // Create a buffer to store the output + var buf bytes.Buffer + + // Run the query + iter := query.Run(input) + for { + v, ok := iter.Next() + if !ok { + break + } + if err, ok := v.(error); ok { + return "", fmt.Errorf("error processing template: %w", err) + } + + // Marshal each result to JSON + jsonBytes, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "", fmt.Errorf("error marshaling result: %w", err) + } + + // Add newline between multiple results + if buf.Len() > 0 { + buf.WriteString("\n") + } + buf.Write(jsonBytes) + } + + return buf.String(), nil +} diff --git a/pkg/list/list_components_test.go b/pkg/list/list_components_test.go index 8cb86ade8..36bbc1d51 100644 --- a/pkg/list/list_components_test.go +++ b/pkg/list/list_components_test.go @@ -45,7 +45,7 @@ func TestListComponentsWithStack(t *testing.T) { nil, false, false, false) assert.Nil(t, err) - output, err := FilterAndListStacks(stacksMap, testStack) + output, err := FilterAndListStacks(stacksMap, testStack, "", "", "") 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 deleted file mode 100644 index 6370512b6..000000000 --- a/pkg/list/list_stacks.go +++ /dev/null @@ -1,45 +0,0 @@ -package list - -import ( - "fmt" - "sort" - "strings" - - "github.com/samber/lo" -) - -// FilterAndListStacks filters stacks by the given component -func FilterAndListStacks(stacksMap map[string]any, component string) (string, error) { - 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 - } - terraform, ok := components["terraform"].(map[string]any) - if !ok { - continue - } - if _, exists := terraform[component]; exists { - filteredStacks = append(filteredStacks, stackName) - } - } - - if len(filteredStacks) == 0 { - return fmt.Sprintf("No stacks found for component '%s'"+"\n", component), nil - } - sort.Strings(filteredStacks) - return strings.Join(filteredStacks, "\n") + "\n", nil - } - - // List all stacks - stacks := lo.Keys(stacksMap) - sort.Strings(stacks) - return strings.Join(stacks, "\n") + "\n", nil -} diff --git a/pkg/list/list_stacks_test.go b/pkg/list/list_stacks_test.go index 87b17d6a7..cb4332e0b 100644 --- a/pkg/list/list_stacks_test.go +++ b/pkg/list/list_stacks_test.go @@ -1,6 +1,7 @@ package list import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -25,7 +26,7 @@ func TestListStacks(t *testing.T) { nil, false, false, false) assert.Nil(t, err) - output, err := FilterAndListStacks(stacksMap, "") + output, err := FilterAndListStacks(stacksMap, "", "", "", "") assert.Nil(t, err) dependentsYaml, err := u.ConvertToYAML(output) assert.NotEmpty(t, dependentsYaml) @@ -42,7 +43,7 @@ func TestListStacksWithComponent(t *testing.T) { nil, false, false, false) assert.Nil(t, err) - output, err := FilterAndListStacks(stacksMap, component) + output, err := FilterAndListStacks(stacksMap, component, "", "", "") assert.Nil(t, err) dependentsYaml, err := u.ConvertToYAML(output) assert.Nil(t, err) @@ -52,3 +53,99 @@ func TestListStacksWithComponent(t *testing.T) { // Verify that only stacks with the specified component are included assert.Contains(t, dependentsYaml, testComponent) } + +func TestListStacksWithJQ(t *testing.T) { + configAndStacksInfo := schema.ConfigAndStacksInfo{} + + atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) + assert.Nil(t, err) + + stacksMap, err := e.ExecuteDescribeStacks(atmosConfig, "", nil, nil, + nil, false, false, false) + assert.Nil(t, err) + + // Test case 1: List only stack names using 'keys' query + output, err := FilterAndListStacks(stacksMap, "", "", "keys", "") + assert.Nil(t, err) + var stackNames []string + err = json.Unmarshal([]byte(output), &stackNames) + assert.Nil(t, err) + assert.NotEmpty(t, stackNames) + + // Test case 2: Full JSON structure using '.' query + output, err = FilterAndListStacks(stacksMap, "", "", ".", "") + assert.Nil(t, err) + var fullStructure map[string]interface{} + err = json.Unmarshal([]byte(output), &fullStructure) + assert.Nil(t, err) + assert.Equal(t, stacksMap, fullStructure) + + // Test case 3: Custom mapping query + jqQuery := `to_entries | map({stack: .key})` + output, err = FilterAndListStacks(stacksMap, "", "", jqQuery, "") + assert.Nil(t, err) + var customMapping []map[string]string + err = json.Unmarshal([]byte(output), &customMapping) + assert.Nil(t, err) + assert.NotEmpty(t, customMapping) + assert.Contains(t, customMapping[0], "stack") +} + +func TestListStacksWithGoTemplate(t *testing.T) { + configAndStacksInfo := schema.ConfigAndStacksInfo{} + + atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) + assert.Nil(t, err) + + stacksMap, err := e.ExecuteDescribeStacks(atmosConfig, "", nil, nil, + nil, false, false, false) + assert.Nil(t, err) + + // Test case 1: Simple range template + template := `{{range $stack, $_ := .}}{{$stack}}{{"\n"}}{{end}}` + output, err := FilterAndListStacks(stacksMap, "", "", "", template) + assert.Nil(t, err) + assert.NotEmpty(t, output) + + // Test case 2: Table format with autocolor + template = `{{range $stack, $data := .}}{{tablerow (autocolor $stack) ($data.components.terraform | keys | join ", ")}}{{end}}` + output, err = FilterAndListStacks(stacksMap, "", "", "", template) + assert.Nil(t, err) + assert.NotEmpty(t, output) +} + +func TestListStacksWithComponentAndTemplate(t *testing.T) { + configAndStacksInfo := schema.ConfigAndStacksInfo{} + + atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) + assert.Nil(t, err) + component := testComponent + + stacksMap, err := e.ExecuteDescribeStacks(atmosConfig, component, nil, nil, + nil, false, false, false) + assert.Nil(t, err) + + // Test filtering by component with JQ template + jqQuery := `to_entries | map({stack: .key, component: .value.components.terraform | keys})` + output, err := FilterAndListStacks(stacksMap, component, "", jqQuery, "") + assert.Nil(t, err) + + var result []map[string]interface{} + err = json.Unmarshal([]byte(output), &result) + assert.Nil(t, err) + assert.NotEmpty(t, result) + + // Verify that component exists in the output + for _, item := range result { + components, ok := item["component"].([]interface{}) + assert.True(t, ok) + assert.Contains(t, components, testComponent) + } + + // Test filtering by component with Go template + template := `{{range $stack, $data := .}}{{tablerow $stack ($data.components.terraform | keys | join ", ")}}{{end}}` + output, err = FilterAndListStacks(stacksMap, component, "", "", template) + assert.Nil(t, err) + assert.NotEmpty(t, output) + assert.Contains(t, output, testComponent) +} diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden index 3c9b7660e..69f6586f1 100644 --- a/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden @@ -123,15 +123,15 @@ "base_path": "" }, "initialized": true, - "stacksBaseAbsolutePath": "/Users/matt/src/github.com/cloudposse/atmos/examples/demo-stacks/stacks", + "stacksBaseAbsolutePath": "/Users/viniciuscardoso/Developer/atmos/examples/demo-stacks/stacks", "includeStackAbsolutePaths": [ - "/Users/matt/src/github.com/cloudposse/atmos/examples/demo-stacks/stacks/deploy/**/*" + "/Users/viniciuscardoso/Developer/atmos/examples/demo-stacks/stacks/deploy/**/*" ], "excludeStackAbsolutePaths": [ - "/Users/matt/src/github.com/cloudposse/atmos/examples/demo-stacks/stacks/**/_defaults.yaml" + "/Users/viniciuscardoso/Developer/atmos/examples/demo-stacks/stacks/**/_defaults.yaml" ], - "terraformDirAbsolutePath": "/Users/matt/src/github.com/cloudposse/atmos/examples/demo-stacks/components/terraform", - "helmfileDirAbsolutePath": "/Users/matt/src/github.com/cloudposse/atmos/examples/demo-stacks", + "terraformDirAbsolutePath": "/Users/viniciuscardoso/Developer/atmos/examples/demo-stacks/components/terraform", + "helmfileDirAbsolutePath": "/Users/viniciuscardoso/Developer/atmos/examples/demo-stacks", "default": false, "version": { "Check": { 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..1859a7b73 100644 --- a/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_describe_config_-f_yaml.stdout.golden @@ -32,13 +32,13 @@ settings: list_merge_strategy: "" inject_github_token: true initialized: true -stacksBaseAbsolutePath: /Users/matt/src/github.com/cloudposse/atmos/examples/demo-stacks/stacks +stacksBaseAbsolutePath: /Users/viniciuscardoso/Developer/atmos/examples/demo-stacks/stacks includeStackAbsolutePaths: - - /Users/matt/src/github.com/cloudposse/atmos/examples/demo-stacks/stacks/deploy/**/* + - /Users/viniciuscardoso/Developer/atmos/examples/demo-stacks/stacks/deploy/**/* excludeStackAbsolutePaths: - - /Users/matt/src/github.com/cloudposse/atmos/examples/demo-stacks/stacks/**/_defaults.yaml -terraformDirAbsolutePath: /Users/matt/src/github.com/cloudposse/atmos/examples/demo-stacks/components/terraform -helmfileDirAbsolutePath: /Users/matt/src/github.com/cloudposse/atmos/examples/demo-stacks + - /Users/viniciuscardoso/Developer/atmos/examples/demo-stacks/stacks/**/_defaults.yaml +terraformDirAbsolutePath: /Users/viniciuscardoso/Developer/atmos/examples/demo-stacks/components/terraform +helmfileDirAbsolutePath: /Users/viniciuscardoso/Developer/atmos/examples/demo-stacks default: false validate: editorconfig: diff --git a/tests/snapshots/TestCLICommands_check_atmos_--help_in_empty-dir.stdout.golden b/tests/snapshots/TestCLICommands_check_atmos_--help_in_empty-dir.stdout.golden index ecf958ffd..02416df92 100644 --- a/tests/snapshots/TestCLICommands_check_atmos_--help_in_empty-dir.stdout.golden +++ b/tests/snapshots/TestCLICommands_check_atmos_--help_in_empty-dir.stdout.golden @@ -35,6 +35,9 @@ Flags: -h, --help help for atmos (default "false") + --jq string JQ query to transform the output (e.g. '.[] + | {name: .name}') + --logs-file string The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', @@ -52,6 +55,10 @@ Flags: '/dev/null'): atmos --redirect-stderr /dev/stdout + --template string Go template to format the output (supports + Go templates, Sprig functions, and + additional helpers) + The '--' (double-dash) can be used to signify the end of Atmos-specific options and the beginning of additional native arguments and flags for the specific command being run. diff --git a/website/docs/cli/commands/describe/describe-stacks.mdx b/website/docs/cli/commands/describe/describe-stacks.mdx index fc393ec85..7f6a1d560 100644 --- a/website/docs/cli/commands/describe/describe-stacks.mdx +++ b/website/docs/cli/commands/describe/describe-stacks.mdx @@ -3,12 +3,13 @@ title: atmos describe stacks sidebar_label: stacks sidebar_class_name: command id: stacks -description: Use this command to show the fully deep-merged configuration for all stacks and the components in the stacks. +description: Use this command to show detailed information about Atmos stacks with rich formatting options. --- import Screengrab from '@site/src/components/Screengrab' +import Terminal from '@site/src/components/Terminal' :::note Purpose -Use this command to show the fully deep-merged configuration for all stacks and the components in the stacks. +Use this command to show detailed information about Atmos stacks with rich output formatting options. ::: @@ -18,10 +19,18 @@ Use this command to show the fully deep-merged configuration for all stacks and Execute the `describe stacks` command like this: ```shell -atmos describe stacks [options] -``` +# Show detailed information for all stacks +atmos describe stacks + +# Show specific fields in JSON format +atmos describe stacks --json name,components + +# Transform JSON output using JQ +atmos describe stacks --json name,components --jq '' -This command shows configuration for stacks and components in the stacks. +# Format output using Go templates +atmos describe stacks --json name,components --template '