diff --git a/internal/command/graph.go b/internal/command/graph.go index f840a01b35..1761102599 100644 --- a/internal/command/graph.go +++ b/internal/command/graph.go @@ -5,8 +5,10 @@ package command import ( "fmt" + "sort" "strings" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/dag" @@ -115,11 +117,23 @@ func (c *GraphCommand) Run(args []string) int { } if graphTypeStr == "" { - switch { - case lr.Plan != nil: + if planFile == nil { + // Simple resource dependency mode: + // This is based on the plan graph but we then further reduce it down + // to just resource dependency relationships, assuming that in most + // cases the most important thing is what order we'll visit the + // resources in. + fullG, graphDiags := lr.Core.PlanGraphForUI(lr.Config, lr.InputState, plans.NormalMode) + diags = diags.Append(graphDiags) + if graphDiags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + g := fullG.ResourceGraph() + return c.resourceOnlyGraph(g) + } else { graphTypeStr = "apply" - default: - graphTypeStr = "plan" } } @@ -189,11 +203,103 @@ func (c *GraphCommand) Run(args []string) int { return 1 } - c.Ui.Output(graphStr) + _, err = c.Streams.Stdout.File.WriteString(graphStr) + if err != nil { + c.Ui.Error(fmt.Sprintf("Failed to write graph to stdout: %s", err)) + return 1 + } return 0 } +func (c *GraphCommand) resourceOnlyGraph(graph addrs.DirectedGraph[addrs.ConfigResource]) int { + out := c.Streams.Stdout.File + fmt.Fprintln(out, "digraph G {") + // Horizontal presentation is easier to read because our nodes tend + // to be much wider than they are tall. The leftmost nodes in the output + // are those Terraform would visit first. + fmt.Fprintln(out, " rankdir = \"RL\";") + fmt.Fprintln(out, " node [shape = rect, fontname = \"sans-serif\"];") + + // To help relate the output back to the configuration it came from, + // and to make the individual node labels more reasonably sized when + // deeply nested inside modules, we'll cluster the nodes together by + // the module they belong to and then show only the local resource + // address in the individual nodes. We'll accomplish that by sorting + // the nodes first by module, so we can then notice the transitions. + allAddrs := graph.AllNodes() + if len(allAddrs) == 0 { + fmt.Fprintln(out, " /* This configuration does not contain any resources. */") + fmt.Fprintln(out, " /* For a more detailed graph, try: terraform graph -type=plan */") + } + addrsOrder := make([]addrs.ConfigResource, 0, len(allAddrs)) + for _, addr := range allAddrs { + addrsOrder = append(addrsOrder, addr) + } + sort.Slice(addrsOrder, func(i, j int) bool { + iAddr, jAddr := addrsOrder[i], addrsOrder[j] + iModStr, jModStr := iAddr.Module.String(), jAddr.Module.String() + switch { + case iModStr != jModStr: + return iModStr < jModStr + default: + iRes, jRes := iAddr.Resource, jAddr.Resource + switch { + case iRes.Mode != jRes.Mode: + return iRes.Mode == addrs.DataResourceMode + case iRes.Type != jRes.Type: + return iRes.Type < jRes.Type + default: + return iRes.Name < jRes.Name + } + } + }) + + currentMod := addrs.RootModule + for _, addr := range addrsOrder { + if !addr.Module.Equal(currentMod) { + // We need a new subgraph, then. + // Experimentally it seems like nested clusters tend to make it + // hard for dot to converge on a good layout, so we'll stick with + // just one level of clusters for now but could revise later based + // on feedback. + if !currentMod.IsRoot() { + fmt.Fprintln(out, " }") + } + currentMod = addr.Module + fmt.Fprintf(out, " subgraph \"cluster_%s\" {\n", currentMod.String()) + fmt.Fprintf(out, " label = %q\n", currentMod.String()) + fmt.Fprintf(out, " fontname = %q\n", "sans-serif") + } + if currentMod.IsRoot() { + fmt.Fprintf(out, " %q [label=%q];\n", addr.String(), addr.Resource.String()) + } else { + fmt.Fprintf(out, " %q [label=%q];\n", addr.String(), addr.Resource.String()) + } + } + if !currentMod.IsRoot() { + fmt.Fprintln(out, " }") + } + + // Now we'll emit all of the edges. + // We use addrsOrder for both levels to ensure a consistent ordering between + // runs without further sorting, which means we visit more nodes than we + // really need to but this output format is only really useful for relatively + // small graphs anyway, so this should be fine. + for _, sourceAddr := range addrsOrder { + deps := graph.DirectDependenciesOf(sourceAddr) + for _, targetAddr := range addrsOrder { + if !deps.Has(targetAddr) { + continue + } + fmt.Fprintf(out, " %q -> %q;\n", sourceAddr.String(), targetAddr.String()) + } + } + + fmt.Fprintln(out, "}") + return 0 +} + func (c *GraphCommand) Help() string { helpText := ` Usage: terraform [global options] graph [options] @@ -201,6 +307,12 @@ Usage: terraform [global options] graph [options] Produces a representation of the dependency graph between different objects in the current configuration and state. + By default the graph shows a summary only of the relationships between + resources in the configuration, since those are the main objects that + have side-effects whose ordering is significant. You can generate more + detailed graphs reflecting Terraform's actual evaluation strategy + by specifying the -type=TYPE option to select an operation type. + The graph is presented in the DOT language. The typical program that can read this format is GraphViz, but many web services are also available to read this format. @@ -208,17 +320,22 @@ Usage: terraform [global options] graph [options] Options: -plan=tfplan Render graph using the specified plan file instead of the - configuration in the current directory. + configuration in the current directory. Implies -type=apply. -draw-cycles Highlight any cycles in the graph with colored edges. - This helps when diagnosing cycle errors. + This helps when diagnosing cycle errors. This option is + supported only when illustrating a real evaluation graph, + selected using the -type=TYPE option. - -type=plan Type of graph to output. Can be: plan, plan-refresh-only, - plan-destroy, or apply. By default Terraform chooses - "plan", or "apply" if you also set the -plan=... option. + -type=TYPE Type of operation graph to output. Can be: plan, + plan-refresh-only, plan-destroy, or apply. By default + Terraform just summarizes the relationships between the + resources in your configuration, without any particular + operation in mind. Full operation graphs are more detailed + but therefore often harder to read. -module-depth=n (deprecated) In prior versions of Terraform, specified the - depth of modules to show in the output. + depth of modules to show in the output. ` return strings.TrimSpace(helpText) } diff --git a/internal/command/graph_test.go b/internal/command/graph_test.go index 53be30e016..f131e8a6aa 100644 --- a/internal/command/graph_test.go +++ b/internal/command/graph_test.go @@ -4,39 +4,49 @@ package command import ( + "context" "os" "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/mitchellh/cli" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs/configload" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/registry" "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/terminal" ) -func TestGraph(t *testing.T) { +func TestGraph_planPhase(t *testing.T) { td := t.TempDir() testCopyDir(t, testFixturePath("graph"), td) defer testChdir(t, td)() ui := new(cli.MockUi) + streams, closeStreams := terminal.StreamsForTesting(t) c := &GraphCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(applyFixtureProvider()), Ui: ui, + Streams: streams, }, } - args := []string{} + args := []string{"-type=plan"} if code := c.Run(args); code != 0 { t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) } - output := ui.OutputWriter.String() - if !strings.Contains(output, `provider[\"registry.terraform.io/hashicorp/test\"]`) { - t.Fatalf("doesn't look like digraph: %s", output) + output := closeStreams(t) + if !strings.Contains(output.Stdout(), `provider[\"registry.terraform.io/hashicorp/test\"]`) { + t.Fatalf("doesn't look like digraph:\n%s\n\nstderr:\n%s", output.Stdout(), output.Stderr()) } } @@ -58,52 +68,112 @@ func TestGraph_multipleArgs(t *testing.T) { } } -func TestGraph_noArgs(t *testing.T) { +func TestGraph_noConfig(t *testing.T) { td := t.TempDir() - testCopyDir(t, testFixturePath("graph"), td) + os.MkdirAll(td, 0755) defer testChdir(t, td)() - ui := new(cli.MockUi) + streams, closeStreams := terminal.StreamsForTesting(t) + defer closeStreams(t) + ui := cli.NewMockUi() c := &GraphCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(applyFixtureProvider()), Ui: ui, + Streams: streams, }, } - args := []string{} + // Running the graph command without a config should not panic, + // but this may be an error at some point in the future. + args := []string{"-type", "apply"} if code := c.Run(args); code != 0 { t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) } - - output := ui.OutputWriter.String() - if !strings.Contains(output, `provider[\"registry.terraform.io/hashicorp/test\"]`) { - t.Fatalf("doesn't look like digraph: %s", output) - } } -func TestGraph_noConfig(t *testing.T) { - td := t.TempDir() - os.MkdirAll(td, 0755) - defer testChdir(t, td)() +func TestGraph_resourcesOnly(t *testing.T) { + wd := tempWorkingDirFixture(t, "graph-interesting") + defer testChdir(t, wd.RootModuleDir())() + + // The graph-interesting fixture has a child module, so we'll need to + // run the module installer just to get the working directory set up + // properly, as if the user has run "terraform init". This is really + // just building the working directory's index of module directories. + loader, cleanupLoader := configload.NewLoaderForTests(t) + t.Cleanup(cleanupLoader) + err := os.MkdirAll(".terraform/modules", 0700) + if err != nil { + t.Fatal(err) + } + inst := initwd.NewModuleInstaller(".terraform/modules", loader, registry.NewClient(nil, nil)) + _, instDiags := inst.InstallModules(context.Background(), ".", "tests", true, false, initwd.ModuleInstallHooksImpl{}) + if instDiags.HasErrors() { + t.Fatal(instDiags.Err()) + } + + p := testProvider() + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "foo": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "arg": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + } - ui := new(cli.MockUi) + ui := cli.NewMockUi() + streams, closeStreams := terminal.StreamsForTesting(t) c := &GraphCommand{ Meta: Meta{ - testingOverrides: metaOverridesForProvider(applyFixtureProvider()), - Ui: ui, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("foo"): providers.FactoryFixed(p), + }, + }, + Ui: ui, + Streams: streams, }, } - // Running the graph command without a config should not panic, - // but this may be an error at some point in the future. - args := []string{"-type", "apply"} + // A "resources only" graph is the default behavior, with no extra arguments. + args := []string{} if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + output := closeStreams(t) + t.Fatalf("unexpected error: \n%s", output.Stderr()) + } + + output := closeStreams(t) + gotGraph := strings.TrimSpace(output.Stdout()) + wantGraph := strings.TrimSpace(` +digraph G { + rankdir = "RL"; + node [shape = rect, fontname = "sans-serif"]; + "foo.bar" [label="foo.bar"]; + "foo.baz" [label="foo.baz"]; + "foo.boop" [label="foo.boop"]; + subgraph "cluster_module.child" { + label = "module.child" + fontname = "sans-serif" + "module.child.foo.bleep" [label="foo.bleep"]; + } + "foo.baz" -> "foo.bar"; + "foo.boop" -> "module.child.foo.bleep"; + "module.child.foo.bleep" -> "foo.bar"; +} +`) + if diff := cmp.Diff(wantGraph, gotGraph); diff != "" { + t.Fatalf("wrong result\n%s", diff) } } -func TestGraph_plan(t *testing.T) { +func TestGraph_applyPhaseSavedPlan(t *testing.T) { testCwd(t) plan := &plans.Plan{ @@ -140,11 +210,13 @@ func TestGraph_plan(t *testing.T) { planPath := testPlanFile(t, configSnap, states.NewState(), plan) - ui := new(cli.MockUi) + streams, closeStreams := terminal.StreamsForTesting(t) + ui := cli.NewMockUi() c := &GraphCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(applyFixtureProvider()), Ui: ui, + Streams: streams, }, } @@ -155,8 +227,8 @@ func TestGraph_plan(t *testing.T) { t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) } - output := ui.OutputWriter.String() - if !strings.Contains(output, `provider[\"registry.terraform.io/hashicorp/test\"]`) { - t.Fatalf("doesn't look like digraph: %s", output) + output := closeStreams(t) + if !strings.Contains(output.Stdout(), `provider[\"registry.terraform.io/hashicorp/test\"]`) { + t.Fatalf("doesn't look like digraph:\n%s\n\nstderr:\n%s", output.Stdout(), output.Stderr()) } } diff --git a/internal/command/testdata/graph-interesting/child/graph-interesting-child.tf b/internal/command/testdata/graph-interesting/child/graph-interesting-child.tf new file mode 100644 index 0000000000..31f2b15f07 --- /dev/null +++ b/internal/command/testdata/graph-interesting/child/graph-interesting-child.tf @@ -0,0 +1,12 @@ + +variable "in" { + type = string +} + +resource "foo" "bleep" { + arg = var.in +} + +output "out" { + value = foo.bleep.arg +} diff --git a/internal/command/testdata/graph-interesting/graph-interesting.tf b/internal/command/testdata/graph-interesting/graph-interesting.tf new file mode 100644 index 0000000000..91b5db1ba4 --- /dev/null +++ b/internal/command/testdata/graph-interesting/graph-interesting.tf @@ -0,0 +1,20 @@ +resource "foo" "bar" { +} + +locals { + foo_bar_baz = foo.bar.baz +} + +resource "foo" "baz" { + arg = local.foo_bar_baz +} + +module "child" { + source = "./child" + + in = local.foo_bar_baz +} + +resource "foo" "boop" { + arg = module.child.out +} diff --git a/website/docs/cli/commands/graph.mdx b/website/docs/cli/commands/graph.mdx index 9f59ee564b..6ee59236ad 100644 --- a/website/docs/cli/commands/graph.mdx +++ b/website/docs/cli/commands/graph.mdx @@ -7,50 +7,50 @@ description: >- # Command: graph -The `terraform graph` command is used to generate a visual -representation of either a configuration or execution plan. -The output is in the DOT format, which can be used by -[GraphViz](http://www.graphviz.org) to generate charts. +The `terraform graph` command produces descriptions of the relationships +between objects in a Terraform configuration, using +[the DOT language](https://en.wikipedia.org/wiki/DOT_(graph_description_language)). ## Usage Usage: `terraform graph [options]` -Outputs the visual execution graph of Terraform resources according to -either the current configuration or an execution plan. +By default the result is a simplified graph which describes only the dependency +ordering of the resources (`resource` and `data` blocks) in the configuration. -The graph is outputted in DOT format. The typical program that can -read this format is GraphViz, but many web services are also available -to read this format. - -The `-type` flag can be used to control the type of graph shown. Terraform -creates different graphs for different operations. See the options below -for the list of types supported. The default type is "plan" if a -configuration is given, and "apply" if a plan file is passed as an -argument. +The `-type=...` option optionally selects from a number of other graph types +which have more detail, at the expense of also exposing some of the +implementation details of the Terraform language runtime. Options: -* `-plan=tfplan` - Render graph using the specified plan file instead of the - configuration in the current directory. - -* `-draw-cycles` - Highlight any cycles in the graph with colored edges. - This helps when diagnosing cycle errors. +* `-plan=tfplan` - Produce a graph for applying the given plan. Implies `-type=apply`. -* `-type=plan` - Type of graph to output. Can be: `plan`, `plan-refresh-only`, `plan-destroy`, or `apply`. +* `-draw-cycles` - Highlight any cycles in the graph with colored edges. + This helps when diagnosing cycle errors. This option is supported only when + selecting one of the real graph operaton types using the `-type=...` + option. -* `-module-depth=n` - (deprecated) In prior versions of Terraform, specified the - depth of modules to show in the output. +* `-type=...` - Selects a specific operation type to show the graph of, instead + of the default resources-only simplified graph. + Can be: `plan`, `plan-refresh-only`, `plan-destroy`, or `apply`. ## Generating Images -The output of `terraform graph` is in the DOT format, which can -easily be converted to an image by making use of `dot` provided -by GraphViz: +The graph output uses +[the DOT language](https://en.wikipedia.org/wiki/DOT_(graph_description_language)), +which is a machine-readable graph description language which originated in +[Graphviz](https://graphviz.org/). You can use the Graphviz `dot` command +to present the resulting graph description as an image. There are also various +third-party online graph rendering services which accept this format. + +If you have the Graphviz `dot` command already installed, you can render +a PNG image by piping into that command: ```shellsession -$ terraform graph | dot -Tsvg > graph.svg +$ terraform graph -type=plan | dot -Tpng >graph.png ``` -Here is an example graph output: -![Graph Example](/img/docs/graph-example.png) +The following is an example result: + +![A visualization of the plan graph of a hypothetical Terraform configuration, produced by dot](/img/docs/graph-example.png)