From fea8f6cfa220e3db34b30d8c21345aa0fa6f01e4 Mon Sep 17 00:00:00 2001 From: Krista LaFentres Date: Mon, 10 Jan 2022 17:16:12 -0600 Subject: [PATCH] cli: Migrate show command to use command arguments and views --- internal/command/arguments/show.go | 59 +++ internal/command/arguments/show_test.go | 99 ++++ internal/command/show.go | 256 +++++----- internal/command/show_test.go | 466 ++++++++++++------ .../show-json-state/empty/terraform.tfstate | 0 .../show-json-state/no-state/output.json | 3 + internal/command/views/show.go | 82 ++- internal/command/views/show_test.go | 184 +++++++ internal/command/views/testdata/show/main.tf | 3 + 9 files changed, 871 insertions(+), 281 deletions(-) create mode 100644 internal/command/arguments/show.go create mode 100644 internal/command/arguments/show_test.go create mode 100644 internal/command/testdata/show-json-state/empty/terraform.tfstate create mode 100644 internal/command/testdata/show-json-state/no-state/output.json create mode 100644 internal/command/views/show_test.go create mode 100644 internal/command/views/testdata/show/main.tf diff --git a/internal/command/arguments/show.go b/internal/command/arguments/show.go new file mode 100644 index 000000000..4d95fc1da --- /dev/null +++ b/internal/command/arguments/show.go @@ -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 +} diff --git a/internal/command/arguments/show_test.go b/internal/command/arguments/show_test.go new file mode 100644 index 000000000..5088e1a94 --- /dev/null +++ b/internal/command/arguments/show_test.go @@ -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)) + } + }) + } +} diff --git a/internal/command/show.go b/internal/command/show.go index c3c88ebf6..0b16ee735 100644 --- a/internal/command/show.go +++ b/internal/command/show.go @@ -7,9 +7,6 @@ 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" @@ -26,142 +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 - - var planErr, stateErr error - var plan *plans.Plan - var stateFile *statefile.File - var config *configs.Config - var schemas *terraform.Schemas - - // 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, config, 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 { - // Load the backend - b, backendDiags := c.Backend(nil) - diags = diags.Append(backendDiags) - if backendDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - c.ignoreRemoteVersionConflict(b) - - workspace, err := c.Workspace() - if err != nil { - c.Ui.Error(fmt.Sprintf("Error selecting workspace: %s", err)) - return 1 - } - stateFile, stateErr = getStateFromBackend(b, workspace) - if stateErr != nil { - c.Ui.Error(stateErr.Error()) - return 1 - } + // 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 } - if config != nil || stateFile != nil { - opts, err := c.contextOpts() - if err != nil { - diags = diags.Append(err) - c.showDiagnostics(diags) - return 1 - } - tfCtx, ctxDiags := terraform.NewContext(opts) - diags = diags.Append(ctxDiags) - if ctxDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - var schemaDiags tfdiags.Diagnostics - schemas, schemaDiags = tfCtx.Schemas(config, stateFile.State) - diags = diags.Append(schemaDiags) - if schemaDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - } - - if plan != nil { - if jsonOutput { - 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 { @@ -185,8 +80,113 @@ func (c *ShowCommand) Synopsis() string { return "Show the current state or a saved plan" } +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 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 planfile. If both plan and error are nil, the path is likely +// 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) diff --git a/internal/command/show_test.go b/internal/command/show_test.go index 25504d2c2..5f220e906 100644 --- a/internal/command/show_test.go +++ b/internal/command/show_test.go @@ -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) } } diff --git a/internal/command/testdata/show-json-state/empty/terraform.tfstate b/internal/command/testdata/show-json-state/empty/terraform.tfstate new file mode 100644 index 000000000..e69de29bb diff --git a/internal/command/testdata/show-json-state/no-state/output.json b/internal/command/testdata/show-json-state/no-state/output.json new file mode 100644 index 000000000..381450cad --- /dev/null +++ b/internal/command/testdata/show-json-state/no-state/output.json @@ -0,0 +1,3 @@ +{ + "format_version": "1.0" +} diff --git a/internal/command/views/show.go b/internal/command/views/show.go index bfc45a4ce..1ab16c2d5 100644 --- a/internal/command/views/show.go +++ b/internal/command/views/show.go @@ -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) } diff --git a/internal/command/views/show_test.go b/internal/command/views/show_test.go new file mode 100644 index 000000000..fe69130d9 --- /dev/null +++ b/internal/command/views/show_test.go @@ -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() +} diff --git a/internal/command/views/testdata/show/main.tf b/internal/command/views/testdata/show/main.tf new file mode 100644 index 000000000..e1cca23dd --- /dev/null +++ b/internal/command/views/testdata/show/main.tf @@ -0,0 +1,3 @@ +resource "test_resource" "foo" { + foo = "value" +}