Merge pull request #30344 from hashicorp/lafentres/refactor-show-command

cli: Refactor show command & migrate to command arguments and views
This commit is contained in:
Krista LaFentres (she/her) 2022-01-13 13:58:53 -06:00 committed by GitHub
commit 6dcf00aefc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 909 additions and 342 deletions

View File

@ -275,13 +275,6 @@ type Operation struct {
// the variables set in the plan are used instead, and they must be valid.
AllowUnsetVariables bool
// When loading a plan file for a read-only operation, we may want to
// disable the state lineage checks which are only relevant for operations
// which can modify state. An example where this is important is showing
// a plan which was prepared against a non-default state file, because the
// lineage checks are always against the default state.
DisablePlanFileStateLineageChecks bool
// View implements the logic for all UI interactions.
View views.Operation

View File

@ -284,7 +284,7 @@ func (b *Local) localRunForPlanFile(op *backend.Operation, pf *planfile.Reader,
))
return nil, snap, diags
}
if !op.DisablePlanFileStateLineageChecks && currentStateMeta != nil {
if currentStateMeta != nil {
// If the caller sets this, we require that the stored prior state
// has the same metadata, which is an extra safety check that nothing
// has changed since the plan was created. (All of the "real-world"

View File

@ -0,0 +1,59 @@
package arguments
import (
"github.com/hashicorp/terraform/internal/tfdiags"
)
// Show represents the command-line arguments for the show command.
type Show struct {
// Path is the path to the state file or plan file to be displayed. If
// unspecified, show will display the latest state snapshot.
Path string
// ViewType specifies which output format to use: human, JSON, or "raw".
ViewType ViewType
}
// ParseShow processes CLI arguments, returning a Show value and errors.
// If errors are encountered, a Show value is still returned representing
// the best effort interpretation of the arguments.
func ParseShow(args []string) (*Show, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
show := &Show{
Path: "",
}
var jsonOutput bool
cmdFlags := defaultFlagSet("show")
cmdFlags.BoolVar(&jsonOutput, "json", false, "json")
if err := cmdFlags.Parse(args); err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to parse command-line flags",
err.Error(),
))
}
args = cmdFlags.Args()
if len(args) > 1 {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Too many command line arguments",
"Expected at most one positional argument.",
))
}
if len(args) > 0 {
show.Path = args[0]
}
switch {
case jsonOutput:
show.ViewType = ViewJSON
default:
show.ViewType = ViewHuman
}
return show, diags
}

View File

@ -0,0 +1,99 @@
package arguments
import (
"reflect"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/hashicorp/terraform/internal/tfdiags"
)
func TestParseShow_valid(t *testing.T) {
testCases := map[string]struct {
args []string
want *Show
}{
"defaults": {
nil,
&Show{
Path: "",
ViewType: ViewHuman,
},
},
"json": {
[]string{"-json"},
&Show{
Path: "",
ViewType: ViewJSON,
},
},
"path": {
[]string{"-json", "foo"},
&Show{
Path: "foo",
ViewType: ViewJSON,
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got, diags := ParseShow(tc.args)
if len(diags) > 0 {
t.Fatalf("unexpected diags: %v", diags)
}
if *got != *tc.want {
t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want)
}
})
}
}
func TestParseShow_invalid(t *testing.T) {
testCases := map[string]struct {
args []string
want *Show
wantDiags tfdiags.Diagnostics
}{
"unknown flag": {
[]string{"-boop"},
&Show{
Path: "",
ViewType: ViewHuman,
},
tfdiags.Diagnostics{
tfdiags.Sourceless(
tfdiags.Error,
"Failed to parse command-line flags",
"flag provided but not defined: -boop",
),
},
},
"too many arguments": {
[]string{"-json", "bar", "baz"},
&Show{
Path: "bar",
ViewType: ViewJSON,
},
tfdiags.Diagnostics{
tfdiags.Sourceless(
tfdiags.Error,
"Too many command line arguments",
"Expected at most one positional argument.",
),
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got, gotDiags := ParseShow(tc.args)
if *got != *tc.want {
t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want)
}
if !reflect.DeepEqual(gotDiags, tc.wantDiags) {
t.Errorf("wrong result\ngot: %s\nwant: %s", spew.Sdump(gotDiags), spew.Sdump(tc.wantDiags))
}
})
}
}

View File

