| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package command | |
| import ( | |
| "context" | |
| "fmt" | |
| "sort" | |
| "strings" | |
| "github.com/hashicorp/terraform/internal/addrs" | |
| "github.com/hashicorp/terraform/internal/backend/backendrun" | |
| "github.com/hashicorp/terraform/internal/command/arguments" | |
| "github.com/hashicorp/terraform/internal/dag" | |
| "github.com/hashicorp/terraform/internal/plans" | |
| "github.com/hashicorp/terraform/internal/plans/planfile" | |
| "github.com/hashicorp/terraform/internal/terraform" | |
| "github.com/hashicorp/terraform/internal/tfdiags" | |
| ) | |
| // GraphCommand is a Command implementation that takes a Terraform | |
| // configuration and outputs the dependency tree in graphical form. | |
| type GraphCommand struct { | |
| Meta | |
| } | |
| func (c *GraphCommand) Run(rawArgs []string) int { | |
| args, diags := arguments.ParseGraph(c.Meta.process(rawArgs)) | |
| if diags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| configPath, err := ModulePath(nil) | |
| if err != nil { | |
| c.Ui.Error(err.Error()) | |
| return 1 | |
| } | |
| // Check for user-supplied plugin path | |
| if c.pluginPath, err = c.loadPluginPath(); err != nil { | |
| c.Ui.Error(fmt.Sprintf("Error loading plugin path: %s", err)) | |
| return 1 | |
| } | |
| // Try to load plan if path is specified | |
| var planFile *planfile.WrappedPlanFile | |
| if args.Plan != "" { | |
| planFile, err = c.PlanFile(args.Plan) | |
| if err != nil { | |
| c.Ui.Error(err.Error()) | |
| return 1 | |
| } | |
| } | |
| // Load the backend | |
| b, backendDiags := c.backend(".", arguments.ViewHuman) | |
| diags = diags.Append(backendDiags) | |
| if backendDiags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| // We require a local backend | |
| local, ok := b.(backendrun.Local) | |
| if !ok { | |
| c.showDiagnostics(diags) // in case of any warnings in here | |
| c.Ui.Error(ErrUnsupportedLocalOp) | |
| return 1 | |
| } | |
| // This is a read-only command | |
| c.ignoreRemoteVersionConflict(b) | |
| // Build the operation | |
| opReq := c.Operation(b, arguments.ViewHuman) | |
| opReq.ConfigDir = configPath | |
| opReq.ConfigLoader, err = c.initConfigLoader() | |
| opReq.PlanFile = planFile | |
| opReq.AllowUnsetVariables = true | |
| if err != nil { | |
| diags = diags.Append(err) | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| var varDiags tfdiags.Diagnostics | |
| opReq.Variables, varDiags = args.Vars.CollectValues(func(filename string, src []byte) { | |
| opReq.ConfigLoader.Parser().ForceFileSource(filename, src) | |
| }) | |
| diags = diags.Append(varDiags) | |
| if diags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| // Get the context | |
| lr, _, ctxDiags := local.LocalRun(context.Background(), opReq) | |
| diags = diags.Append(ctxDiags) | |
| if ctxDiags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| lr.Core.SetGraphOpts(&terraform.ContextGraphOpts{SkipGraphValidation: args.DrawCycles}) | |
| if args.GraphType == "" { | |
| 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() | |
| switch args.Format { | |
| case "mermaid": | |
| return c.resourceOnlyGraphMermaid(g) | |
| case "", "dot": | |
| return c.resourceOnlyGraph(g) | |
| default: | |
| c.Ui.Error(fmt.Sprintf("Unsupported graph format: %s", args.Format)) | |
| return 1 | |
| } | |
| } else { | |
| args.GraphType = "apply" | |
| } | |
| } | |
| var g *terraform.Graph | |
| var graphDiags tfdiags.Diagnostics | |
| switch args.GraphType { | |
| case "plan": | |
| g, graphDiags = lr.Core.PlanGraphForUI(lr.Config, lr.InputState, plans.NormalMode) | |
| case "plan-refresh-only": | |
| g, graphDiags = lr.Core.PlanGraphForUI(lr.Config, lr.InputState, plans.RefreshOnlyMode) | |
| case "plan-destroy": | |
| g, graphDiags = lr.Core.PlanGraphForUI(lr.Config, lr.InputState, plans.DestroyMode) | |
| case "apply": | |
| plan := lr.Plan | |
| // Historically "terraform graph" would allow the nonsensical request to | |
| // render an apply graph without a plan, so we continue to support that | |
| // here, though perhaps one day this should be an error. | |
| if lr.Plan == nil { | |
| plan = &plans.Plan{ | |
| Changes: plans.NewChangesSrc(), | |
| UIMode: plans.NormalMode, | |
| PriorState: lr.InputState, | |
| PrevRunState: lr.InputState, | |
| } | |
| } | |
| g, graphDiags = lr.Core.ApplyGraphForUI(plan, lr.Config) | |
| case "eval", "validate": | |
| // Terraform v0.12 through v1.0 supported both of these, but the | |
| // graph variants for "eval" and "validate" are purely implementation | |
| // details and don't reveal anything (user-model-wise) that you can't | |
| // see in the plan graph. | |
| graphDiags = graphDiags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Graph type no longer available", | |
| fmt.Sprintf("The graph type %q is no longer available. Use -type=plan instead to get a similar result.", args.GraphType), | |
| )) | |
| default: | |
| graphDiags = graphDiags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Unsupported graph type", | |
| `The -type=... argument must be either "plan", "plan-refresh-only", "plan-destroy", or "apply".`, | |
| )) | |
| } | |
| diags = diags.Append(graphDiags) | |
| if graphDiags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| var graphStr string | |
| opts := &dag.DotOpts{ | |
| DrawCycles: args.DrawCycles, | |
| MaxDepth: args.ModuleDepth, | |
| Verbose: args.Verbose, | |
| } | |
| switch args.Format { | |
| case "mermaid": | |
| graphStr, err = terraform.GraphMermaid(g, opts) | |
| case "", "dot": | |
| graphStr, err = terraform.GraphDot(g, opts) | |
| default: | |
| c.Ui.Error(fmt.Sprintf("Unsupported graph format: %s", args.Format)) | |
| return 1 | |
| } | |
| if err != nil { | |
| c.Ui.Error(fmt.Sprintf("Error converting graph: %s", err)) | |
| return 1 | |
| } | |
| if diags.HasErrors() { | |
| // For this command we only show diagnostics if there are errors, | |
| // because printing out naked warnings could upset a naive program | |
| // consuming our graph output. | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| _, 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) resourceOnlyGraphMermaid(graph addrs.DirectedGraph[addrs.ConfigResource]) int { | |
| out := c.Streams.Stdout.File | |
| // use left-to-right layout by default | |
| fmt.Fprintln(out, "flowchart LR") | |
| // collect and sort addresses similar to resourceOnlyGraph for deterministic output | |
| 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") | |
| return 0 | |
| } | |
| 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) { | |
| if !currentMod.IsRoot() { | |
| fmt.Fprintln(out, " end") | |
| } | |
| currentMod = addr.Module | |
| fmt.Fprintf(out, " subgraph %s\n", currentMod.String()) | |
| } | |
| id := addr.String() | |
| label := addr.Resource.String() | |
| if currentMod.IsRoot() { | |
| fmt.Fprintf(out, " %s[%s]\n", id, dag.MermaidEscapeLabel(label)) | |
| } else { | |
| fmt.Fprintf(out, " %s[%s]\n", id, dag.MermaidEscapeLabel(label)) | |
| } | |
| } | |
| if !currentMod.IsRoot() { | |
| fmt.Fprintln(out, " end") | |
| } | |
| // emit edges | |
| for _, sourceAddr := range addrsOrder { | |
| deps := graph.DirectDependenciesOf(sourceAddr) | |
| srcID := sourceAddr.String() | |
| for _, targetAddr := range addrsOrder { | |
| if !deps.Has(targetAddr) { | |
| continue | |
| } | |
| tgtID := targetAddr.String() | |
| fmt.Fprintf(out, " %s --> %s\n", srcID, tgtID) | |
| } | |
| } | |
| return 0 | |
| } | |
| func (c *GraphCommand) Help() string { | |
| helpText := ` | |
| 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. | |
| Options: | |
| -plan=tfplan Render graph using the specified plan file instead of the | |
| 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 option is | |
| supported only when illustrating a real evaluation graph, | |
| selected using the -type=TYPE 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. | |
| -var 'foo=bar' Set a value for one of the input variables in the root | |
| module of the configuration. Use this option more than | |
| once to set more than one variable. | |
| -var-file=filename Load variable values from the given file, in addition | |
| to the default files terraform.tfvars and *.auto.tfvars. | |
| Use this option more than once to include more than one | |
| variables file. | |
| -format=FORMAT Output format for the graph. Supported values are | |
| dot (default) and mermaid. | |
| ` | |
| return strings.TrimSpace(helpText) | |
| } | |
| func (c *GraphCommand) Synopsis() string { | |
| return "Generate a Graphviz graph of the steps in an operation" | |
| } |