Files
opentf/internal/command/show.go
Martin Atkins 67a5cd0911 statemgr+remote: context.Context parameters
This extends statemgr.Persistent, statemgr.Locker and remote.Client to
all expect context.Context parameters, and then updates all of the existing
implementations of those interfaces to support them.

All of the calls to statemgr.Persistent and statemgr.Locker methods outside
of tests are consistently context.TODO() for now, because the caller
landscape of these interfaces has some complications:

1. statemgr.Locker is also used by the clistate package for its state
   implementation that was derived from statemgr.Filesystem's predecessor,
   even though what clistate manages is not actually "state" in the sense
   of package statemgr. The callers of that are not yet ready to provide
   real contexts.

   In a future commit we'll either need to plumb context through to all of
   the clistate callers, or continue the effort to separate statemgr from
   clistate by introducing a clistate-specific "locker" API for it
   to use instead.

2. We call statemgr.Persistent and statemgr.Locker methods in situations
   where the active context might have already been cancelled, and so we'll
   need to make sure to ignore cancellation when calling those.

   This is mainly limited to PersistState and Unlock, since both need to
   be able to complete after a cancellation, but there are various
   codepaths that perform a Lock, Refresh, Persist, Unlock sequence and so
   it isn't yet clear where is the best place to enforce the invariant that
   Persist and Unlock must not be called with a cancelable context. We'll
   deal with that more in subsequent commits.

Within the various state manager and remote client implementations the
contexts _are_ wired together as best as possible with how these subsystems
are already laid out, and so once we deal with the problems above and make
callers provide suitable contexts they should be able to reach all of the
leaf API clients that might want to generate OpenTelemetry traces.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-07-10 08:11:39 -07:00