@ -7,14 +7,13 @@ import (
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/command/jsonplan"
"github.com/hashicorp/terraform/internal/command/jsonstate"
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/plans/planfile"
"github.com/hashicorp/terraform/internal/states/statefile"
"github.com/hashicorp/terraform/internal/states/statemgr"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
)
@ -24,174 +23,40 @@ type ShowCommand struct {
Meta
}
func (c *ShowCommand) Run(args []string) int {
args = c.Meta.process(args)
cmdFlags := c.Meta.defaultFlagSet("show")
var jsonOutput bool
cmdFlags.BoolVar(&jsonOutput, "json", false, "produce JSON output")
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()))
func (c *ShowCommand) Run(rawArgs []string) int {
// 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
}
args = cmdFlags.Args()
if len(args) > 2 {
c.Ui.Error(
"The show command expects at most two arguments.\n The path to a " +
"Terraform state or plan file, and optionally -json for json output.\n")
cmdFlags.Usage()
return 1
}
// 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 {
c.Ui.Error(fmt.Sprintf("Error loading plugin path: %s", err))
diags = diags.Append(fmt.Errorf("error loading plugin path: %s", err))
view.Diagnostics(diags)
return 1
}
var diags tfdiags.Diagnostics
// Load the backend
b, backendDiags := c.Backend(nil)
diags = diags.Append(backendDiags)
if backendDiags.HasErrors() {
c.showDiagnostics(diags)
// Get the data we need to display
plan, stateFile, config, schemas, showDiags := c.show(args.Path)
diags = diags.Append(showDiags)
if showDiags.HasErrors() {
view.Diagnostics(diags)
return 1
}
// We require a local backend
local, ok := b.(backend.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)
// the show command expects the config dir to always be the cwd
cwd, err := os.Getwd()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error getting cwd: %s", err))
return 1
}
// Determine if a planfile was passed to the command
var planFile *planfile.Reader
if len(args) > 0 {
// We will handle error checking later on - this is just required to
// load the local context if the given path is successfully read as
// a planfile.
planFile, _ = c.PlanFile(args[0])
}
// Build the operation
opReq := c.Operation(b)
opReq.ConfigDir = cwd
opReq.PlanFile = planFile
opReq.ConfigLoader, err = c.initConfigLoader()
opReq.AllowUnsetVariables = true
opReq.DisablePlanFileStateLineageChecks = 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
}
// Get the schemas from the context
schemas, moreDiags := lr.Core.Schemas(lr.Config, lr.InputState)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
c.showDiagnostics(diags)
return 1
}
var planErr, stateErr error
var plan *plans.Plan
var stateFile *statefile.File
// if a path was provided, try to read it as a path to a planfile
// if that fails, try to read the cli argument as a path to a statefile
if len(args) > 0 {
path := args[0]
plan, stateFile, planErr = getPlanFromPath(path)
if planErr != nil {
stateFile, stateErr = getStateFromPath(path)
if stateErr != nil {
c.Ui.Error(fmt.Sprintf(
"Terraform couldn't read the given file as a state or plan file.\n"+
"The errors while attempting to read the file as each format are\n"+
"shown below.\n\n"+
"State read error: %s\n\nPlan read error: %s",
stateErr,
planErr))
return 1
}
}
} else {
env, err := c.Workspace()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error selecting workspace: %s", err))
return 1
}
stateFile, stateErr = getStateFromEnv(b, env)
if stateErr != nil {
c.Ui.Error(stateErr.Error())
return 1
}
}
if plan != nil {
if jsonOutput {
config := lr.Config
jsonPlan, err := jsonplan.Marshal(config, plan, stateFile, schemas)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to marshal plan to json: %s", err))
return 1
}
c.Ui.Output(string(jsonPlan))
return 0
}
view := views.NewShow(arguments.ViewHuman, c.View)
view.Plan(plan, schemas)
return 0
}
if jsonOutput {
// At this point, it is possible that there is neither state nor a plan.
// That's ok, we'll just return an empty object.
jsonState, err := jsonstate.Marshal(stateFile, schemas)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to marshal state to json: %s", err))
return 1
}
c.Ui.Output(string(jsonState))
} else {
if stateFile == nil {
c.Ui.Output("No state.")
return 0
}
c.Ui.Output(format.State(&format.StateOpts{
State: stateFile.State,
Color: c.Colorize(),
Schemas: schemas,
}))
}
return 0
// Display the data
return view.Display(config, plan, stateFile, schemas)
}
func (c *ShowCommand) Help() string {
@ -215,52 +80,171 @@ func (c *ShowCommand) Synopsis() string {
return "Show the current state or a saved plan"
}
// getPlanFromPath returns a plan and statefile if the user-supplied path points
// to a planfile. If both plan and error are nil, the path is likely a
// directory. An error could suggest that the given path points to a statefile.
func getPlanFromPath(path string) (*plans.Plan, *statefile.File, error) {
pr, err := planfile.Open(path)
if err != nil {
return nil, nil, err
func (c *ShowCommand) show(path string) (*plans.Plan, *statefile.File, *configs.Config, *terraform.Schemas, tfdiags.Diagnostics) {
var diags, showDiags tfdiags.Diagnostics
var plan *plans.Plan
var stateFile *statefile.File
var config *configs.Config
var schemas *terraform.Schemas
// No plan file or state file argument provided,
// so get the latest state snapshot
if path == "" {
stateFile, showDiags = c.showFromLatestStateSnapshot()
diags = diags.Append(showDiags)
if showDiags.HasErrors() {
return plan, stateFile, config, schemas, diags
}
plan, err := pr.ReadPlan()
if err != nil {
return nil, nil, err
}
stateFile, err := pr.ReadStateFile()
return plan, stateFile, err
// Plan file or state file argument provided,
// so try to load the argument as a plan file first.
// If that fails, try to load it as a statefile.
if path != "" {
plan, stateFile, config, showDiags = c.showFromPath(path)
diags = diags.Append(showDiags)
if showDiags.HasErrors() {
return plan, stateFile, config, schemas, diags
}
}
// Get schemas, if possible
if config != nil || stateFile != nil {
opts, err := c.contextOpts()
if err != nil {
diags = diags.Append(err)
return plan, stateFile, config, schemas, diags
}
tfCtx, ctxDiags := terraform.NewContext(opts)
diags = diags.Append(ctxDiags)
if ctxDiags.HasErrors() {
return plan, stateFile, config, schemas, diags
}
var schemaDiags tfdiags.Diagnostics
schemas, schemaDiags = tfCtx.Schemas(config, stateFile.State)
diags = diags.Append(schemaDiags)
if schemaDiags.HasErrors() {
return plan, stateFile, config, schemas, diags
}
}
return plan, stateFile, config, schemas, diags
}
func (c *ShowCommand) showFromLatestStateSnapshot() (*statefile.File, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// Load the backend
b, backendDiags := c.Backend(nil)
diags = diags.Append(backendDiags)
if backendDiags.HasErrors() {
return nil, diags
}
c.ignoreRemoteVersionConflict(b)
// Load the workspace
workspace, err := c.Workspace()
if err != nil {
diags = diags.Append(fmt.Errorf("error selecting workspace: %s", err))
return nil, diags
}
// Get the latest state snapshot from the backend for the current workspace
stateFile, stateErr := getStateFromBackend(b, workspace)
if stateErr != nil {
diags = diags.Append(stateErr.Error())
return nil, diags
}
return stateFile, diags
}
func (c *ShowCommand) showFromPath(path string) (*plans.Plan, *statefile.File, *configs.Config, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
var planErr, stateErr error
var plan *plans.Plan
var stateFile *statefile.File
var config *configs.Config
// Try to get the plan file and associated data from
// the path argument. If that fails, try to get the
// statefile from the path argument.
plan, stateFile, config, planErr = getPlanFromPath(path)
if planErr != nil {
stateFile, stateErr = getStateFromPath(path)
if stateErr != nil {
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),
),
)
return nil, nil, nil, diags
}
}
return plan, stateFile, config, diags
}
// getPlanFromPath returns a plan, statefile, and config if the user-supplied
// path points to a plan file. If both plan and error are nil, the path is likely
// a directory. An error could suggest that the given path points to a statefile.
func getPlanFromPath(path string) (*plans.Plan, *statefile.File, *configs.Config, error) {
planReader, err := planfile.Open(path)
if err != nil {
return nil, nil, nil, err
}
// 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
}
// Get config
config, diags := planReader.ReadConfig()
if diags.HasErrors() {
return nil, nil, nil, diags.Err()
}
return plan, stateFile, config, err
}
// getStateFromPath returns a statefile if the user-supplied path points to a statefile.
func getStateFromPath(path string) (*statefile.File, error) {
f, err := os.Open(path)
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("Error loading statefile: %s", err)
}
defer f.Close()
defer file.Close()
var stateFile *statefile.File
stateFile, err = statefile.Read(f)
stateFile, err = statefile.Read(file)
if err != nil {
return nil, fmt.Errorf("Error reading %s as a statefile: %s", path, err)
}
return stateFile, nil
}
// getStateFromEnv returns the State for the current workspace, if available.
func getStateFromEnv(b backend.Backend, env string) (*statefile.File, error) {
// Get the state
stateStore, err := b.StateMgr(env)
// getStateFromBackend returns the State for the current workspace, if available.
func getStateFromBackend(b backend.Backend, workspace string) (*statefile.File, error) {
// Get the state store for the given workspace
stateStore, err := b.StateMgr(workspace)
if err != nil {
return nil, fmt.Errorf("Failed to load state manager: %s", err)
}
// Refresh the state store with the latest state snapshot from persistent storage
if err := stateStore.RefreshState(); err != nil {
return nil, fmt.Errorf("Failed to load state: %s", err)
}
sf := statemgr.Export(stateStore)
return sf, nil
// Get the latest state snapshot and return it
stateFile := statemgr.Export(stateStore)
return stateFile, nil
}

View File

@ -2,7 +2,6 @@ package command
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
@ -22,13 +21,11 @@ import (
"github.com/zclconf/go-cty/cty"
)
func TestShow(t *testing.T) {
ui := new(cli.MockUi)
view, _ := testView(t)
func TestShow_badArgs(t *testing.T) {
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
@ -36,40 +33,99 @@ func TestShow(t *testing.T) {
args := []string{
"bad",
"bad",
"-no-color",
}
if code := c.Run(args); code != 1 {
t.Fatalf("bad: \n%s", ui.OutputWriter.String())
code := c.Run(args)
output := done(t)
if code != 1 {
t.Fatalf("unexpected exit status %d; want 1\ngot: %s", code, output.Stdout())
}
}
func TestShow_noArgs(t *testing.T) {
func TestShow_noArgsNoState(t *testing.T) {
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
View: view,
},
}
code := c.Run([]string{})
output := done(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, output.Stderr())
}
got := output.Stdout()
want := `No state.`
if !strings.Contains(got, want) {
t.Fatalf("unexpected output\ngot: %s\nwant: %s", got, want)
}
}
func TestShow_noArgsWithState(t *testing.T) {
// Get a temp cwd
tmp, cwd := testCwd(t)
defer testFixCwd(t, tmp, cwd)
// Create the default state
testStateFileDefault(t, testState())
ui := new(cli.MockUi)
view, _ := testView(t)
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
if code := c.Run([]string{}); code != 0 {
t.Fatalf("bad: \n%s", ui.OutputWriter.String())
code := c.Run([]string{})
output := done(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, output.Stderr())
}
if !strings.Contains(ui.OutputWriter.String(), "# test_instance.foo:") {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
got := output.Stdout()
want := `# test_instance.foo:`
if !strings.Contains(got, want) {
t.Fatalf("unexpected output\ngot: %s\nwant: %s", got, want)
}
}
func TestShow_argsWithState(t *testing.T) {
// Create the default state
statePath := testStateFile(t, testState())
stateDir := filepath.Dir(statePath)
defer os.RemoveAll(stateDir)
defer testChdir(t, stateDir)()
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
View: view,
},
}
path := filepath.Base(statePath)
args := []string{
path,
"-no-color",
}
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, output.Stderr())
}
}
// https://github.com/hashicorp/terraform/issues/21462
func TestShow_aliasedProvider(t *testing.T) {
func TestShow_argsWithStateAliasedProvider(t *testing.T) {
// Create the default state with aliased resource
testState := states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(
@ -95,103 +151,198 @@ func TestShow_aliasedProvider(t *testing.T) {
defer os.RemoveAll(stateDir)
defer testChdir(t, stateDir)()
ui := new(cli.MockUi)
view, _ := testView(t)
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
// the statefile created by testStateFile is named state.tfstate
args := []string{"state.tfstate"}
if code := c.Run(args); code != 0 {
t.Fatalf("bad exit code: \n%s", ui.OutputWriter.String())
path := filepath.Base(statePath)
args := []string{
path,
"-no-color",
}
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, output.Stderr())
}
if strings.Contains(ui.OutputWriter.String(), "# missing schema for provider \"test.alias\"") {
t.Fatalf("bad output: \n%s", ui.OutputWriter.String())
got := output.Stdout()
want := `# missing schema for provider \"test.alias\"`
if strings.Contains(got, want) {
t.Fatalf("unexpected output\ngot: %s", got)
}
}
func TestShow_noArgsNoState(t *testing.T) {
// Create the default state
statePath := testStateFile(t, testState())
stateDir := filepath.Dir(statePath)
defer os.RemoveAll(stateDir)
defer testChdir(t, stateDir)()
ui := new(cli.MockUi)
view, _ := testView(t)
func TestShow_argsPlanFileDoesNotExist(t *testing.T) {
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
// the statefile created by testStateFile is named state.tfstate
args := []string{"state.tfstate"}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.OutputWriter.String())
args := []string{
"doesNotExist.tfplan",
"-no-color",
}
code := c.Run(args)
output := done(t)
if code != 1 {
t.Fatalf("unexpected exit status %d; want 1\ngot: %s", code, output.Stdout())
}
got := output.Stderr()
want := `Plan read error: open doesNotExist.tfplan:`
if !strings.Contains(got, want) {
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want)
}
}
func TestShow_argsStatefileDoesNotExist(t *testing.T) {
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
View: view,
},
}
args := []string{
"doesNotExist.tfstate",
"-no-color",
}
code := c.Run(args)
output := done(t)
if code != 1 {
t.Fatalf("unexpected exit status %d; want 1\ngot: %s", code, output.Stdout())
}
got := output.Stderr()
want := `State read error: Error loading statefile:`
if !strings.Contains(got, want) {
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want)
}
}
func TestShow_json_argsPlanFileDoesNotExist(t *testing.T) {
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
View: view,
},
}
args := []string{
"-json",
"doesNotExist.tfplan",
"-no-color",
}
code := c.Run(args)
output := done(t)
if code != 1 {
t.Fatalf("unexpected exit status %d; want 1\ngot: %s", code, output.Stdout())
}
got := output.Stderr()
want := `Plan read error: open doesNotExist.tfplan:`
if !strings.Contains(got, want) {
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want)
}
}
func TestShow_json_argsStatefileDoesNotExist(t *testing.T) {
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
View: view,
},
}
args := []string{
"-json",
"doesNotExist.tfstate",
"-no-color",
}
code := c.Run(args)
output := done(t)
if code != 1 {
t.Fatalf("unexpected exit status %d; want 1\ngot: %s", code, output.Stdout())
}
got := output.Stderr()
want := `State read error: Error loading statefile:`
if !strings.Contains(got, want) {
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want)
}
}
func TestShow_planNoop(t *testing.T) {
planPath := testPlanFileNoop(t)
ui := cli.NewMockUi()
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
args := []string{
planPath,
"-no-color",
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, output.Stderr())
}
got := output.Stdout()
want := `No changes. Your infrastructure matches the configuration.`
got := done(t).Stdout()
if !strings.Contains(got, want) {
t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got)
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want)
}
}
func TestShow_planWithChanges(t *testing.T) {
planPathWithChanges := showFixturePlanFile(t, plans.DeleteThenCreate)
ui := cli.NewMockUi()
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(showFixtureProvider()),
Ui: ui,
View: view,
},
}
args := []string{
planPathWithChanges,
"-no-color",
}
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, output.Stderr())
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
}
got := output.Stdout()
want := `test_instance.foo must be replaced`
got := done(t).Stdout()
if !strings.Contains(got, want) {
t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got)
t.Fatalf("unexpected output\ngot: %s\nwant: %s", got, want)
}
}
@ -239,30 +390,34 @@ func TestShow_planWithForceReplaceChange(t *testing.T) {
plan,
)
ui := cli.NewMockUi()
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(showFixtureProvider()),
Ui: ui,
View: view,
},
}
args := []string{
planFilePath,
"-no-color",
}
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, output.Stderr())
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
got := output.Stdout()
want := `test_instance.foo will be replaced, as requested`
if !strings.Contains(got, want) {
t.Fatalf("unexpected output\ngot: %s\nwant: %s", got, want)
}
got := done(t).Stdout()
if want := `test_instance.foo will be replaced, as requested`; !strings.Contains(got, want) {
t.Errorf("wrong output\ngot:\n%s\n\nwant substring: %s", got, want)
}
if want := `Plan: 1 to add, 0 to change, 1 to destroy.`; !strings.Contains(got, want) {
t.Errorf("wrong output\ngot:\n%s\n\nwant substring: %s", got, want)
want = `Plan: 1 to add, 0 to change, 1 to destroy.`
if !strings.Contains(got, want) {
t.Fatalf("unexpected output\ngot: %s\nwant: %s", got, want)
}
}
@ -270,12 +425,10 @@ func TestShow_planWithForceReplaceChange(t *testing.T) {
func TestShow_plan_json(t *testing.T) {
planPath := showFixturePlanFile(t, plans.Create)
ui := new(cli.MockUi)
view, _ := testView(t)
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(showFixtureProvider()),
Ui: ui,
View: view,
},
}
@ -283,9 +436,13 @@ func TestShow_plan_json(t *testing.T) {
args := []string{
"-json",
planPath,
"-no-color",
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, output.Stderr())
}
}
@ -294,21 +451,23 @@ func TestShow_state(t *testing.T) {
statePath := testStateFile(t, originalState)
defer os.RemoveAll(filepath.Dir(statePath))
ui := new(cli.MockUi)
view, _ := testView(t)
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
args := []string{
statePath,
"-no-color",
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, output.Stderr())
}
}
@ -339,18 +498,15 @@ func TestShow_json_output(t *testing.T) {
defer close()
p := showFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
m := Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
ProviderSource: providerSource,
}
// init
ui := new(cli.MockUi)
ic := &InitCommand{
Meta: m,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
ProviderSource: providerSource,
},
}
if code := ic.Run([]string{}); code != 0 {
if expectError {
@ -360,22 +516,35 @@ func TestShow_json_output(t *testing.T) {
t.Fatalf("init failed\n%s", ui.ErrorWriter)
}
// plan
planView, planDone := testView(t)
pc := &PlanCommand{
Meta: m,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
View: planView,
ProviderSource: providerSource,
},
}
args := []string{
"-out=terraform.plan",
}
if code := pc.Run(args); code != 0 {
t.Fatalf("wrong exit status %d; want 0\nstderr: %s", code, ui.ErrorWriter.String())
code := pc.Run(args)
planOutput := planDone(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, planOutput.Stderr())
}
// flush the plan output from the mock ui
ui.OutputWriter.Reset()
// show
showView, showDone := testView(t)
sc := &ShowCommand{
Meta: m,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
View: showView,
ProviderSource: providerSource,
},
}
args = []string{
@ -383,25 +552,27 @@ func TestShow_json_output(t *testing.T) {
"terraform.plan",
}
defer os.Remove("terraform.plan")
code = sc.Run(args)
showOutput := showDone(t)
if code := sc.Run(args); code != 0 {
t.Fatalf("wrong exit status %d; want 0\nstderr: %s", code, ui.ErrorWriter.String())
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, showOutput.Stderr())
}
// compare ui output to wanted output
// compare view output to wanted output
var got, want plan
gotString := ui.OutputWriter.String()
gotString := showOutput.Stdout()
json.Unmarshal([]byte(gotString), &got)
wantFile, err := os.Open("output.json")
if err != nil {
t.Fatalf("err: %s", err)
t.Fatalf("unexpected err: %s", err)
}
defer wantFile.Close()
byteValue, err := ioutil.ReadAll(wantFile)
if err != nil {
t.Fatalf("err: %s", err)
t.Fatalf("unexpected err: %s", err)
}
json.Unmarshal([]byte(byteValue), &want)
@ -423,43 +594,48 @@ func TestShow_json_output_sensitive(t *testing.T) {
defer close()
p := showFixtureSensitiveProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
m := Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
ProviderSource: providerSource,
}
// init
ui := new(cli.MockUi)
ic := &InitCommand{
Meta: m,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
ProviderSource: providerSource,
},
}
if code := ic.Run([]string{}); code != 0 {
t.Fatalf("init failed\n%s", ui.ErrorWriter)
}
// flush init output
ui.OutputWriter.Reset()
// plan
planView, planDone := testView(t)
pc := &PlanCommand{
Meta: m,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
View: planView,
ProviderSource: providerSource,
},
}
args := []string{
"-out=terraform.plan",
}
code := pc.Run(args)
planOutput := planDone(t)
if code := pc.Run(args); code != 0 {
fmt.Println(ui.OutputWriter.String())
t.Fatalf("wrong exit status %d; want 0\nstderr: %s", code, ui.ErrorWriter.String())
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, planOutput.Stderr())
}
// flush the plan output from the mock ui
ui.OutputWriter.Reset()
// show
showView, showDone := testView(t)
sc := &ShowCommand{
Meta: m,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
View: showView,
ProviderSource: providerSource,
},
}
args = []string{
@ -467,25 +643,27 @@ func TestShow_json_output_sensitive(t *testing.T) {
"terraform.plan",
}
defer os.Remove("terraform.plan")
code = sc.Run(args)
showOutput := showDone(t)
if code := sc.Run(args); code != 0 {
t.Fatalf("wrong exit status %d; want 0\nstderr: %s", code, ui.ErrorWriter.String())
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, showOutput.Stderr())
}
// compare ui output to wanted output
var got, want plan
gotString := ui.OutputWriter.String()
gotString := showOutput.Stdout()
json.Unmarshal([]byte(gotString), &got)
wantFile, err := os.Open("output.json")
if err != nil {
t.Fatalf("err: %s", err)
t.Fatalf("unexpected err: %s", err)
}
defer wantFile.Close()
byteValue, err := ioutil.ReadAll(wantFile)
if err != nil {
t.Fatalf("err: %s", err)
t.Fatalf("unexpected err: %s", err)
}
json.Unmarshal([]byte(byteValue), &want)
@ -520,31 +698,35 @@ func TestShow_json_output_state(t *testing.T) {
defer close()
p := showFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
m := Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
ProviderSource: providerSource,
}
// init
ui := new(cli.MockUi)
ic := &InitCommand{
Meta: m,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
ProviderSource: providerSource,
},
}
if code := ic.Run([]string{}); code != 0 {
t.Fatalf("init failed\n%s", ui.ErrorWriter)
}
// flush the plan output from the mock ui
ui.OutputWriter.Reset()
// show
showView, showDone := testView(t)
sc := &ShowCommand{
Meta: m,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
View: showView,
ProviderSource: providerSource,
},
}
if code := sc.Run([]string{"-json"}); code != 0 {
t.Fatalf("wrong exit status %d; want 0\nstderr: %s", code, ui.ErrorWriter.String())
code := sc.Run([]string{"-json"})
showOutput := showDone(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, showOutput.Stderr())
}
// compare ui output to wanted output
@ -556,17 +738,17 @@ func TestShow_json_output_state(t *testing.T) {
}
var got, want state
gotString := ui.OutputWriter.String()
gotString := showOutput.Stdout()
json.Unmarshal([]byte(gotString), &got)
wantFile, err := os.Open("output.json")
if err != nil {
t.Fatalf("err: %s", err)
t.Fatalf("unexpected error: %s", err)
}
defer wantFile.Close()
byteValue, err := ioutil.ReadAll(wantFile)
if err != nil {
t.Fatalf("err: %s", err)
t.Fatalf("unexpected err: %s", err)
}
json.Unmarshal([]byte(byteValue), &want)
@ -599,27 +781,29 @@ func TestShow_planWithNonDefaultStateLineage(t *testing.T) {
}
planPath := testPlanFileMatchState(t, snap, state, plan, stateMeta)
ui := cli.NewMockUi()
view, done := testView(t)
c := &ShowCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
args := []string{
planPath,
"-no-color",
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("unexpected exit status %d; want 0\ngot: %s", code, output.Stderr())
}
got := output.Stdout()
want := `No changes. Your infrastructure matches the configuration.`
got := done(t).Stdout()
if !strings.Contains(got, want) {
t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got)
t.Fatalf("unexpected output\ngot: %s\nwant: %s", got, want)
}
}

