diff --git a/command/arguments/plan.go b/command/arguments/plan.go new file mode 100644 index 000000000..93de15c29 --- /dev/null +++ b/command/arguments/plan.go @@ -0,0 +1,75 @@ +package arguments + +import ( + "github.com/hashicorp/terraform/tfdiags" +) + +// Plan represents the command-line arguments for the plan command. +type Plan struct { + // State, Operation, and Vars are the common extended flags + State *State + Operation *Operation + Vars *Vars + + // Destroy can be set to generate a plan to destroy all infrastructure. + Destroy bool + + // DetailedExitCode enables different exit codes for error, success with + // changes, and success with no changes. + DetailedExitCode bool + + // InputEnabled is used to disable interactive input for unspecified + // variable and backend config values. Default is true. + InputEnabled bool + + // OutPath contains an optional path to store the plan file + OutPath string + + // ViewType specifies which output format to use + ViewType ViewType +} + +// ParsePlan processes CLI arguments, returning a Plan value and errors. +// If errors are encountered, a Plan value is still returned representing +// the best effort interpretation of the arguments. +func ParsePlan(args []string) (*Plan, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + plan := &Plan{ + State: &State{}, + Operation: &Operation{}, + Vars: &Vars{}, + } + + cmdFlags := extendedFlagSet("plan", plan.State, plan.Operation, plan.Vars) + cmdFlags.BoolVar(&plan.Destroy, "destroy", false, "destroy") + cmdFlags.BoolVar(&plan.DetailedExitCode, "detailed-exitcode", false, "detailed-exitcode") + cmdFlags.BoolVar(&plan.InputEnabled, "input", true, "input") + cmdFlags.StringVar(&plan.OutPath, "out", "", "out") + + 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) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "To specify a working directory for the plan, use the global -chdir flag.", + )) + } + + diags = diags.Append(plan.Operation.Parse()) + + switch { + default: + plan.ViewType = ViewHuman + } + + return plan, diags +} diff --git a/command/arguments/plan_test.go b/command/arguments/plan_test.go new file mode 100644 index 000000000..aa0af925e --- /dev/null +++ b/command/arguments/plan_test.go @@ -0,0 +1,179 @@ +package arguments + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/addrs" +) + +func TestParsePlan_basicValid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Plan + }{ + "defaults": { + nil, + &Plan{ + Destroy: false, + DetailedExitCode: false, + InputEnabled: true, + OutPath: "", + ViewType: ViewHuman, + }, + }, + "setting all options": { + []string{"-destroy", "-detailed-exitcode", "-input=false", "-out=saved.tfplan"}, + &Plan{ + Destroy: true, + DetailedExitCode: true, + InputEnabled: false, + OutPath: "saved.tfplan", + ViewType: ViewHuman, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParsePlan(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + // Ignore the extended arguments for simplicity + got.State = nil + got.Operation = nil + got.Vars = nil + if *got != *tc.want { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + }) + } +} + +func TestParsePlan_invalid(t *testing.T) { + got, diags := ParsePlan([]string{"-frob"}) + if len(diags) == 0 { + t.Fatal("expected diags but got none") + } + if got, want := diags.Err().Error(), "flag provided but not defined"; !strings.Contains(got, want) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, want) + } + if got.ViewType != ViewHuman { + t.Fatalf("wrong view type, got %#v, want %#v", got.ViewType, ViewHuman) + } +} + +func TestParsePlan_tooManyArguments(t *testing.T) { + got, diags := ParsePlan([]string{"saved.tfplan"}) + if len(diags) == 0 { + t.Fatal("expected diags but got none") + } + if got, want := diags.Err().Error(), "Too many command line arguments"; !strings.Contains(got, want) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, want) + } + if got.ViewType != ViewHuman { + t.Fatalf("wrong view type, got %#v, want %#v", got.ViewType, ViewHuman) + } +} + +func TestParsePlan_targets(t *testing.T) { + foobarbaz, _ := addrs.ParseTargetStr("foo_bar.baz") + boop, _ := addrs.ParseTargetStr("module.boop") + testCases := map[string]struct { + args []string + want []addrs.Targetable + wantErr string + }{ + "no targets by default": { + args: nil, + want: nil, + }, + "one target": { + args: []string{"-target=foo_bar.baz"}, + want: []addrs.Targetable{foobarbaz.Subject}, + }, + "two targets": { + args: []string{"-target=foo_bar.baz", "-target", "module.boop"}, + want: []addrs.Targetable{foobarbaz.Subject, boop.Subject}, + }, + "invalid traversal": { + args: []string{"-target=foo."}, + want: nil, + wantErr: "Dot must be followed by attribute name", + }, + "invalid target": { + args: []string{"-target=data[0].foo"}, + want: nil, + wantErr: "A data source name is required", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParsePlan(tc.args) + if len(diags) > 0 { + if tc.wantErr == "" { + t.Fatalf("unexpected diags: %v", diags) + } else if got := diags.Err().Error(); !strings.Contains(got, tc.wantErr) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, tc.wantErr) + } + } + if !cmp.Equal(got.Operation.Targets, tc.want) { + t.Fatalf("unexpected result\n%s", cmp.Diff(got.Operation.Targets, tc.want)) + } + }) + } +} + +func TestParsePlan_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "no var flags by default": { + args: nil, + want: nil, + }, + "one var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "one var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "ordering preserved": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParsePlan(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected result\n%s", cmp.Diff(vars, tc.want)) + } + if got, want := got.Vars.Empty(), len(tc.want) == 0; got != want { + t.Fatalf("expected Empty() to return %t, but was %t", want, got) + } + }) + } +} diff --git a/command/plan.go b/command/plan.go index 7b73d0ce6..aa3ea87a8 100644 --- a/command/plan.go +++ b/command/plan.go @@ -7,8 +7,6 @@ import ( "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/views" - "github.com/hashicorp/terraform/configs" - "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" ) @@ -18,112 +16,176 @@ type PlanCommand struct { Meta } -func (c *PlanCommand) Run(args []string) int { - var destroy, refresh, detailed bool - var outPath string +func (c *PlanCommand) Run(rawArgs []string) int { + // Parse and apply global view arguments + common, rawArgs := arguments.ParseView(rawArgs) + c.View.Configure(common) - args = c.Meta.process(args) - cmdFlags := c.Meta.extendedFlagSet("plan") - cmdFlags.BoolVar(&destroy, "destroy", false, "destroy") - cmdFlags.BoolVar(&refresh, "refresh", true, "refresh") - cmdFlags.StringVar(&outPath, "out", "", "path") - cmdFlags.IntVar(&c.Meta.parallelism, "parallelism", DefaultParallelism, "parallelism") - cmdFlags.StringVar(&c.Meta.statePath, "state", "", "path") - cmdFlags.BoolVar(&detailed, "detailed-exitcode", false, "detailed-exitcode") - cmdFlags.BoolVar(&c.Meta.stateLock, "lock", true, "lock state") - cmdFlags.DurationVar(&c.Meta.stateLockTimeout, "lock-timeout", 0, "lock timeout") - cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } - if err := cmdFlags.Parse(args); err != nil { - c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) - return 1 - } + // Parse and validate flags + args, diags := arguments.ParsePlan(rawArgs) + + // Instantiate the view, even if there are flag errors, so that we render + // diagnostics according to the desired view + view := views.NewPlan(args.ViewType, c.RunningInAutomation, c.View) - diags := c.parseTargetFlags() if diags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - - configPath, err := ModulePath(cmdFlags.Args()) - if err != nil { - c.Ui.Error(err.Error()) + view.Diagnostics(diags) + view.HelpPrompt("plan") return 1 } // 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(err) + view.Diagnostics(diags) return 1 } - var backendConfig *configs.Backend - var configDiags tfdiags.Diagnostics - backendConfig, configDiags = c.loadBackendConfig(configPath) - diags = diags.Append(configDiags) - if configDiags.HasErrors() { - c.showDiagnostics(diags) + // FIXME: the -input flag value is needed to initialize the backend and the + // operation, but there is no clear path to pass this value down, so we + // continue to mutate the Meta object state for now. + c.Meta.input = args.InputEnabled + + // FIXME: the -parallelism flag is used to control the concurrency of + // Terraform operations. At the moment, this value is used both to + // initialize the backend via the ContextOpts field inside CLIOpts, and to + // set a largely unused field on the Operation request. Again, there is no + // clear path to pass this value down, so we continue to mutate the Meta + // object state for now. + c.Meta.parallelism = args.Operation.Parallelism + + diags = diags.Append(c.providerDevOverrideRuntimeWarnings()) + + // Prepare the backend with the backend-specific arguments + be, beDiags := c.PrepareBackend(args.State) + diags = diags.Append(beDiags) + if diags.HasErrors() { + view.Diagnostics(diags) return 1 } - // Load the backend - b, backendDiags := c.Backend(&BackendOpts{ - Config: backendConfig, - }) - diags = diags.Append(backendDiags) - if backendDiags.HasErrors() { - c.showDiagnostics(diags) + // Build the operation request + opReq, opDiags := c.OperationRequest(be, view, args.Operation, args.Destroy, args.OutPath) + diags = diags.Append(opDiags) + if diags.HasErrors() { + view.Diagnostics(diags) return 1 } - // Emit any diagnostics we've accumulated before we delegate to the - // backend, since the backend will handle its own diagnostics internally. - c.showDiagnostics(diags) + // Collect variable value and add them to the operation request + diags = diags.Append(c.GatherVariables(opReq, args.Vars)) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + + // Before we delegate to the backend, we'll print any warning diagnostics + // we've accumulated here, since the backend will start fresh with its own + // diagnostics. + view.Diagnostics(diags) diags = nil - // Build the operation - opReq := c.Operation(b) - opReq.ConfigDir = configPath - opReq.Destroy = destroy - opReq.Hooks = []terraform.Hook{c.uiHook()} - opReq.PlanOutPath = outPath - opReq.PlanRefresh = refresh - opReq.ShowDiagnostics = c.showDiagnostics - opReq.Type = backend.OperationTypePlan - opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) - - opReq.ConfigLoader, err = c.initConfigLoader() - if err != nil { - c.showDiagnostics(err) - return 1 - } - - { - var moreDiags tfdiags.Diagnostics - opReq.Variables, moreDiags = c.collectVariableValues() - diags = diags.Append(moreDiags) - if moreDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - } - // Perform the operation - op, err := c.RunOperation(b, opReq) + op, err := c.RunOperation(be, opReq) if err != nil { - c.showDiagnostics(err) + diags = diags.Append(err) + view.Diagnostics(diags) return 1 } if op.Result != backend.OperationSuccess { return op.Result.ExitStatus() } - if detailed && !op.PlanEmpty { + if args.DetailedExitCode && !op.PlanEmpty { return 2 } return op.Result.ExitStatus() } +func (c *PlanCommand) PrepareBackend(args *arguments.State) (backend.Enhanced, tfdiags.Diagnostics) { + // FIXME: we need to apply the state arguments to the meta object here + // because they are later used when initializing the backend. Carving a + // path to pass these arguments to the functions that need them is + // difficult but would make their use easier to understand. + c.Meta.applyStateArguments(args) + + backendConfig, diags := c.loadBackendConfig(".") + if diags.HasErrors() { + return nil, diags + } + + // Load the backend + be, beDiags := c.Backend(&BackendOpts{ + Config: backendConfig, + }) + diags = diags.Append(beDiags) + if beDiags.HasErrors() { + return nil, diags + } + + return be, diags +} + +func (c *PlanCommand) OperationRequest( + be backend.Enhanced, + view views.Plan, + args *arguments.Operation, + destroy bool, + planOutPath string, +) (*backend.Operation, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + // Build the operation + opReq := c.Operation(be) + opReq.ConfigDir = "." + opReq.Destroy = destroy + opReq.Hooks = view.Hooks() + opReq.PlanRefresh = args.Refresh + opReq.PlanOutPath = planOutPath + opReq.Targets = args.Targets + opReq.Type = backend.OperationTypePlan + opReq.View = view.Operation() + // FIXME: this shim is needed until the remote backend is migrated to views + opReq.ShowDiagnostics = func(vals ...interface{}) { + var diags tfdiags.Diagnostics + diags = diags.Append(vals...) + view.Diagnostics(diags) + } + + var err error + opReq.ConfigLoader, err = c.initConfigLoader() + if err != nil { + diags = diags.Append(fmt.Errorf("Failed to initialize config loader: %s", err)) + return nil, diags + } + + return opReq, diags +} + +func (c *PlanCommand) GatherVariables(opReq *backend.Operation, args *arguments.Vars) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // FIXME the arguments package currently trivially gathers variable related + // arguments in a heterogenous 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} + opReq.Variables, diags = c.collectVariableValues() + + return diags +} + func (c *PlanCommand) Help() string { helpText := ` Usage: terraform plan [options] diff --git a/command/plan_test.go b/command/plan_test.go index 5cf53e276..001667348 100644 --- a/command/plan_test.go +++ b/command/plan_test.go @@ -12,7 +12,6 @@ import ( "time" "github.com/davecgh/go-spew/spew" - "github.com/mitchellh/cli" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/addrs" @@ -31,19 +30,19 @@ func TestPlan(t *testing.T) { defer testChdir(t, td)() p := planFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } } @@ -60,22 +59,21 @@ func TestPlan_lockedState(t *testing.T) { defer unlock() p := planFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } args := []string{} - if code := c.Run(args); code == 0 { - t.Fatal("expected error") + code := c.Run(args) + if code == 0 { + t.Fatal("expected error", done(t).Stdout()) } - output := ui.ErrorWriter.String() + output := done(t).Stderr() if !strings.Contains(output, "lock") { t.Fatal("command output does not look like a lock error:", output) } @@ -88,19 +86,19 @@ func TestPlan_plan(t *testing.T) { planPath := testPlanFileNoop(t) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } args := []string{planPath} - if code := c.Run(args); code != 1 { - t.Fatalf("wrong exit status %d; want 1\nstderr: %s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("wrong exit status %d; want 1\nstderr: %s", code, output.Stderr()) } } @@ -131,12 +129,10 @@ func TestPlan_destroy(t *testing.T) { statePath := testStateFile(t, originalState) p := planFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -146,8 +142,10 @@ func TestPlan_destroy(t *testing.T) { "-out", outPath, "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } plan := testReadPlan(t, outPath) @@ -165,19 +163,19 @@ func TestPlan_noState(t *testing.T) { defer testChdir(t, td)() p := planFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } // Verify that refresh was called @@ -202,12 +200,10 @@ func TestPlan_outPath(t *testing.T) { outPath := filepath.Join(td, "test.plan") p := planFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -219,8 +215,10 @@ func TestPlan_outPath(t *testing.T) { args := []string{ "-out", outPath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } testReadPlan(t, outPath) // will call t.Fatal itself if the file cannot be read @@ -257,12 +255,10 @@ func TestPlan_outPathNoChange(t *testing.T) { outPath := filepath.Join(td, "test.plan") p := planFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -271,8 +267,10 @@ func TestPlan_outPathNoChange(t *testing.T) { "-out", outPath, "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } plan := testReadPlan(t, outPath) @@ -337,12 +335,10 @@ func TestPlan_outBackend(t *testing.T) { PlannedState: req.ProposedNewState, } } - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -350,9 +346,11 @@ func TestPlan_outBackend(t *testing.T) { args := []string{ "-out", outPath, } - if code := c.Run(args); code != 0 { - t.Logf("stdout: %s", ui.OutputWriter.String()) - t.Fatalf("plan command failed with exit code %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Logf("stdout: %s", output.Stdout()) + t.Fatalf("plan command failed with exit code %d\n\n%s", code, output.Stderr()) } plan := testReadPlan(t, outPath) @@ -391,12 +389,10 @@ func TestPlan_refreshFalse(t *testing.T) { defer testChdir(t, td)() p := planFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -404,8 +400,10 @@ func TestPlan_refreshFalse(t *testing.T) { args := []string{ "-refresh=false", } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if p.ReadResourceCalled { @@ -424,12 +422,10 @@ func TestPlan_state(t *testing.T) { statePath := testStateFile(t, originalState) p := planFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -437,8 +433,10 @@ func TestPlan_state(t *testing.T) { args := []string{ "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } // Verify that the provider was called with the existing state @@ -469,19 +467,19 @@ func TestPlan_stateDefault(t *testing.T) { os.Rename(statePath, path.Join(td, "terraform.tfstate")) p := planFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } // Verify that the provider was called with the existing state @@ -526,22 +524,22 @@ func TestPlan_validate(t *testing.T) { PlannedState: req.ProposedNewState, } } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } - args := []string{} - if code := c.Run(args); code != 1 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + args := []string{"-no-color"} + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } - actual := ui.ErrorWriter.String() + actual := output.Stderr() if want := "Error: Invalid count argument"; !strings.Contains(actual, want) { t.Fatalf("unexpected error output\ngot:\n%s\n\nshould contain: %s", actual, want) } @@ -555,12 +553,10 @@ func TestPlan_vars(t *testing.T) { defer testChdir(t, td)() p := planVarsFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -575,8 +571,10 @@ func TestPlan_vars(t *testing.T) { args := []string{ "-var", "foo=bar", } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if actual != "bar" { @@ -602,19 +600,19 @@ func TestPlan_varsUnset(t *testing.T) { defer close() p := planVarsFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } } @@ -667,19 +665,19 @@ func TestPlan_providerArgumentUnset(t *testing.T) { }, }, } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } } @@ -696,12 +694,10 @@ func TestPlan_varFile(t *testing.T) { } p := planVarsFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -716,8 +712,10 @@ func TestPlan_varFile(t *testing.T) { args := []string{ "-var-file", varFilePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if actual != "bar" { @@ -738,12 +736,10 @@ func TestPlan_varFileDefault(t *testing.T) { } p := planVarsFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -756,8 +752,10 @@ func TestPlan_varFileDefault(t *testing.T) { } args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if actual != "bar" { @@ -778,12 +776,10 @@ func TestPlan_varFileWithDecls(t *testing.T) { } p := planVarsFixtureProvider() - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -791,11 +787,13 @@ func TestPlan_varFileWithDecls(t *testing.T) { args := []string{ "-var-file", varFilePath, } - if code := c.Run(args); code == 0 { - t.Fatalf("succeeded; want failure\n\n%s", ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("succeeded; want failure\n\n%s", output.Stdout()) } - msg := ui.ErrorWriter.String() + msg := output.Stderr() if got, want := msg, "Variable declaration in .tfvars file"; !strings.Contains(got, want) { t.Fatalf("missing expected error message\nwant message containing %q\ngot:\n%s", want, got) } @@ -808,19 +806,19 @@ func TestPlan_detailedExitcode(t *testing.T) { defer testChdir(t, td)() p := planFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } args := []string{"-detailed-exitcode"} - if code := c.Run(args); code != 2 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 2 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } } @@ -831,19 +829,19 @@ func TestPlan_detailedExitcode_emptyDiff(t *testing.T) { defer testChdir(t, td)() p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } args := []string{"-detailed-exitcode"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } } @@ -858,12 +856,10 @@ func TestPlan_shutdown(t *testing.T) { shutdownCh := make(chan struct{}) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, ShutdownCh: shutdownCh, }, @@ -908,8 +904,9 @@ func TestPlan_shutdown(t *testing.T) { } code := c.Run([]string{}) + output := done(t) if code != 1 { - t.Errorf("wrong exit code %d; want 1\noutput:\n%s", code, ui.OutputWriter.String()) + t.Errorf("wrong exit code %d; want 1\noutput:\n%s", code, output.Stdout()) } select { @@ -925,23 +922,23 @@ func TestPlan_init_required(t *testing.T) { defer os.RemoveAll(td) defer testChdir(t, td)() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ // Running plan without setting testingOverrides is similar to plan without init - Ui: ui, View: view, }, } args := []string{} - if code := c.Run(args); code != 1 { + code := c.Run(args) + output := done(t) + if code != 1 { t.Fatalf("expected error, got success") } - output := ui.ErrorWriter.String() - if !strings.Contains(output, `Plugin reinitialization required. Please run "terraform init".`) { - t.Fatal("wrong error message in output:", output) + got := output.Stderr() + if !strings.Contains(got, `Plugin reinitialization required. Please run "terraform init".`) { + t.Fatal("wrong error message in output:", got) } } @@ -970,12 +967,10 @@ func TestPlan_targeted(t *testing.T) { } } - ui := new(cli.MockUi) view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -984,11 +979,13 @@ func TestPlan_targeted(t *testing.T) { "-target", "test_instance.foo", "-target", "test_instance.baz", } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } - if got, want := done(t).Stdout(), "3 to add, 0 to change, 0 to destroy"; !strings.Contains(got, want) { + if got, want := output.Stdout(), "3 to add, 0 to change, 0 to destroy"; !strings.Contains(got, want) { t.Fatalf("bad change summary, want %q, got:\n%s", want, got) } } @@ -1006,11 +1003,9 @@ func TestPlan_targetFlagsDiags(t *testing.T) { defer os.RemoveAll(td) defer testChdir(t, td)() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ - Ui: ui, View: view, }, } @@ -1018,11 +1013,13 @@ func TestPlan_targetFlagsDiags(t *testing.T) { args := []string{ "-target", target, } - if code := c.Run(args); code != 1 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("bad: %d\n\n%s", code, output.Stdout()) } - got := ui.ErrorWriter.String() + got := output.Stderr() if !strings.Contains(got, target) { t.Fatalf("bad error output, want %q, got:\n%s", target, got) } diff --git a/command/views/plan.go b/command/views/plan.go index ade7838c3..c569c83bf 100644 --- a/command/views/plan.go +++ b/command/views/plan.go @@ -7,12 +7,56 @@ import ( "strings" "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/format" "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/terraform/tfdiags" ) +// The Plan view is used for the plan command. +type Plan interface { + Operation() Operation + Hooks() []terraform.Hook + + Diagnostics(diags tfdiags.Diagnostics) + HelpPrompt(string) +} + +// NewPlan returns an initialized Plan implementation for the given ViewType. +func NewPlan(vt arguments.ViewType, runningInAutomation bool, view *View) Plan { + switch vt { + case arguments.ViewHuman: + return &PlanHuman{ + View: *view, + inAutomation: runningInAutomation, + } + default: + panic(fmt.Sprintf("unknown view type %v", vt)) + } +} + +// The PlanHuman implementation renders human-readable text logs, suitable for +// a scrolling terminal. +type PlanHuman struct { + View + + inAutomation bool +} + +var _ Plan = (*PlanHuman)(nil) + +func (v *PlanHuman) Operation() Operation { + return NewOperation(arguments.ViewHuman, v.inAutomation, &v.View) +} + +func (v *PlanHuman) Hooks() []terraform.Hook { + return []terraform.Hook{ + NewUiHook(&v.View), + } +} + // The plan renderer is used by the Operation view (for plan and apply // commands) and the Show view (for the show command). func renderPlan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas, view *View) { diff --git a/command/views/plan_test.go b/command/views/plan_test.go index a8ab98804..481b05605 100644 --- a/command/views/plan_test.go +++ b/command/views/plan_test.go @@ -4,13 +4,46 @@ import ( "testing" "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/terraform" "github.com/zclconf/go-cty/cty" ) +// Ensure that the correct view type and in-automation settings propagate to the +// Operation view. +func TestPlanHuman_operation(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + defer done(t) + v := NewPlan(arguments.ViewHuman, true, NewView(streams)).Operation() + if hv, ok := v.(*OperationHuman); !ok { + t.Fatalf("unexpected return type %t", v) + } else if hv.inAutomation != true { + t.Fatalf("unexpected inAutomation value on Operation view") + } +} + +// Verify that Hooks includes a UI hook +func TestPlanHuman_hooks(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + defer done(t) + v := NewPlan(arguments.ViewHuman, true, NewView(streams)) + hooks := v.Hooks() + + var uiHook *UiHook + for _, hook := range hooks { + if ch, ok := hook.(*UiHook); ok { + uiHook = ch + } + } + if uiHook == nil { + t.Fatalf("expected Hooks to include a UiHook: %#v", hooks) + } +} + // Helper functions to build a trivial test plan, to exercise the plan // renderer. func testPlan(t *testing.T) *plans.Plan {