586 lines
19 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
otelAttr "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/opentofu/opentofu/internal/backend"
"github.com/opentofu/opentofu/internal/cloud"
"github.com/opentofu/opentofu/internal/cloud/cloudplan"
"github.com/opentofu/opentofu/internal/command/arguments"
"github.com/opentofu/opentofu/internal/command/views"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/encryption"
"github.com/opentofu/opentofu/internal/plans"
"github.com/opentofu/opentofu/internal/plans/planfile"
"github.com/opentofu/opentofu/internal/states/statefile"
"github.com/opentofu/opentofu/internal/states/statemgr"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/opentofu/opentofu/internal/tofu"
"github.com/opentofu/opentofu/internal/tracing"
)
// Many of the methods we get data from can emit special error types if they're
// pretty sure about the file type but still can't use it. But they can't all do
// that! So, we have to do a couple ourselves if we want to preserve that data.
type errUnusableDataMisc struct {
inner error
kind string
}
func errUnusable(err error, kind string) *errUnusableDataMisc {
return &errUnusableDataMisc{inner: err, kind: kind}
}
func (e *errUnusableDataMisc) Error() string {
return e.inner.Error()
}
func (e *errUnusableDataMisc) Unwrap() error {
return e.inner
}
// ShowCommand is a Command implementation that reads and outputs the
// contents of a OpenTofu plan or state file.
// write about config here
type ShowCommand struct {
Meta
viewType arguments.ViewType
}
func (c *ShowCommand) Run(rawArgs []string) int {
ctx := c.CommandContext()
// Parse and apply global view arguments
common, rawArgs := arguments.ParseView(rawArgs)
c.View.Configure(common)
// Parse and validate flags
args, diags := arguments.ParseShow(rawArgs)
if diags.HasErrors() {
c.View.Diagnostics(diags)
c.View.HelpPrompt("show")
return 1
}
c.viewType = args.ViewType
c.View.SetShowSensitive(args.ShowSensitive)
//nolint:ineffassign - As this is a high-level call, we want to ensure that we are correctly using the right ctx later on when
ctx, span := tracing.Tracer().Start(ctx, "Show",
trace.WithAttributes(
otelAttr.String("opentofu.show.view", args.ViewType.String()),
otelAttr.String("opentofu.show.target", args.TargetType.String()),
otelAttr.String("opentofu.show.target_arg", args.TargetArg),
otelAttr.Bool("opentofu.show.show_sensitive", args.ShowSensitive),
),
)
defer span.End()
// Set up view
view := views.NewShow(args.ViewType, c.View)
// Check for user-supplied plugin path
var err error
if c.pluginPath, err = c.loadPluginPath(); err != nil {
diags = diags.Append(fmt.Errorf("error loading plugin path: %w", err))
view.Diagnostics(diags)
return 1
}
// Inject variables from args into meta for static evaluation
c.GatherVariables(args.Vars)
// Load the encryption configuration
enc, encDiags := c.Encryption(ctx)
diags = diags.Append(encDiags)
if encDiags.HasErrors() {
c.showDiagnostics(diags)
return 1
}
renderResult, showDiags := c.show(ctx, args.TargetType, args.TargetArg, enc)
diags = diags.Append(showDiags)
if showDiags.HasErrors() {
// "tofu show" intentionally ignores warnings unless there is at
// least one error, because view.Diagnostics produces human output
// even in the JSON view and so would cause the JSON output to
// be invalid if only warnings were returned.
view.Diagnostics(diags)
tracing.SetSpanError(span, showDiags)
return 1
}
return renderResult(view)
}
func (c *ShowCommand) Help() string {
helpText := `
Usage: tofu [global options] show [target-selection-option] [other-options]
Reads and outputs a OpenTofu state or plan file in a human-readable
form. If no path is specified, the current state will be shown.
Target selection options:
Use one of the following options to specify what to show.
-state The latest state snapshot, if any.
-plan=FILENAME The plan from a saved plan file.
-config Show the current configuration (requires -json).
If no target selection options are provided, -state is the default.
Other options:
-no-color Disable terminal escape sequences.
-json Show the information in a machine-readable form.
-show-sensitive If specified, sensitive values will be displayed.
-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.
`
return strings.TrimSpace(helpText)
}
func (c *ShowCommand) Synopsis() string {
return "Show the current state or a saved plan"
}
func (c *ShowCommand) GatherVariables(args *arguments.Vars) {
// FIXME the arguments package currently trivially gathers variable related
// arguments in a heterogeneous slice, in order to minimize the number of
// code paths gathering variables during the transition to this structure.
// Once all commands that gather variables have been converted to this
// structure, we could move the variable gathering code to the arguments
// package directly, removing this shim layer.
varArgs := args.All()
items := make([]rawFlag, len(varArgs))
for i := range varArgs {
items[i].Name = varArgs[i].Name
items[i].Value = varArgs[i].Value
}
c.Meta.variableArgs = rawFlags{items: &items}
}
type showRenderFunc func(view views.Show) int
func (c *ShowCommand) show(ctx context.Context, targetType arguments.ShowTargetType, targetArg string, enc encryption.Encryption) (showRenderFunc, tfdiags.Diagnostics) {
switch targetType {
case arguments.ShowState:
return c.showFromLatestStateSnapshot(ctx, enc)
case arguments.ShowPlan:
return c.showFromSavedPlanFile(ctx, targetArg, enc)
case arguments.ShowConfig:
return c.showConfiguration(ctx)
case arguments.ShowUnknownType:
// This is a legacy case where we just have a filename and need to
// try treating it as either a saved plan file or a local state
// snapshot file.
return c.legacyShowFromPath(ctx, targetArg, enc)
default:
// Should not get here because the above cases should cover all
// possible values of [arguments.ShowTargetType].
panic(fmt.Sprintf("unsupported show target type %s", targetType))
}
}
func (c *ShowCommand) showFromLatestStateSnapshot(ctx context.Context, enc encryption.Encryption) (showRenderFunc, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
ctx, span := tracing.Tracer().Start(ctx, "Show State")
defer span.End()
// Load the backend
b, backendDiags := c.Backend(ctx, nil, enc.State())
diags = diags.Append(backendDiags)
if backendDiags.HasErrors() {
return nil, diags
}
c.ignoreRemoteVersionConflict(b)
// Load the workspace
workspace, err := c.Workspace(ctx)
if err != nil {
diags = diags.Append(fmt.Errorf("error selecting workspace: %w", err))
return nil, diags
}
// Get the latest state snapshot from the backend for the current workspace
stateFile, stateErr := getStateFromBackend(ctx, b, workspace)
if stateErr != nil {
diags = diags.Append(stateErr)
return nil, diags
}
schemas, schemaDiags := c.maybeGetSchemas(ctx, stateFile, nil)
diags = diags.Append(schemaDiags)
if schemaDiags.HasErrors() {
return nil, diags
}
return func(view views.Show) int {
return view.DisplayState(ctx, stateFile, schemas)
}, diags
}
func (c *ShowCommand) showFromSavedPlanFile(ctx context.Context, filename string, enc encryption.Encryption) (showRenderFunc, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
ctx, span := tracing.Tracer().Start(ctx, "Show Plan")
defer span.End()
rootCall, callDiags := c.rootModuleCall(ctx, ".")
diags = diags.Append(callDiags)
if diags.HasErrors() {
return nil, diags
}
plan, jsonPlan, stateFile, config, err := c.getPlanFromPath(ctx, filename, enc, rootCall)
if err != nil {
diags = diags.Append(err)
return nil, diags
}
schemas, schemaDiags := c.maybeGetSchemas(ctx, stateFile, config)
diags = diags.Append(schemaDiags)
if schemaDiags.HasErrors() {
return nil, diags
}
return func(view views.Show) int {
return view.DisplayPlan(ctx, plan, jsonPlan, config, stateFile, schemas)
}, diags
}
func (c *ShowCommand) legacyShowFromPath(ctx context.Context, path string, enc encryption.Encryption) (showRenderFunc, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
var planErr, stateErr error
var plan *plans.Plan
var jsonPlan *cloudplan.RemotePlanJSON
var stateFile *statefile.File
var config *configs.Config
ctx, span := tracing.Tracer().Start(ctx, "Show")
defer span.End()
rootCall, callDiags := c.rootModuleCall(ctx, ".")
diags = diags.Append(callDiags)
if diags.HasErrors() {
return nil, diags
}
// Path might be a local plan file, a bookmark to a saved cloud plan, or a
// state file. First, try to get a plan and associated data from a local
// plan file. If that fails, try to get a json plan from the path argument.
// If that fails, try to get the statefile from the path argument.
plan, jsonPlan, stateFile, config, planErr = c.getPlanFromPath(ctx, path, enc, rootCall)
if planErr != nil {
stateFile, stateErr = getStateFromPath(path, enc)
if stateErr != nil {
// To avoid spamming the user with irrelevant errors, first check to
// see if one of our errors happens to know for a fact what file
// type we were dealing with. If so, then we can ignore the other
// ones (which are likely to be something unhelpful like "not a
// valid zip file"). If not, we can fall back to dumping whatever
// we've got.
var unLocal *planfile.ErrUnusableLocalPlan
var unState *statefile.ErrUnusableState
var unMisc *errUnusableDataMisc
if errors.As(planErr, &unLocal) {
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
"Couldn't show local plan",
fmt.Sprintf("Plan read error: %s", unLocal),
),
)
} else if errors.As(planErr, &unMisc) {
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
fmt.Sprintf("Couldn't show %s", unMisc.kind),
fmt.Sprintf("Plan read error: %s", unMisc),
),
)
} else if errors.As(stateErr, &unState) {
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
"Couldn't show state file",
fmt.Sprintf("Plan read error: %s", unState),
),
)
} else if errors.As(stateErr, &unMisc) {
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
fmt.Sprintf("Couldn't show %s", unMisc.kind),
fmt.Sprintf("Plan read error: %s", unMisc),
),
)
} else {
// Ok, give up and show the really big error
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
"Failed to read the given file as a state or plan file",
fmt.Sprintf("State read error: %s\n\nPlan read error: %s", stateErr, planErr),
),
)
}
tracing.SetSpanError(span, diags)
return nil, diags
}
}
schemas, schemaDiags := c.maybeGetSchemas(ctx, stateFile, config)
diags = diags.Append(schemaDiags)
if schemaDiags.HasErrors() {
tracing.SetSpanError(span, diags)
return nil, diags
}
// If we successfully loaded some things then the show mode we
// choose depends on what we loaded.
switch {
case plan != nil || jsonPlan != nil:
return func(view views.Show) int {
return view.DisplayPlan(ctx, plan, jsonPlan, config, stateFile, schemas)
}, diags
default:
// We treat all other cases as a state, and DisplayState
// tolerates stateFile being nil.
return func(view views.Show) int {
return view.DisplayState(ctx, stateFile, schemas)
}, diags
}
}
// getPlanFromPath returns a plan, json plan, statefile, and config if the
// user-supplied path points to either a local or cloud plan file. Note that
// some of the return values will be nil no matter what; local plan files do not
// yield a json plan, and cloud plans do not yield real plan/state/config
// structs. An error generally suggests that the given path is either a
// directory or a statefile.
func (c *ShowCommand) getPlanFromPath(ctx context.Context, path string, enc encryption.Encryption, rootCall configs.StaticModuleCall) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, error) {
var err error
var plan *plans.Plan
var jsonPlan *cloudplan.RemotePlanJSON
var stateFile *statefile.File
var config *configs.Config
pf, err := planfile.OpenWrapped(path, enc.Plan())
if err != nil {
return nil, nil, nil, nil, err
}
if lp, ok := pf.Local(); ok {
plan, stateFile, config, err = getDataFromPlanfileReader(ctx, lp, rootCall)
} else if cp, ok := pf.Cloud(); ok {
redacted := c.viewType != arguments.ViewJSON
jsonPlan, err = c.getDataFromCloudPlan(ctx, cp, redacted, enc)
}
return plan, jsonPlan, stateFile, config, err
}
func (c *ShowCommand) getDataFromCloudPlan(ctx context.Context, plan *cloudplan.SavedPlanBookmark, redacted bool, enc encryption.Encryption) (*cloudplan.RemotePlanJSON, error) {
// Set up the backend
b, backendDiags := c.Backend(ctx, nil, enc.State())
if backendDiags.HasErrors() {
return nil, errUnusable(backendDiags.Err(), "cloud plan")
}
// Cloud plans only work if we're cloud.
cl, ok := b.(*cloud.Cloud)
if !ok {
return nil, errUnusable(fmt.Errorf("can't show a saved cloud plan unless the current root module is connected to Terraform Cloud"), "cloud plan")
}
result, err := cl.ShowPlanForRun(context.Background(), plan.RunID, plan.Hostname, redacted)
if err != nil {
err = errUnusable(err, "cloud plan")
}
return result, err
}
// maybeGetSchemas is a thin wrapper around [Meta.MaybeGetSchemas] that
// takes a [*statefile.File] instead of a [*states.State] and tolerates
// the state file being nil, since that's more convenient for the
// "tofu show" methods that may or may not have a state file to use.
func (c *ShowCommand) maybeGetSchemas(ctx context.Context, stateFile *statefile.File, config *configs.Config) (*tofu.Schemas, tfdiags.Diagnostics) {
ctx, span := tracing.Tracer().Start(ctx, "Get Schemas")
defer span.End()
if stateFile == nil {
return nil, nil
}
schemas, diags := c.MaybeGetSchemas(ctx, stateFile.State, config)
if diags.HasErrors() {
tracing.SetSpanError(span, diags.Err())
return nil, diags
}
return schemas, nil
}
// getDataFromPlanfileReader returns a plan, statefile, and config, extracted from a local plan file.
func getDataFromPlanfileReader(ctx context.Context, planReader *planfile.Reader, rootCall configs.StaticModuleCall) (*plans.Plan, *statefile.File, *configs.Config, error) {
// Get plan
plan, err := planReader.ReadPlan()
if err != nil {
return nil, nil, nil, err
}
// Get statefile
stateFile, err := planReader.ReadStateFile()
if err != nil {
return nil, nil, nil, err
}
subCall := rootCall.WithVariables(func(variable *configs.Variable) (cty.Value, hcl.Diagnostics) {
var diags hcl.Diagnostics
name := variable.Name
v, ok := plan.VariableValues[name]
if !ok {
if variable.Required() {
// This should not happen...
return cty.DynamicVal, diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing plan variable " + variable.Name,
})
}
return variable.Default, nil
}
parsed, parsedErr := v.Decode(cty.DynamicPseudoType)
if parsedErr != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: parsedErr.Error(),
})
}
return parsed, diags
})
// Get config
config, diags := planReader.ReadConfig(ctx, subCall)
if diags.HasErrors() {
return nil, nil, nil, errUnusable(diags.Err(), "local plan")
}
return plan, stateFile, config, err
}
// getStateFromPath returns a statefile if the user-supplied path points to a statefile.
func getStateFromPath(path string, enc encryption.Encryption) (*statefile.File, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("Error loading statefile: %w", err)
}
defer file.Close()
var stateFile *statefile.File
stateFile, err = statefile.Read(file, enc.State())
if err != nil {
return nil, fmt.Errorf("Error reading %s as a statefile: %w", path, err)
}
return stateFile, nil
}
// getStateFromBackend returns the State for the current workspace, if available.
func getStateFromBackend(ctx context.Context, b backend.Backend, workspace string) (*statefile.File, error) {
ctx, span := tracing.Tracer().Start(ctx, "Get State from Backend")
defer span.End()
// Get the state store for the given workspace
stateStore, err := b.StateMgr(ctx, workspace)
if err != nil {
tracing.SetSpanError(span, err)
return nil, fmt.Errorf("failed to load state manager: %w", err)
}
// Refresh the state store with the latest state snapshot from persistent storage
if err := stateStore.RefreshState(context.TODO()); err != nil {
tracing.SetSpanError(span, err)
return nil, fmt.Errorf("failed to load state: %w", err)
}
// Get the latest state snapshot and return it
stateFile := statemgr.Export(stateStore)
return stateFile, nil
}
// showConfiguration returns a function that will display the current configuration
// in JSON format. This is a new feature that requires -json to be specified.
func (c *ShowCommand) showConfiguration(ctx context.Context) (showRenderFunc, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// Check if the directory is empty
empty, err := configs.IsEmptyDir(".")
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Error validating configuration directory",
fmt.Sprintf("OpenTofu encountered an unexpected error while verifying that the given configuration directory is valid: %s.", err),
))
return nil, diags
}
if empty {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"No configuration files",
"This directory contains no OpenTofu configuration files.",
))
return nil, diags
}
// Load the configuration
config, configDiags := c.loadConfig(ctx, ".")
diags = diags.Append(configDiags)
if configDiags.HasErrors() {
return nil, diags
}
// Load provider schemas (without state)
schemas, schemaDiags := c.MaybeGetSchemas(ctx, nil, config)
diags = diags.Append(schemaDiags)
if schemaDiags.HasErrors() {
return nil, diags
}
if schemas == nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to load provider schemas",
"The configuration cannot be shown without provider schema information.",
))
return nil, diags
}
// Return a function that will render the configuration as JSON
return func(view views.Show) int {
// Display the configuration using the view
return view.DisplayConfig(config, schemas)
}, diags
}