mirror of https://github.com/hashicorp/terraform
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
338 lines
11 KiB
338 lines
11 KiB
// Copyright IBM Corp. 2014, 2026
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package command
|
|
|
|
import (
|
|
"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(args []string) int {
|
|
var drawCycles bool
|
|
var graphTypeStr string
|
|
var moduleDepth int
|
|
var verbose bool
|
|
var planPath string
|
|
|
|
args = c.Meta.process(args)
|
|
cmdFlags := c.Meta.defaultFlagSet("graph")
|
|
cmdFlags.BoolVar(&drawCycles, "draw-cycles", false, "draw-cycles")
|
|
cmdFlags.StringVar(&graphTypeStr, "type", "", "type")
|
|
cmdFlags.IntVar(&moduleDepth, "module-depth", -1, "module-depth")
|
|
cmdFlags.BoolVar(&verbose, "verbose", false, "verbose")
|
|
cmdFlags.StringVar(&planPath, "plan", "", "plan")
|
|
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
|
|
if err := cmdFlags.Parse(args); err != nil {
|
|
c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error()))
|
|
return 1
|
|
}
|
|
|
|
configPath, err := ModulePath(cmdFlags.Args())
|
|
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 planPath != "" {
|
|
planFile, err = c.PlanFile(planPath)
|
|
if err != nil {
|
|
c.Ui.Error(err.Error())
|
|
return 1
|
|
}
|
|
}
|
|
|
|
var diags tfdiags.Diagnostics
|
|
|
|
// 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
|
|
}
|
|
|
|
// Get the context
|
|
lr, _, ctxDiags := local.LocalRun(opReq)
|
|
diags = diags.Append(ctxDiags)
|
|
if ctxDiags.HasErrors() {
|
|
c.showDiagnostics(diags)
|
|
return 1
|
|
}
|
|
lr.Core.SetGraphOpts(&terraform.ContextGraphOpts{SkipGraphValidation: drawCycles})
|
|
|
|
if graphTypeStr == "" {
|
|
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"
|
|
}
|
|
}
|
|
|
|
var g *terraform.Graph
|
|
var graphDiags tfdiags.Diagnostics
|
|
switch graphTypeStr {
|
|
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.", graphTypeStr),
|
|
))
|
|
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
|
|
}
|
|
|
|
graphStr, err := terraform.GraphDot(g, &dag.DotOpts{
|
|
DrawCycles: drawCycles,
|
|
MaxDepth: moduleDepth,
|
|
Verbose: verbose,
|
|
})
|
|
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 dot 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) 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.
|
|
`
|
|
return strings.TrimSpace(helpText)
|
|
}
|
|
|
|
func (c *GraphCommand) Synopsis() string {
|
|
return "Generate a Graphviz graph of the steps in an operation"
|
|
}
|