diff --git a/commands/stack/list/list.go b/commands/stack/list/list.go index fe44c4b69..912b33557 100644 --- a/commands/stack/list/list.go +++ b/commands/stack/list/list.go @@ -6,14 +6,17 @@ package list import ( "context" + "encoding/json" "fmt" + "github.com/emicklei/dot" "github.com/terramate-io/terramate/cloud/api/status" "github.com/terramate-io/terramate/config" "github.com/terramate-io/terramate/engine" "github.com/terramate-io/terramate/errors" "github.com/terramate-io/terramate/printer" "github.com/terramate-io/terramate/run" + "github.com/terramate-io/terramate/run/dag" "github.com/terramate-io/terramate/stack" ) @@ -25,6 +28,8 @@ type Spec struct { Target string StatusFilters StatusFilters RunOrder bool + Format string + Label string Tags []string NoTags []string Printers printer.Printers @@ -37,15 +42,54 @@ type StatusFilters struct { DriftStatus string } +// StackInfo represents stack information for JSON output. +type StackInfo struct { + Dir string `json:"dir"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Tags []string `json:"tags"` + Dependencies []string `json:"dependencies"` + Reason string `json:"reason"` + IsChanged bool `json:"is_changed"` +} + +// stackMetadata holds processed stack information for output formatting. +type stackMetadata struct { + Stack *config.SortableStack + FriendlyDir string + Label string + Entry stack.Entry + AncestorIDs []dag.ID +} + // Name returns the name of the command. func (s *Spec) Name() string { return "list" } +// getLabel extracts the appropriate label from a stack based on the Label field. +func (s *Spec) getLabel(st *config.Stack, friendlyDir string) (string, error) { + switch s.Label { + case "stack.name": + return st.Name, nil + case "stack.id": + return st.ID, nil + case "stack.dir": + return friendlyDir, nil + default: + return "", errors.E(`--label expects the values "stack.name", "stack.id", or "stack.dir"`) + } +} + // Exec executes the list command. func (s *Spec) Exec(_ context.Context) error { if s.Reason && !s.GitFilter.IsChanged { return errors.E("the --why flag must be used together with --changed") } + if s.Format == "json" && s.Label == "stack.name" { + return errors.E("--format json cannot be used with --label stack.name (stack names are not guaranteed to be unique)") + } + err := s.Engine.CheckTargetsConfiguration(s.Target, "", func(isTargetSet bool) error { isStatusSet := s.StatusFilters.StackStatus != "" isDeploymentStatusSet := s.StatusFilters.DeploymentStatus != "" @@ -90,6 +134,14 @@ func (s *Spec) printStacksList(allStacks []stack.Entry) error { reasons[entry.Stack.ID] = entry.Reason } + if s.Format == "json" { + return s.printStacksListJSON(stacks, filteredStacks) + } + + if s.Format == "dot" { + return s.printStacksListDot(stacks, filteredStacks) + } + if s.RunOrder { var failReason string var err error @@ -108,11 +160,154 @@ func (s *Spec) printStacksList(allStacks []stack.Entry) error { printer.Stdout.Println(dir) continue } + + label, err := s.getLabel(st.Stack, friendlyDir) + if err != nil { + return err + } + if s.Reason { - printer.Stdout.Println(fmt.Sprintf("%s - %s", friendlyDir, reasons[st.ID])) + printer.Stdout.Println(fmt.Sprintf("%s - %s", label, reasons[st.ID])) } else { - printer.Stdout.Println(friendlyDir) + printer.Stdout.Println(label) + } + } + return nil +} + +// buildStackMetadata processes stacks and collects metadata to be formatted. +func (s *Spec) buildStackMetadata(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) (map[dag.ID]*stackMetadata, *dag.DAG[*config.SortableStack], error) { + // Create a map from stack ID to Entry for quick lookup + entryMap := make(map[string]stack.Entry) + for _, entry := range filteredStacks { + entryMap[entry.Stack.ID] = entry + } + + d, reason, err := run.BuildDAGFromStacks( + s.Engine.Config(), + stacks, + func(s *config.SortableStack) *config.Stack { return s.Stack }, + ) + if err != nil { + return nil, nil, errors.E(err, "Invalid stack configuration: "+reason) + } + + metadata := make(map[dag.ID]*stackMetadata) + + for _, id := range d.IDs() { + st, err := d.Node(id) + if err != nil { + return nil, nil, errors.E(err, "getting node from DAG") + } + + dir := st.Stack.Dir.String() + friendlyDir, ok := s.Engine.FriendlyFmtDir(dir) + if !ok { + return nil, nil, errors.E("unable to format stack dir %s", dir) + } + + label, err := s.getLabel(st.Stack, friendlyDir) + if err != nil { + return nil, nil, err + } + + ancestors := d.DirectAncestorsOf(id) + + entry, hasEntry := entryMap[st.Stack.ID] + if !hasEntry { + entry = stack.Entry{Stack: st.Stack} + } + + metadata[id] = &stackMetadata{ + Stack: st, + FriendlyDir: friendlyDir, + Label: label, + Entry: entry, + AncestorIDs: ancestors, + } + } + + return metadata, d, nil +} + +func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) error { + metadata, d, err := s.buildStackMetadata(stacks, filteredStacks) + if err != nil { + return err + } + + stackInfos := make(map[string]StackInfo) + + for _, id := range d.IDs() { + m := metadata[id] + + // Build dependency labels from ancestor IDs + deps := make([]string, 0, len(m.AncestorIDs)) + for _, ancestorID := range m.AncestorIDs { + ancestorMeta := metadata[ancestorID] + deps = append(deps, ancestorMeta.Label) + } + + tags := m.Stack.Stack.Tags + if tags == nil { + tags = []string{} + } + + info := StackInfo{ + Dir: m.FriendlyDir, + ID: m.Stack.Stack.ID, + Name: m.Stack.Stack.Name, + Description: m.Stack.Stack.Description, + Tags: tags, + Dependencies: deps, + Reason: m.Entry.Reason, + IsChanged: m.Stack.Stack.IsChanged, } + + stackInfos[m.Label] = info } + + jsonData, err := json.MarshalIndent(stackInfos, "", " ") + if err != nil { + return errors.E(err, "marshaling JSON") + } + + s.Printers.Stdout.Println(string(jsonData)) + return nil +} + +func (s *Spec) printStacksListDot(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) error { + metadata, d, err := s.buildStackMetadata(stacks, filteredStacks) + if err != nil { + return err + } + + dotGraph := dot.NewGraph(dot.Directed) + + for _, id := range d.IDs() { + m := metadata[id] + + descendant := dotGraph.Node(m.FriendlyDir) + if m.Label != m.FriendlyDir { + descendant.Attr("label", m.Label) + } + + for _, ancestorID := range m.AncestorIDs { + ancestorMeta := metadata[ancestorID] + + ancestorNode := dotGraph.Node(ancestorMeta.FriendlyDir) + if ancestorMeta.Label != ancestorMeta.FriendlyDir { + ancestorNode.Attr("label", ancestorMeta.Label) + } + + // check if edge already exists to avoid duplicates + edges := dotGraph.FindEdges(ancestorNode, descendant) + if len(edges) == 0 { + dotGraph.Edge(ancestorNode, descendant) + } + } + } + + s.Printers.Stdout.Println(dotGraph.String()) return nil } diff --git a/e2etests/core/list_json_test.go b/e2etests/core/list_json_test.go new file mode 100644 index 000000000..8863e14c3 --- /dev/null +++ b/e2etests/core/list_json_test.go @@ -0,0 +1,210 @@ +// Copyright 2024 Terramate GmbH +// SPDX-License-Identifier: MPL-2.0 + +package core_test + +import ( + "encoding/json" + "testing" + + . "github.com/terramate-io/terramate/e2etests/internal/runner" + "github.com/terramate-io/terramate/test/sandbox" +) + +func TestListJSON(t *testing.T) { + t.Parallel() + + type stackInfo struct { + Dir string `json:"dir"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Tags []string `json:"tags"` + Dependencies []string `json:"dependencies"` + Reason string `json:"reason"` + IsChanged bool `json:"is_changed"` + } + + type testcase struct { + name string + layout []string + want map[string][]string // map of stack dir -> dependencies + } + + for _, tcase := range []testcase{ + { + name: "simple dependency: stack1 after stack2", + layout: []string{ + `s:stack1:after=["/stack2"]`, + "s:stack2", + }, + want: map[string][]string{ + "stack1": {"stack2"}, + "stack2": {}, + }, + }, + { + name: "linear chain: A -> B -> C", + layout: []string{ + "s:A", + `s:B:after=["/A"]`, + `s:C:after=["/B"]`, + }, + want: map[string][]string{ + "A": {}, + "B": {"A"}, + "C": {"B"}, + }, + }, + { + name: "diamond dependency", + layout: []string{ + "s:A", + `s:B:after=["/A"]`, + `s:C:after=["/A"]`, + `s:D:after=["/B","/C"]`, + }, + want: map[string][]string{ + "A": {}, + "B": {"A"}, + "C": {"A"}, + "D": {"B", "C"}, + }, + }, + { + name: "fork-fork-join-join", + layout: []string{ + "s:A", + `s:B:after=["/A"]`, + `s:C:after=["/B"]`, + `s:D:after=["/C","/X"]`, + `s:E:after=["/A"]`, + `s:F:after=["/E"]`, + `s:G:after=["/F","/Y"]`, + `s:X:after=["/B"]`, + `s:Y:after=["/E"]`, + `s:Z:after=["/D","/G"]`, + }, + want: map[string][]string{ + "A": {}, + "B": {"A"}, + "C": {"B"}, + "D": {"C", "X"}, + "E": {"A"}, + "F": {"E"}, + "G": {"F", "Y"}, + "X": {"B"}, + "Y": {"E"}, + "Z": {"D", "G"}, + }, + }, + { + name: "no dependencies", + layout: []string{ + "s:stack1", + "s:stack2", + "s:stack3", + }, + want: map[string][]string{ + "stack1": {}, + "stack2": {}, + "stack3": {}, + }, + }, + { + name: "before statement: stack1 before stack2", + layout: []string{ + `s:stack1:before=["/stack2"]`, + "s:stack2", + }, + want: map[string][]string{ + "stack1": {}, + "stack2": {"stack1"}, + }, + }, + { + name: "mixed before and after statements", + layout: []string{ + `s:A:before=["/B"]`, + `s:B:after=["/C"]`, + "s:C", + }, + want: map[string][]string{ + "A": {}, + "B": {"A", "C"}, + "C": {}, + }, + }, + { + name: "complex before statements", + layout: []string{ + `s:A:before=["/B", "/C"]`, + "s:B", + "s:C", + `s:D:after=["/B", "/C"]`, + }, + want: map[string][]string{ + "A": {}, + "B": {"A"}, + "C": {"A"}, + "D": {"B", "C"}, + }, + }, + } { + tc := tcase + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := sandbox.New(t) + s.BuildTree(tc.layout) + + cli := NewCLI(t, s.RootDir()) + result := cli.Run("list", "--run-order", "--format=json") + + if result.Status != 0 { + t.Fatalf("command failed with status %d: %s", result.Status, result.Stderr) + } + + var got map[string]stackInfo + if err := json.Unmarshal([]byte(result.Stdout), &got); err != nil { + t.Fatalf("failed to unmarshal JSON output: %v\nOutput: %s", err, result.Stdout) + } + + if len(got) != len(tc.want) { + t.Fatalf("expected %d stacks, got %d", len(tc.want), len(got)) + } + + for stackDir, wantDeps := range tc.want { + info, ok := got[stackDir] + if !ok { + t.Fatalf("stack %q not found in output", stackDir) + } + + if info.Dir != stackDir { + t.Fatalf("stack dir mismatch: expected %q, got %q", stackDir, info.Dir) + } + + // convert to maps, order doesn't matter for dependencies + wantDepsMap := make(map[string]bool) + for _, dep := range wantDeps { + wantDepsMap[dep] = true + } + + gotDepsMap := make(map[string]bool) + for _, dep := range info.Dependencies { + gotDepsMap[dep] = true + } + + if len(wantDepsMap) != len(gotDepsMap) { + t.Fatalf("stack %q: expected %d dependencies, got %d", stackDir, len(wantDeps), len(info.Dependencies)) + } + + for dep := range wantDepsMap { + if !gotDepsMap[dep] { + t.Fatalf("stack %q: missing dependency %q", stackDir, dep) + } + } + } + }) + } +} diff --git a/run/dag/dag.go b/run/dag/dag.go index 7e9d26db5..269effec1 100644 --- a/run/dag/dag.go +++ b/run/dag/dag.go @@ -219,6 +219,70 @@ func (d *DAG[V]) AncestorsOf(id ID) []ID { return d.dag[id] } +// DirectAncestorsOf returns only the immediate ancestors of the given node +// by performing transitive reduction - removing ancestors reachable through other ancestors. +func (d *DAG[V]) DirectAncestorsOf(id ID) []ID { + ancestors := d.AncestorsOf(id) + if len(ancestors) <= 1 { + return ancestors // Already minimal + } + + ancestorSet := make(map[ID]bool, len(ancestors)) + for _, a := range ancestors { + ancestorSet[a] = true + } + + reachableAncestors := make(map[ID]map[ID]bool, len(ancestors)) + for _, ancestorID := range ancestors { + reachableAncestors[ancestorID] = d.findReachableAncestors(ancestorID, ancestorSet) + } + + var directAncestors []ID + for _, ancestorID := range ancestors { + isDirect := true + for _, otherID := range ancestors { + if ancestorID != otherID && reachableAncestors[otherID][ancestorID] { + isDirect = false + break + } + } + if isDirect { + directAncestors = append(directAncestors, ancestorID) + } + } + + return directAncestors +} + +// findReachableAncestors finds all ancestors reachable from 'from' that are in the targets set. +func (d *DAG[V]) findReachableAncestors(from ID, targets map[ID]bool) map[ID]bool { + reachable := make(map[ID]bool) + visited := make(map[ID]bool) + queue := make([]ID, 0, 16) // Pre-allocate reasonable capacity + queue = append(queue, from) + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + if visited[current] { + continue + } + visited[current] = true + + for _, ancestor := range d.AncestorsOf(current) { + if targets[ancestor] { + reachable[ancestor] = true + } + if !visited[ancestor] { + queue = append(queue, ancestor) + } + } + } + + return reachable +} + // HasCycle returns true if the DAG has a cycle. func (d *DAG[V]) HasCycle(id ID) bool { if !d.validated { diff --git a/test/sandbox/sandbox.go b/test/sandbox/sandbox.go index 1b6a530a9..4247f68a3 100644 --- a/test/sandbox/sandbox.go +++ b/test/sandbox/sandbox.go @@ -690,6 +690,8 @@ func buildTree(t testing.TB, root *config.Root, environ []string, layout []strin switch name { case "id": cfg.Stack.ID = value + case "name": + cfg.Stack.Name = value case "after": cfg.Stack.After = parseListSpec(t, name, value) case "before": diff --git a/ui/tui/cli_handler.go b/ui/tui/cli_handler.go index f28c99df4..86543377f 100644 --- a/ui/tui/cli_handler.go +++ b/ui/tui/cli_handler.go @@ -322,6 +322,8 @@ func DefaultAfterConfigHandler(ctx context.Context, c *CLI) (commands.Executor, tel.StringFlag("filter-deployment-status", parsedArgs.List.DeploymentStatus), tel.StringFlag("filter-target", parsedArgs.List.Target), tel.BoolFlag("run-order", parsedArgs.List.RunOrder), + tel.StringFlag("format", parsedArgs.List.Format), + tel.StringFlag("label", parsedArgs.List.Label), ) expStatus := parsedArgs.List.ExperimentalStatus cloudStatus := parsedArgs.List.Status @@ -355,8 +357,11 @@ func DefaultAfterConfigHandler(ctx context.Context, c *CLI) (commands.Executor, DriftStatus: parsedArgs.List.DriftStatus, }, RunOrder: parsedArgs.List.RunOrder, + Format: parsedArgs.List.Format, + Label: parsedArgs.List.Label, Tags: parsedArgs.Tags, NoTags: parsedArgs.NoTags, + Printers: c.printers, }, true, false, nil case "generate": diff --git a/ui/tui/cli_spec.go b/ui/tui/cli_spec.go index 8021c8dfc..531e46f0c 100644 --- a/ui/tui/cli_spec.go +++ b/ui/tui/cli_spec.go @@ -45,7 +45,9 @@ type FlagSpec struct { } `cmd:"" help:"Format configuration files."` List struct { - Why bool `help:"Shows the reason why the stack has changed."` + Why bool `help:"Shows the reason why the stack has changed."` + Format string `default:"text" enum:"text,json,dot" help:"Output format (text, json, or dot)"` + Label string `short:"l" default:"stack.dir" enum:"stack.id,stack.name,stack.dir" help:"Label used in output (stack.id, stack.name, or stack.dir)"` cloudFilterFlags Target string `help:"Select the deployment target of the filtered stacks."`