View File

@ -0,0 +1,3 @@
{
"format_version": "1.0"
}

View File

@ -2,37 +2,95 @@ package views
import (
"fmt"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/command/jsonplan"
"github.com/hashicorp/terraform/internal/command/jsonstate"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states/statefile"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
)
// FIXME: this is a temporary partial definition of the view for the show
// command, in place to allow access to the plan renderer which is now in the
// views package.
type Show interface {
Plan(plan *plans.Plan, schemas *terraform.Schemas)
// Display renders the plan, if it is available. If plan is nil, it renders the statefile.
Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int
// Diagnostics renders early diagnostics, resulting from argument parsing.
Diagnostics(diags tfdiags.Diagnostics)
}
// FIXME: the show view should support both human and JSON types. This code is
// currently only used to render the plan in human-readable UI, so does not yet
// support JSON.
func NewShow(vt arguments.ViewType, view *View) Show {
switch vt {
case arguments.ViewJSON:
return &ShowJSON{view: view}
case arguments.ViewHuman:
return &ShowHuman{View: *view}
return &ShowHuman{view: view}
default:
panic(fmt.Sprintf("unknown view type %v", vt))
}
}
type ShowHuman struct {
View
view *View
}
var _ Show = (*ShowHuman)(nil)
func (v *ShowHuman) Plan(plan *plans.Plan, schemas *terraform.Schemas) {
renderPlan(plan, schemas, &v.View)
func (v *ShowHuman) Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int {
if plan != nil {
renderPlan(plan, schemas, v.view)
} else {
if stateFile == nil {
v.view.streams.Println("No state.")
return 0
}
v.view.streams.Println(format.State(&format.StateOpts{
State: stateFile.State,
Color: v.view.colorize,
Schemas: schemas,
}))
}
return 0
}
func (v *ShowHuman) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags)
}
type ShowJSON struct {
view *View
}
var _ Show = (*ShowJSON)(nil)
func (v *ShowJSON) Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int {
if plan != nil {
jsonPlan, err := jsonplan.Marshal(config, plan, stateFile, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal plan to json: %s", err)
return 1
}
v.view.streams.Println(string(jsonPlan))
} else {
// It is possible that there is neither state nor a plan.
// That's ok, we'll just return an empty object.
jsonState, err := jsonstate.Marshal(stateFile, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal state to json: %s", err)
return 1
}
v.view.streams.Println(string(jsonState))
}
return 0
}
// Diagnostics should only be called if show cannot be executed.
// In this case, we choose to render human-readable diagnostic output,
// primarily for backwards compatibility.
func (v *ShowJSON) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags)
}

