github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/command/show.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package command
     5  
     6  import (
     7  	"context"
     8  	"errors"
     9  	"fmt"
    10  	"os"
    11  	"strings"
    12  
    13  	"github.com/terramate-io/tf/backend"
    14  	"github.com/terramate-io/tf/cloud"
    15  	"github.com/terramate-io/tf/cloud/cloudplan"
    16  	"github.com/terramate-io/tf/command/arguments"
    17  	"github.com/terramate-io/tf/command/views"
    18  	"github.com/terramate-io/tf/configs"
    19  	"github.com/terramate-io/tf/plans"
    20  	"github.com/terramate-io/tf/plans/planfile"
    21  	"github.com/terramate-io/tf/states/statefile"
    22  	"github.com/terramate-io/tf/states/statemgr"
    23  	"github.com/terramate-io/tf/terraform"
    24  	"github.com/terramate-io/tf/tfdiags"
    25  )
    26  
    27  // Many of the methods we get data from can emit special error types if they're
    28  // pretty sure about the file type but still can't use it. But they can't all do
    29  // that! So, we have to do a couple ourselves if we want to preserve that data.
    30  type errUnusableDataMisc struct {
    31  	inner error
    32  	kind  string
    33  }
    34  
    35  func errUnusable(err error, kind string) *errUnusableDataMisc {
    36  	return &errUnusableDataMisc{inner: err, kind: kind}
    37  }
    38  func (e *errUnusableDataMisc) Error() string {
    39  	return e.inner.Error()
    40  }
    41  func (e *errUnusableDataMisc) Unwrap() error {
    42  	return e.inner
    43  }
    44  
    45  // ShowCommand is a Command implementation that reads and outputs the
    46  // contents of a Terraform plan or state file.
    47  type ShowCommand struct {
    48  	Meta
    49  	viewType arguments.ViewType
    50  }
    51  
    52  func (c *ShowCommand) Run(rawArgs []string) int {
    53  	// Parse and apply global view arguments
    54  	common, rawArgs := arguments.ParseView(rawArgs)
    55  	c.View.Configure(common)
    56  
    57  	// Parse and validate flags
    58  	args, diags := arguments.ParseShow(rawArgs)
    59  	if diags.HasErrors() {
    60  		c.View.Diagnostics(diags)
    61  		c.View.HelpPrompt("show")
    62  		return 1
    63  	}
    64  	c.viewType = args.ViewType
    65  
    66  	// Set up view
    67  	view := views.NewShow(args.ViewType, c.View)
    68  
    69  	// Check for user-supplied plugin path
    70  	var err error
    71  	if c.pluginPath, err = c.loadPluginPath(); err != nil {
    72  		diags = diags.Append(fmt.Errorf("error loading plugin path: %s", err))
    73  		view.Diagnostics(diags)
    74  		return 1
    75  	}
    76  
    77  	// Get the data we need to display
    78  	plan, jsonPlan, stateFile, config, schemas, showDiags := c.show(args.Path)
    79  	diags = diags.Append(showDiags)
    80  	if showDiags.HasErrors() {
    81  		view.Diagnostics(diags)
    82  		return 1
    83  	}
    84  
    85  	// Display the data
    86  	return view.Display(config, plan, jsonPlan, stateFile, schemas)
    87  }
    88  
    89  func (c *ShowCommand) Help() string {
    90  	helpText := `
    91  Usage: terraform [global options] show [options] [path]
    92  
    93    Reads and outputs a Terraform state or plan file in a human-readable
    94    form. If no path is specified, the current state will be shown.
    95  
    96  Options:
    97  
    98    -no-color           If specified, output won't contain any color.
    99    -json               If specified, output the Terraform plan or state in
   100                        a machine-readable form.
   101  
   102  `
   103  	return strings.TrimSpace(helpText)
   104  }
   105  
   106  func (c *ShowCommand) Synopsis() string {
   107  	return "Show the current state or a saved plan"
   108  }
   109  
   110  func (c *ShowCommand) show(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, *terraform.Schemas, tfdiags.Diagnostics) {
   111  	var diags, showDiags tfdiags.Diagnostics
   112  	var plan *plans.Plan
   113  	var jsonPlan *cloudplan.RemotePlanJSON
   114  	var stateFile *statefile.File
   115  	var config *configs.Config
   116  	var schemas *terraform.Schemas
   117  
   118  	// No plan file or state file argument provided,
   119  	// so get the latest state snapshot
   120  	if path == "" {
   121  		stateFile, showDiags = c.showFromLatestStateSnapshot()
   122  		diags = diags.Append(showDiags)
   123  		if showDiags.HasErrors() {
   124  			return plan, jsonPlan, stateFile, config, schemas, diags
   125  		}
   126  	}
   127  
   128  	// Plan file or state file argument provided,
   129  	// so try to load the argument as a plan file first.
   130  	// If that fails, try to load it as a statefile.
   131  	if path != "" {
   132  		plan, jsonPlan, stateFile, config, showDiags = c.showFromPath(path)
   133  		diags = diags.Append(showDiags)
   134  		if showDiags.HasErrors() {
   135  			return plan, jsonPlan, stateFile, config, schemas, diags
   136  		}
   137  	}
   138  
   139  	// Get schemas, if possible
   140  	if config != nil || stateFile != nil {
   141  		schemas, diags = c.MaybeGetSchemas(stateFile.State, config)
   142  		if diags.HasErrors() {
   143  			return plan, jsonPlan, stateFile, config, schemas, diags
   144  		}
   145  	}
   146  
   147  	return plan, jsonPlan, stateFile, config, schemas, diags
   148  }
   149  func (c *ShowCommand) showFromLatestStateSnapshot() (*statefile.File, tfdiags.Diagnostics) {
   150  	var diags tfdiags.Diagnostics
   151  
   152  	// Load the backend
   153  	b, backendDiags := c.Backend(nil)
   154  	diags = diags.Append(backendDiags)
   155  	if backendDiags.HasErrors() {
   156  		return nil, diags
   157  	}
   158  	c.ignoreRemoteVersionConflict(b)
   159  
   160  	// Load the workspace
   161  	workspace, err := c.Workspace()
   162  	if err != nil {
   163  		diags = diags.Append(fmt.Errorf("error selecting workspace: %s", err))
   164  		return nil, diags
   165  	}
   166  
   167  	// Get the latest state snapshot from the backend for the current workspace
   168  	stateFile, stateErr := getStateFromBackend(b, workspace)
   169  	if stateErr != nil {
   170  		diags = diags.Append(stateErr)
   171  		return nil, diags
   172  	}
   173  
   174  	return stateFile, diags
   175  }
   176  
   177  func (c *ShowCommand) showFromPath(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, tfdiags.Diagnostics) {
   178  	var diags tfdiags.Diagnostics
   179  	var planErr, stateErr error
   180  	var plan *plans.Plan
   181  	var jsonPlan *cloudplan.RemotePlanJSON
   182  	var stateFile *statefile.File
   183  	var config *configs.Config
   184  
   185  	// Path might be a local plan file, a bookmark to a saved cloud plan, or a
   186  	// state file. First, try to get a plan and associated data from a local
   187  	// plan file. If that fails, try to get a json plan from the path argument.
   188  	// If that fails, try to get the statefile from the path argument.
   189  	plan, jsonPlan, stateFile, config, planErr = c.getPlanFromPath(path)
   190  	if planErr != nil {
   191  		stateFile, stateErr = getStateFromPath(path)
   192  		if stateErr != nil {
   193  			// To avoid spamming the user with irrelevant errors, first check to
   194  			// see if one of our errors happens to know for a fact what file
   195  			// type we were dealing with. If so, then we can ignore the other
   196  			// ones (which are likely to be something unhelpful like "not a
   197  			// valid zip file"). If not, we can fall back to dumping whatever
   198  			// we've got.
   199  			var unLocal *planfile.ErrUnusableLocalPlan
   200  			var unState *statefile.ErrUnusableState
   201  			var unMisc *errUnusableDataMisc
   202  			if errors.As(planErr, &unLocal) {
   203  				diags = diags.Append(
   204  					tfdiags.Sourceless(
   205  						tfdiags.Error,
   206  						"Couldn't show local plan",
   207  						fmt.Sprintf("Plan read error: %s", unLocal),
   208  					),
   209  				)
   210  			} else if errors.As(planErr, &unMisc) {
   211  				diags = diags.Append(
   212  					tfdiags.Sourceless(
   213  						tfdiags.Error,
   214  						fmt.Sprintf("Couldn't show %s", unMisc.kind),
   215  						fmt.Sprintf("Plan read error: %s", unMisc),
   216  					),
   217  				)
   218  			} else if errors.As(stateErr, &unState) {
   219  				diags = diags.Append(
   220  					tfdiags.Sourceless(
   221  						tfdiags.Error,
   222  						"Couldn't show state file",
   223  						fmt.Sprintf("Plan read error: %s", unState),
   224  					),
   225  				)
   226  			} else if errors.As(stateErr, &unMisc) {
   227  				diags = diags.Append(
   228  					tfdiags.Sourceless(
   229  						tfdiags.Error,
   230  						fmt.Sprintf("Couldn't show %s", unMisc.kind),
   231  						fmt.Sprintf("Plan read error: %s", unMisc),
   232  					),
   233  				)
   234  			} else {
   235  				// Ok, give up and show the really big error
   236  				diags = diags.Append(
   237  					tfdiags.Sourceless(
   238  						tfdiags.Error,
   239  						"Failed to read the given file as a state or plan file",
   240  						fmt.Sprintf("State read error: %s\n\nPlan read error: %s", stateErr, planErr),
   241  					),
   242  				)
   243  			}
   244  
   245  			return nil, nil, nil, nil, diags
   246  		}
   247  	}
   248  	return plan, jsonPlan, stateFile, config, diags
   249  }
   250  
   251  // getPlanFromPath returns a plan, json plan, statefile, and config if the
   252  // user-supplied path points to either a local or cloud plan file. Note that
   253  // some of the return values will be nil no matter what; local plan files do not
   254  // yield a json plan, and cloud plans do not yield real plan/state/config
   255  // structs. An error generally suggests that the given path is either a
   256  // directory or a statefile.
   257  func (c *ShowCommand) getPlanFromPath(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, error) {
   258  	var err error
   259  	var plan *plans.Plan
   260  	var jsonPlan *cloudplan.RemotePlanJSON
   261  	var stateFile *statefile.File
   262  	var config *configs.Config
   263  
   264  	pf, err := planfile.OpenWrapped(path)
   265  	if err != nil {
   266  		return nil, nil, nil, nil, err
   267  	}
   268  
   269  	if lp, ok := pf.Local(); ok {
   270  		plan, stateFile, config, err = getDataFromPlanfileReader(lp)
   271  	} else if cp, ok := pf.Cloud(); ok {
   272  		redacted := c.viewType != arguments.ViewJSON
   273  		jsonPlan, err = c.getDataFromCloudPlan(cp, redacted)
   274  	}
   275  
   276  	return plan, jsonPlan, stateFile, config, err
   277  }
   278  
   279  func (c *ShowCommand) getDataFromCloudPlan(plan *cloudplan.SavedPlanBookmark, redacted bool) (*cloudplan.RemotePlanJSON, error) {
   280  	// Set up the backend
   281  	b, backendDiags := c.Backend(nil)
   282  	if backendDiags.HasErrors() {
   283  		return nil, errUnusable(backendDiags.Err(), "cloud plan")
   284  	}
   285  	// Cloud plans only work if we're cloud.
   286  	cl, ok := b.(*cloud.Cloud)
   287  	if !ok {
   288  		return nil, errUnusable(fmt.Errorf("can't show a saved cloud plan unless the current root module is connected to Terraform Cloud"), "cloud plan")
   289  	}
   290  
   291  	result, err := cl.ShowPlanForRun(context.Background(), plan.RunID, plan.Hostname, redacted)
   292  	if err != nil {
   293  		err = errUnusable(err, "cloud plan")
   294  	}
   295  	return result, err
   296  }
   297  
   298  // getDataFromPlanfileReader returns a plan, statefile, and config, extracted from a local plan file.
   299  func getDataFromPlanfileReader(planReader *planfile.Reader) (*plans.Plan, *statefile.File, *configs.Config, error) {
   300  	// Get plan
   301  	plan, err := planReader.ReadPlan()
   302  	if err != nil {
   303  		return nil, nil, nil, err
   304  	}
   305  
   306  	// Get statefile
   307  	stateFile, err := planReader.ReadStateFile()
   308  	if err != nil {
   309  		return nil, nil, nil, err
   310  	}
   311  
   312  	// Get config
   313  	config, diags := planReader.ReadConfig()
   314  	if diags.HasErrors() {
   315  		return nil, nil, nil, errUnusable(diags.Err(), "local plan")
   316  	}
   317  
   318  	return plan, stateFile, config, err
   319  }
   320  
   321  // getStateFromPath returns a statefile if the user-supplied path points to a statefile.
   322  func getStateFromPath(path string) (*statefile.File, error) {
   323  	file, err := os.Open(path)
   324  	if err != nil {
   325  		return nil, fmt.Errorf("Error loading statefile: %w", err)
   326  	}
   327  	defer file.Close()
   328  
   329  	var stateFile *statefile.File
   330  	stateFile, err = statefile.Read(file)
   331  	if err != nil {
   332  		return nil, fmt.Errorf("Error reading %s as a statefile: %w", path, err)
   333  	}
   334  	return stateFile, nil
   335  }
   336  
   337  // getStateFromBackend returns the State for the current workspace, if available.
   338  func getStateFromBackend(b backend.Backend, workspace string) (*statefile.File, error) {
   339  	// Get the state store for the given workspace
   340  	stateStore, err := b.StateMgr(workspace)
   341  	if err != nil {
   342  		return nil, fmt.Errorf("Failed to load state manager: %w", err)
   343  	}
   344  
   345  	// Refresh the state store with the latest state snapshot from persistent storage
   346  	if err := stateStore.RefreshState(); err != nil {
   347  		return nil, fmt.Errorf("Failed to load state: %w", err)
   348  	}
   349  
   350  	// Get the latest state snapshot and return it
   351  	stateFile := statemgr.Export(stateStore)
   352  	return stateFile, nil
   353  }