View File

@ -0,0 +1,184 @@
package views
import (
"encoding/json"
"strings"
"testing"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/initwd"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/states/statefile"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/zclconf/go-cty/cty"
)
func TestShowHuman(t *testing.T) {
testCases := map[string]struct {
plan *plans.Plan
stateFile *statefile.File
schemas *terraform.Schemas
wantExact bool
wantString string
}{
"plan file": {
testPlan(t),
nil,
testSchemas(),
false,
"# test_resource.foo will be created",
},
"statefile": {
nil,
&statefile.File{
Serial: 0,
Lineage: "fake-for-testing",
State: testState(),
},
testSchemas(),
false,
"# test_resource.foo:",
},
"empty statefile": {
nil,
&statefile.File{
Serial: 0,
Lineage: "fake-for-testing",
State: states.NewState(),
},
testSchemas(),
true,
"\n",
},
"nothing": {
nil,
nil,
nil,
true,
"No state.\n",
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
view := NewView(streams)
view.Configure(&arguments.View{NoColor: true})
v := NewShow(arguments.ViewHuman, view)
code := v.Display(nil, testCase.plan, testCase.stateFile, testCase.schemas)
if code != 0 {
t.Errorf("expected 0 return code, got %d", code)
}
output := done(t)
got := output.Stdout()
want := testCase.wantString
if (testCase.wantExact && got != want) || (!testCase.wantExact && !strings.Contains(got, want)) {
t.Fatalf("unexpected output\ngot: %s\nwant: %s", got, want)
}
})
}
}
func TestShowJSON(t *testing.T) {
testCases := map[string]struct {
plan *plans.Plan
stateFile *statefile.File
}{
"plan file": {
testPlan(t),
nil,
},
"statefile": {
nil,
&statefile.File{
Serial: 0,
Lineage: "fake-for-testing",
State: testState(),
},
},
"empty statefile": {
nil,
&statefile.File{
Serial: 0,
Lineage: "fake-for-testing",
State: states.NewState(),
},
},
"nothing": {
nil,
nil,
},
}
config, _, configCleanup := initwd.MustLoadConfigForTests(t, "./testdata/show")
defer configCleanup()
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
view := NewView(streams)
view.Configure(&arguments.View{NoColor: true})
v := NewShow(arguments.ViewJSON, view)
schemas := &terraform.Schemas{
Providers: map[addrs.Provider]*terraform.ProviderSchema{
addrs.NewDefaultProvider("test"): {
ResourceTypes: map[string]*configschema.Block{
"test_resource": {
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Optional: true, Computed: true},
"foo": {Type: cty.String, Optional: true},
},
},
},
},
},
}
code := v.Display(config, testCase.plan, testCase.stateFile, schemas)
if code != 0 {
t.Errorf("expected 0 return code, got %d", code)
}
// Make sure the result looks like JSON; we comprehensively test
// the structure of this output in the command package tests.
var result map[string]interface{}
got := done(t).All()
t.Logf("output: %s", got)
if err := json.Unmarshal([]byte(got), &result); err != nil {
t.Fatal(err)
}
})
}
}
// testState returns a test State structure.
func testState() *states.State {
return states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
AttrsJSON: []byte(`{"id":"bar","foo":"value"}`),
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
)
// DeepCopy is used here to ensure our synthetic state matches exactly
// with a state that will have been copied during the command
// operation, and all fields have been copied correctly.
}).DeepCopy()
}

View File

@ -0,0 +1,3 @@
resource "test_resource" "foo" {
foo = "value"
}