From 43028c4912ff5da8a10df7c79779d84d098d239c Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Thu, 18 Feb 2021 17:23:34 -0500 Subject: [PATCH] cli: Migrate apply to command views --- command/apply.go | 321 ++++++++++-------- command/apply_destroy_test.go | 143 ++++---- command/apply_test.go | 446 ++++++++++++++----------- command/arguments/apply.go | 69 ++++ command/arguments/apply_test.go | 175 ++++++++++ command/arguments/extended.go | 162 +++++++++ command/arguments/flags.go | 86 +++++ command/meta.go | 20 +- command/views/apply.go | 99 ++++++ command/views/apply_test.go | 214 ++++++++++++ command/{ => views}/hook_count.go | 16 +- command/{ => views}/hook_count_test.go | 40 +-- command/views/operation.go | 20 ++ 13 files changed, 1374 insertions(+), 437 deletions(-) create mode 100644 command/arguments/apply.go create mode 100644 command/arguments/apply_test.go create mode 100644 command/arguments/extended.go create mode 100644 command/arguments/flags.go create mode 100644 command/views/apply.go create mode 100644 command/views/apply_test.go rename command/{ => views}/hook_count.go (84%) rename command/{ => views}/hook_count_test.go (93%) diff --git a/command/apply.go b/command/apply.go index 9ddb6fd98..a6641d1cf 100644 --- a/command/apply.go +++ b/command/apply.go @@ -8,7 +8,6 @@ import ( "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/plans/planfile" - "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" ) @@ -22,105 +21,177 @@ type ApplyCommand struct { Destroy bool } -func (c *ApplyCommand) Run(args []string) int { - var refresh, autoApprove bool - args = c.Meta.process(args) - cmdName := "apply" - if c.Destroy { - cmdName = "destroy" - } +func (c *ApplyCommand) Run(rawArgs []string) int { + // Parse and apply global view arguments + common, rawArgs := arguments.ParseView(rawArgs) + c.View.Configure(common) - cmdFlags := c.Meta.extendedFlagSet(cmdName) - cmdFlags.BoolVar(&autoApprove, "auto-approve", false, "skip interactive approval of plan before applying") - cmdFlags.BoolVar(&refresh, "refresh", true, "refresh") - cmdFlags.IntVar(&c.Meta.parallelism, "parallelism", DefaultParallelism, "parallelism") - cmdFlags.StringVar(&c.Meta.statePath, "state", "", "path") - cmdFlags.StringVar(&c.Meta.stateOutPath, "state-out", "", "path") - cmdFlags.StringVar(&c.Meta.backupPath, "backup", "", "path") - 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.ParseApply(rawArgs) + + // Instantiate the view, even if there are flag errors, so that we render + // diagnostics according to the desired view + var view views.Apply + view = views.NewApply(args.ViewType, c.Destroy, c.RunningInAutomation, c.View) - diags := c.parseTargetFlags() if diags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - - args = cmdFlags.Args() - var planPath string - if len(args) > 0 { - planPath = args[0] - args = args[1:] - } - - configPath, err := ModulePath(args) - if err != nil { - c.Ui.Error(err.Error()) + view.Diagnostics(diags) + view.HelpPrompt() 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 } - // Try to load plan if path is specified + // Attempt to load the plan file, if specified + planFile, diags := c.LoadPlanFile(args.PlanPath) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + + // Check for invalid combination of plan file and variable overrides + if planFile != nil && !args.Vars.Empty() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Can't set variables when applying a saved plan", + "The -var and -var-file options cannot be used when applying a saved plan file, because a saved plan includes the variable values that were set when it was created.", + )) + view.Diagnostics(diags) + return 1 + } + + // 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 + + // Prepare the backend, passing the plan file if present, and the + // backend-specific arguments + be, beDiags := c.PrepareBackend(planFile, args.State) + diags = diags.Append(beDiags) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + + // Build the operation request + opReq, opDiags := c.OperationRequest(be, view, planFile, args.Operation) + diags = diags.Append(opDiags) + + // Collect variable value and add them to the operation request + diags = diags.Append(c.GatherVariables(opReq, args.Vars)) + + // 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) + if diags.HasErrors() { + return 1 + } + diags = nil + + // Run the operation + op, err := c.RunOperation(be, opReq) + if err != nil { + diags = diags.Append(err) + view.Diagnostics(diags) + return 1 + } + + if op.Result != backend.OperationSuccess { + return op.Result.ExitStatus() + } + + // // Render the resource count and outputs + view.ResourceCount(args.State.StateOutPath) + if !c.Destroy && op.State != nil { + view.Outputs(op.State.RootModule().OutputValues) + } + + view.Diagnostics(diags) + + if diags.HasErrors() { + return 1 + } + + return 0 +} + +func (c *ApplyCommand) LoadPlanFile(path string) (*planfile.Reader, tfdiags.Diagnostics) { var planFile *planfile.Reader - if planPath != "" { - planFile, err = c.PlanFile(planPath) + var diags tfdiags.Diagnostics + + // Try to load plan if path is specified + if path != "" { + var err error + planFile, err = c.PlanFile(path) if err != nil { - c.Ui.Error(err.Error()) - return 1 + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Failed to load %q as a plan file", path), + fmt.Sprintf("Error: %s", err), + )) + return nil, diags } // If the path doesn't look like a plan, both planFile and err will be // nil. In that case, the user is probably trying to use the positional // argument to specify a configuration path. Point them at -chdir. if planFile == nil { - c.Ui.Error(fmt.Sprintf("Failed to load %q as a plan file. Did you mean to use -chdir?", planPath)) - return 1 + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Failed to load %q as a plan file", path), + "The specified path is a directory, not a plan file. You can use the global -chdir flag to use this directory as the configuration root.", + )) + return nil, diags } // If we successfully loaded a plan but this is a destroy operation, // explain that this is not supported. if c.Destroy { - c.Ui.Error("Destroy can't be called with a plan file.") - return 1 - } - } - if planFile != nil { - // Reset the config path for backend loading - configPath = "" - - if !c.variableArgs.Empty() { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, - "Can't set variables when applying a saved plan", - "The -var and -var-file options cannot be used when applying a saved plan file, because a saved plan includes the variable values that were set when it was created.", + "Destroy can't be called with a plan file", + fmt.Sprintf("If this plan was created using plan -destroy, apply it using:\n terraform apply %q", path), )) - c.showDiagnostics(diags) - return 1 + return nil, diags } } - // Set up our count hook that keeps track of resource changes - countHook := new(CountHook) + return planFile, diags +} + +func (c *ApplyCommand) PrepareBackend(planFile *planfile.Reader, args *arguments.State) (backend.Enhanced, tfdiags.Diagnostics) { + var diags 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) // Load the backend var be backend.Enhanced var beDiags tfdiags.Diagnostics if planFile == nil { - backendConfig, configDiags := c.loadBackendConfig(configPath) + backendConfig, configDiags := c.loadBackendConfig(".") diags = diags.Append(configDiags) if configDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 + return nil, diags } be, beDiags = c.Backend(&BackendOpts{ @@ -134,8 +205,7 @@ func (c *ApplyCommand) Run(args []string) int { "Failed to read plan from plan file", fmt.Sprintf("Cannot read the plan from the given plan file: %s.", err), )) - c.showDiagnostics(diags) - return 1 + return nil, diags } if plan.Backend.Config == nil { // Should never happen; always indicates a bug in the creation of the plan file @@ -144,103 +214,80 @@ func (c *ApplyCommand) Run(args []string) int { "Failed to read plan from plan file", "The given plan file does not have a valid backend configuration. This is a bug in the Terraform command that generated this plan file.", )) - c.showDiagnostics(diags) - return 1 + return nil, diags } be, beDiags = c.BackendForPlan(plan.Backend) } + diags = diags.Append(beDiags) if beDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 + return nil, diags } + return be, diags +} + +func (c *ApplyCommand) OperationRequest(be backend.Enhanced, view views.Apply, planFile *planfile.Reader, args *arguments.Operation, +) (*backend.Operation, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics // Applying changes with dev overrides in effect could make it impossible // to switch back to a release version if the schema isn't compatible, // so we'll warn about it. diags = diags.Append(c.providerDevOverrideRuntimeWarnings()) - // 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. - c.showDiagnostics(diags) - diags = nil - // Build the operation opReq := c.Operation(be) - opReq.AutoApprove = autoApprove - opReq.ConfigDir = configPath + opReq.AutoApprove = args.AutoApprove + opReq.ConfigDir = "." opReq.Destroy = c.Destroy - opReq.Hooks = []terraform.Hook{countHook, c.uiHook()} + opReq.Hooks = view.Hooks() opReq.PlanFile = planFile - opReq.PlanRefresh = refresh - opReq.ShowDiagnostics = c.showDiagnostics + opReq.PlanRefresh = args.Refresh + opReq.Targets = args.Targets opReq.Type = backend.OperationTypeApply - opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) + opReq.View = view.Operation() + // FIXME: To allow errors to be easily rendered, the showDiagnostics method + // accepts ...interface{}. The backend no longer needs this, as only + // tfdiags.Diagnostics values are used. Once we have migrated plan and refresh + // to use views, we can remove ShowDiagnostics and update the backend code to + // call view.Diagnostics() instead. + 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 { - c.showDiagnostics(err) - return 1 + diags = diags.Append(fmt.Errorf("Failed to initialize config loader: %s", err)) + return nil, diags } - { - var moreDiags tfdiags.Diagnostics - opReq.Variables, moreDiags = c.collectVariableValues() - diags = diags.Append(moreDiags) - if moreDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - } + return opReq, diags +} - op, err := c.RunOperation(be, opReq) - if err != nil { - c.showDiagnostics(err) - return 1 - } +func (c *ApplyCommand) GatherVariables(opReq *backend.Operation, args *arguments.Vars) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics - if op.Result != backend.OperationSuccess { - return op.Result.ExitStatus() - } + // 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. - // Show the count results from the operation - if c.Destroy { - c.Ui.Output(c.Colorize().Color(fmt.Sprintf( - "[reset][bold][green]\n"+ - "Destroy complete! Resources: %d destroyed.", - countHook.Removed))) - } else { - c.Ui.Output(c.Colorize().Color(fmt.Sprintf( - "[reset][bold][green]\n"+ - "Apply complete! Resources: %d added, %d changed, %d destroyed.", - countHook.Added, - countHook.Changed, - countHook.Removed))) + 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() - // only show the state file help message if the state is local. - if (countHook.Added > 0 || countHook.Changed > 0) && c.Meta.stateOutPath != "" { - c.Ui.Output(c.Colorize().Color(fmt.Sprintf( - "[reset]\n"+ - "The state of your infrastructure has been saved to the path\n"+ - "below. This state is required to modify and destroy your\n"+ - "infrastructure, so keep it safe. To inspect the complete state\n"+ - "use the `terraform show` command.\n\n"+ - "State path: %s", - c.Meta.stateOutPath))) - } - - if !c.Destroy && op.State != nil { - outputValues := op.State.RootModule().OutputValues - if len(outputValues) > 0 { - c.Ui.Output(c.Colorize().Color("[reset][bold][green]\nOutputs:\n\n")) - view := views.NewOutput(arguments.ViewHuman, c.View) - view.Output("", outputValues) - } - } - - return op.Result.ExitStatus() + return diags } func (c *ApplyCommand) Help() string { @@ -369,7 +416,3 @@ Options: ` return strings.TrimSpace(helpText) } - -const outputInterrupt = `Interrupt received. -Please wait for Terraform to exit or data loss may occur. -Gracefully shutting down...` diff --git a/command/apply_destroy_test.go b/command/apply_destroy_test.go index 547ebdb9b..545b2138a 100644 --- a/command/apply_destroy_test.go +++ b/command/apply_destroy_test.go @@ -1,7 +1,6 @@ package command import ( - "bytes" "os" "strings" "testing" @@ -57,14 +56,11 @@ func TestApply_destroy(t *testing.T) { }, } - ui := new(cli.MockUi) view, done := testView(t) - defer done(t) c := &ApplyCommand{ Destroy: true, Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -74,9 +70,11 @@ func TestApply_destroy(t *testing.T) { "-auto-approve", "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Log(ui.OutputWriter.String()) - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Log(output.Stdout()) + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } // Verify a new state exists @@ -150,15 +148,14 @@ func TestApply_destroyApproveNo(t *testing.T) { }) statePath := testStateFile(t, originalState) - // Disable test mode so input would be asked - test = false - defer func() { test = true }() - - // Answer approval request with "no" - defaultInputReader = bytes.NewBufferString("no\n") - defaultInputWriter = new(bytes.Buffer) - p := applyFixtureProvider() + + defer testInputMap(t, map[string]string{ + "approve": "no", + })() + + // Do not use the NewMockUi initializer here, as we want to delay + // the call to init until after setting up the input mocks ui := new(cli.MockUi) view, done := testView(t) c := &ApplyCommand{ @@ -173,10 +170,12 @@ func TestApply_destroyApproveNo(t *testing.T) { args := []string{ "-state", statePath, } - 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()) } - if got, want := done(t).Stdout(), "Destroy cancelled"; !strings.Contains(got, want) { + if got, want := output.Stdout(), "Destroy cancelled"; !strings.Contains(got, want) { t.Fatalf("expected output to include %q, but was:\n%s", want, got) } @@ -198,20 +197,36 @@ func TestApply_destroyApproveYes(t *testing.T) { defer os.RemoveAll(td) defer testChdir(t, td)() - statePath := testTempFile(t) + // Create some existing state + originalState := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"bar"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + }) + statePath := testStateFile(t, originalState) p := applyFixtureProvider() - // Disable test mode so input would be asked - test = false - defer func() { test = true }() - - // Answer approval request with "yes" - defaultInputReader = bytes.NewBufferString("yes\n") - defaultInputWriter = new(bytes.Buffer) + defer testInputMap(t, map[string]string{ + "approve": "yes", + })() + // Do not use the NewMockUi initializer here, as we want to delay + // the call to init until after setting up the input mocks ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Destroy: true, Meta: Meta{ @@ -224,8 +239,11 @@ func TestApply_destroyApproveYes(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.Log(output.Stdout()) + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if _, err := os.Stat(statePath); err != nil { @@ -277,13 +295,11 @@ func TestApply_destroyLockedState(t *testing.T) { defer unlock() p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Destroy: true, Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -294,13 +310,14 @@ func TestApply_destroyLockedState(t *testing.T) { "-state", statePath, } - if code := c.Run(args); code == 0 { - t.Fatal("expected error") + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stdout()) } - output := ui.ErrorWriter.String() - if !strings.Contains(output, "lock") { - t.Fatal("command output does not look like a lock error:", output) + if !strings.Contains(output.Stderr(), "lock") { + t.Fatal("command output does not look like a lock error:", output.Stderr()) } } @@ -314,13 +331,11 @@ func TestApply_destroyPlan(t *testing.T) { planPath := testPlanFileNoop(t) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Destroy: true, Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -329,12 +344,13 @@ func TestApply_destroyPlan(t *testing.T) { args := []string{ planPath, } - 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()) } - output := ui.ErrorWriter.String() - if !strings.Contains(output, "plan file") { - t.Fatal("expected command output to refer to plan file, but got:", output) + if !strings.Contains(output.Stderr(), "plan file") { + t.Fatal("expected command output to refer to plan file, but got:", output.Stderr()) } } @@ -347,13 +363,11 @@ func TestApply_destroyPath(t *testing.T) { p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Destroy: true, Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -362,12 +376,13 @@ func TestApply_destroyPath(t *testing.T) { "-auto-approve", testFixturePath("apply"), } - 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()) } - output := ui.ErrorWriter.String() - if !strings.Contains(output, "-chdir") { - t.Fatal("expected command output to refer to -chdir flag, but got:", output) + if !strings.Contains(output.Stderr(), "-chdir") { + t.Fatal("expected command output to refer to -chdir flag, but got:", output.Stderr()) } } @@ -442,13 +457,11 @@ func TestApply_destroyTargetedDependencies(t *testing.T) { } } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Destroy: true, Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -459,8 +472,11 @@ func TestApply_destroyTargetedDependencies(t *testing.T) { "-target", "test_instance.foo", "-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.Log(output.Stdout()) + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } // Verify a new state exists @@ -593,13 +609,11 @@ func TestApply_destroyTargeted(t *testing.T) { } } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Destroy: true, Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -610,8 +624,11 @@ func TestApply_destroyTargeted(t *testing.T) { "-target", "test_load_balancer.foo", "-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.Log(output.Stdout()) + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } // Verify a new state exists diff --git a/command/apply_test.go b/command/apply_test.go index 5b7836484..6432bffb3 100644 --- a/command/apply_test.go +++ b/command/apply_test.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "os" "path/filepath" + "reflect" "strings" "sync" "testing" @@ -37,12 +38,10 @@ func TestApply(t *testing.T) { p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -51,8 +50,10 @@ func TestApply(t *testing.T) { "-state", statePath, "-auto-approve", } - 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 _, err := os.Stat(statePath); err != nil { @@ -74,12 +75,10 @@ func TestApply_path(t *testing.T) { p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -88,12 +87,13 @@ func TestApply_path(t *testing.T) { "-auto-approve", testFixturePath("apply"), } - 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.Stderr()) } - output := ui.ErrorWriter.String() - if !strings.Contains(output, "-chdir") { - t.Fatal("expected command output to refer to -chdir flag, but got:", output) + if !strings.Contains(output.Stderr(), "-chdir") { + t.Fatal("expected command output to refer to -chdir flag, but got:", output.Stderr()) } } @@ -106,16 +106,15 @@ func TestApply_approveNo(t *testing.T) { statePath := testTempFile(t) - // Disable test mode so input would be asked - test = false - defer func() { test = true }() + defer testInputMap(t, map[string]string{ + "approve": "no", + })() - // Answer approval request with "no" - defaultInputReader = bytes.NewBufferString("no\n") - defaultInputWriter = new(bytes.Buffer) + // Do not use the NewMockUi initializer here, as we want to delay + // the call to init until after setting up the input mocks + ui := new(cli.MockUi) p := applyFixtureProvider() - ui := new(cli.MockUi) view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ @@ -128,10 +127,12 @@ func TestApply_approveNo(t *testing.T) { args := []string{ "-state", statePath, } - 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.Stderr()) } - if got, want := done(t).Stdout(), "Apply cancelled"; !strings.Contains(got, want) { + if got, want := output.Stdout(), "Apply cancelled"; !strings.Contains(got, want) { t.Fatalf("expected output to include %q, but was:\n%s", want, got) } @@ -151,16 +152,15 @@ func TestApply_approveYes(t *testing.T) { p := applyFixtureProvider() - // Disable test mode so input would be asked - test = false - defer func() { test = true }() - - // Answer approval request with "yes" - defaultInputReader = bytes.NewBufferString("yes\n") - defaultInputWriter = new(bytes.Buffer) + defer testInputMap(t, map[string]string{ + "approve": "yes", + })() + // Do not use the NewMockUi initializer here, as we want to delay + // the call to init until after setting up the input mocks ui := new(cli.MockUi) - view, _ := testView(t) + + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), @@ -172,8 +172,10 @@ func TestApply_approveYes(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()) } if _, err := os.Stat(statePath); err != nil { @@ -203,12 +205,10 @@ func TestApply_lockedState(t *testing.T) { defer unlock() p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -217,13 +217,14 @@ func TestApply_lockedState(t *testing.T) { "-state", statePath, "-auto-approve", } - if code := c.Run(args); code == 0 { + code := c.Run(args) + output := done(t) + if code == 0 { t.Fatal("expected error") } - output := ui.ErrorWriter.String() - if !strings.Contains(output, "lock") { - t.Fatal("command output does not look like a lock error:", output) + if !strings.Contains(output.Stderr(), "lock") { + t.Fatal("command output does not look like a lock error:", output.Stderr()) } } @@ -249,12 +250,10 @@ func TestApply_lockedStateWait(t *testing.T) { }() p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -266,8 +265,10 @@ func TestApply_lockedStateWait(t *testing.T) { "-lock-timeout", "4s", "-auto-approve", } - if code := c.Run(args); code != 0 { - t.Fatalf("lock should have succeeded in less than 3s: %s", ui.ErrorWriter) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("lock should have succeeded in less than 3s: %s", output.Stderr()) } } @@ -347,12 +348,10 @@ func TestApply_parallelism(t *testing.T) { Providers: providerFactories, } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: testingOverrides, - Ui: ui, View: view, }, } @@ -364,8 +363,9 @@ func TestApply_parallelism(t *testing.T) { } res := c.Run(args) + output := done(t) if res != 0 { - t.Fatal(ui.OutputWriter.String()) + t.Fatal(output.Stdout()) } } @@ -377,12 +377,10 @@ func TestApply_configInvalid(t *testing.T) { defer testChdir(t, td)() p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -391,8 +389,10 @@ func TestApply_configInvalid(t *testing.T) { "-state", testTempFile(t), "-auto-approve", } - 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("bad: \n%s", output.Stdout()) } } @@ -416,12 +416,10 @@ func TestApply_defaultState(t *testing.T) { defer os.Chdir(cwd) p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -435,8 +433,10 @@ func TestApply_defaultState(t *testing.T) { args := []string{ "-auto-approve", } - 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 _, err := os.Stat(statePath); err != nil { @@ -459,12 +459,10 @@ func TestApply_error(t *testing.T) { statePath := testTempFile(t) p := testProvider() - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -510,12 +508,10 @@ func TestApply_error(t *testing.T) { "-state", statePath, "-auto-approve", } - if ui.ErrorWriter != nil { - t.Logf("stdout:\n%s", ui.OutputWriter.String()) - t.Logf("stderr:\n%s", ui.ErrorWriter.String()) - } - if code := c.Run(args); code != 1 { - t.Fatalf("wrong exit code %d; want 1", code) + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("wrong exit code %d; want 1\n%s", code, output.Stdout()) } if _, err := os.Stat(statePath); err != nil { @@ -553,12 +549,10 @@ func TestApply_input(t *testing.T) { statePath := testTempFile(t) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -567,8 +561,10 @@ func TestApply_input(t *testing.T) { "-state", statePath, "-auto-approve", } - 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()) } expected := strings.TrimSpace(` @@ -600,12 +596,10 @@ func TestApply_inputPartial(t *testing.T) { statePath := testTempFile(t) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -615,8 +609,10 @@ func TestApply_inputPartial(t *testing.T) { "-auto-approve", "-var", "foo=foovalue", } - 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()) } expected := strings.TrimSpace(` @@ -639,12 +635,10 @@ func TestApply_noArgs(t *testing.T) { statePath := testTempFile(t) p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -653,8 +647,10 @@ func TestApply_noArgs(t *testing.T) { "-state", statePath, "-auto-approve", } - 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 _, err := os.Stat(statePath); err != nil { @@ -680,12 +676,10 @@ func TestApply_plan(t *testing.T) { statePath := testTempFile(t) p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -694,8 +688,10 @@ func TestApply_plan(t *testing.T) { "-state-out", statePath, planPath, } - 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 _, err := os.Stat(statePath); err != nil { @@ -714,12 +710,10 @@ func TestApply_plan_backup(t *testing.T) { backupPath := testTempFile(t) p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -735,8 +729,10 @@ func TestApply_plan_backup(t *testing.T) { "-backup", backupPath, planPath, } - 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()) } // Should have a backup file @@ -748,12 +744,10 @@ func TestApply_plan_noBackup(t *testing.T) { statePath := testTempFile(t) p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -763,8 +757,10 @@ func TestApply_plan_noBackup(t *testing.T) { "-backup", "-", planPath, } - 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()) } // Ensure there is no backup @@ -828,12 +824,10 @@ func TestApply_plan_remoteState(t *testing.T) { }) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -841,8 +835,10 @@ func TestApply_plan_remoteState(t *testing.T) { args := []string{ planPath, } - 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()) } // State file should be not be installed @@ -877,12 +873,10 @@ func TestApply_planWithVarFile(t *testing.T) { defer os.Chdir(cwd) p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -891,8 +885,10 @@ func TestApply_planWithVarFile(t *testing.T) { "-state-out", statePath, planPath, } - 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 _, err := os.Stat(statePath); err != nil { @@ -910,12 +906,10 @@ func TestApply_planVars(t *testing.T) { statePath := testTempFile(t) p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -925,8 +919,10 @@ func TestApply_planVars(t *testing.T) { "-var", "foo=bar", planPath, } - if code := c.Run(args); code == 0 { - t.Fatal("should've failed") + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatal("should've failed: ", output.Stdout()) } } @@ -940,7 +936,7 @@ func TestApply_planNoModuleFiles(t *testing.T) { p := applyFixtureProvider() planPath := applyFixturePlanFile(t) - view, _ := testView(t) + view, done := testView(t) apply := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), @@ -952,6 +948,7 @@ func TestApply_planNoModuleFiles(t *testing.T) { planPath, } apply.Run(args) + done(t) } func TestApply_refresh(t *testing.T) { @@ -981,12 +978,10 @@ func TestApply_refresh(t *testing.T) { statePath := testStateFile(t, originalState) p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -995,8 +990,10 @@ func TestApply_refresh(t *testing.T) { "-state", statePath, "-auto-approve", } - 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 { @@ -1035,12 +1032,10 @@ func TestApply_shutdown(t *testing.T) { statePath := testTempFile(t) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, ShutdownCh: shutdownCh, }, @@ -1091,8 +1086,10 @@ func TestApply_shutdown(t *testing.T) { "-state", statePath, "-auto-approve", } - 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.Stderr()) } if _, err := os.Stat(statePath); err != nil { @@ -1149,12 +1146,10 @@ func TestApply_state(t *testing.T) { }), } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1164,8 +1159,10 @@ func TestApply_state(t *testing.T) { "-state", statePath, "-auto-approve", } - 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 @@ -1214,12 +1211,10 @@ func TestApply_stateNoExist(t *testing.T) { defer testChdir(t, td)() p := applyFixtureProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1227,8 +1222,10 @@ func TestApply_stateNoExist(t *testing.T) { args := []string{ "idontexist.tfstate", } - 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("bad: \n%s", output.Stdout()) } } @@ -1240,12 +1237,10 @@ func TestApply_sensitiveOutput(t *testing.T) { defer testChdir(t, td)() p := testProvider() - ui := new(cli.MockUi) view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1257,16 +1252,18 @@ func TestApply_sensitiveOutput(t *testing.T) { "-auto-approve", } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stdout()) } - output := done(t).Stdout() - if !strings.Contains(output, "notsensitive = \"Hello world\"") { - t.Fatalf("bad: output should contain 'notsensitive' output\n%s", output) + stdout := output.Stdout() + if !strings.Contains(stdout, "notsensitive = \"Hello world\"") { + t.Fatalf("bad: output should contain 'notsensitive' output\n%s", stdout) } - if !strings.Contains(output, "sensitive = ") { - t.Fatalf("bad: output should contain 'sensitive' output\n%s", output) + if !strings.Contains(stdout, "sensitive = ") { + t.Fatalf("bad: output should contain 'sensitive' output\n%s", stdout) } } @@ -1280,12 +1277,10 @@ func TestApply_vars(t *testing.T) { statePath := testTempFile(t) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1319,8 +1314,10 @@ func TestApply_vars(t *testing.T) { "-var", "foo=bar", "-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()) } if actual != "bar" { @@ -1343,12 +1340,10 @@ func TestApply_varFile(t *testing.T) { statePath := testTempFile(t) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1382,8 +1377,10 @@ func TestApply_varFile(t *testing.T) { "-var-file", varFilePath, "-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()) } if actual != "bar" { @@ -1406,12 +1403,10 @@ func TestApply_varFileDefault(t *testing.T) { statePath := testTempFile(t) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1444,8 +1439,10 @@ func TestApply_varFileDefault(t *testing.T) { "-auto-approve", "-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()) } if actual != "bar" { @@ -1468,12 +1465,10 @@ func TestApply_varFileDefaultJSON(t *testing.T) { statePath := testTempFile(t) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1506,8 +1501,10 @@ func TestApply_varFileDefaultJSON(t *testing.T) { "-auto-approve", "-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()) } if actual != "bar" { @@ -1549,12 +1546,10 @@ func TestApply_backup(t *testing.T) { }), } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1565,8 +1560,10 @@ func TestApply_backup(t *testing.T) { "-state", statePath, "-backup", backupPath, } - 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 a new state exists @@ -1610,12 +1607,10 @@ func TestApply_disableBackup(t *testing.T) { }), } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1626,8 +1621,10 @@ func TestApply_disableBackup(t *testing.T) { "-state", statePath, "-backup", "-", } - 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 @@ -1683,12 +1680,10 @@ func TestApply_terraformEnv(t *testing.T) { statePath := testTempFile(t) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1697,8 +1692,10 @@ func TestApply_terraformEnv(t *testing.T) { "-auto-approve", "-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()) } expected := strings.TrimSpace(` @@ -1721,11 +1718,12 @@ func TestApply_terraformEnvNonDefault(t *testing.T) { // Create new env { ui := new(cli.MockUi) - view, _ := testView(t) - newCmd := &WorkspaceNewCommand{} - newCmd.Meta = Meta{Ui: ui, View: view} + newCmd := &WorkspaceNewCommand{ + Meta: Meta{ + Ui: ui, + }, + } if code := newCmd.Run([]string{"test"}); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) } } @@ -1733,21 +1731,20 @@ func TestApply_terraformEnvNonDefault(t *testing.T) { { args := []string{"test"} ui := new(cli.MockUi) - view, _ := testView(t) - selCmd := &WorkspaceSelectCommand{} - selCmd.Meta = Meta{Ui: ui, View: view} + selCmd := &WorkspaceSelectCommand{ + Meta: Meta{ + Ui: ui, + }, + } if code := selCmd.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) } } p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1755,8 +1752,10 @@ func TestApply_terraformEnvNonDefault(t *testing.T) { args := []string{ "-auto-approve", } - 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()) } statePath := filepath.Join("terraform.tfstate.d", "test", "terraform.tfstate") @@ -1794,12 +1793,10 @@ func TestApply_targeted(t *testing.T) { } } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -1809,11 +1806,13 @@ func TestApply_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 := ui.OutputWriter.String(), "3 added, 0 changed, 0 destroyed"; !strings.Contains(got, want) { + if got, want := output.Stdout(), "3 added, 0 changed, 0 destroyed"; !strings.Contains(got, want) { t.Fatalf("bad change summary, want %q, got:\n%s", want, got) } } @@ -1831,11 +1830,9 @@ func TestApply_targetFlagsDiags(t *testing.T) { defer os.RemoveAll(td) defer testChdir(t, td)() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ - Ui: ui, View: view, }, } @@ -1844,11 +1841,13 @@ func TestApply_targetFlagsDiags(t *testing.T) { "-auto-approve", "-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.Stderr()) } - got := ui.ErrorWriter.String() + got := output.Stderr() if !strings.Contains(got, target) { t.Fatalf("bad error output, want %q, got:\n%s", target, got) } @@ -1859,6 +1858,47 @@ func TestApply_targetFlagsDiags(t *testing.T) { } } +func TestApply_pluginPath(t *testing.T) { + // Create a temporary working directory that is empty + td := tempDir(t) + testCopyDir(t, testFixturePath("apply"), td) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + statePath := testTempFile(t) + + p := applyFixtureProvider() + + view, done := testView(t) + c := &ApplyCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + }, + } + + pluginPath := []string{"a", "b", "c"} + + if err := c.Meta.storePluginPath(pluginPath); err != nil { + t.Fatal(err) + } + c.Meta.pluginPath = nil + + args := []string{ + "-state", statePath, + "-auto-approve", + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } + + if !reflect.DeepEqual(pluginPath, c.Meta.pluginPath) { + t.Fatalf("expected plugin path %#v, got %#v", pluginPath, c.Meta.pluginPath) + } +} + // applyFixtureSchema returns a schema suitable for processing the // configuration in testdata/apply . This schema should be // assigned to a mock provider named "test". diff --git a/command/arguments/apply.go b/command/arguments/apply.go new file mode 100644 index 000000000..5f4e98bcb --- /dev/null +++ b/command/arguments/apply.go @@ -0,0 +1,69 @@ +package arguments + +import ( + "github.com/hashicorp/terraform/tfdiags" +) + +// Apply represents the command-line arguments for the apply command. +type Apply struct { + // State, Operation, and Vars are the common extended flags + State *State + Operation *Operation + Vars *Vars + + // InputEnabled is used to disable interactive input for unspecified + // variable and backend config values. Default is true. + InputEnabled bool + + // PlanPath contains an optional path to a stored plan file + PlanPath string + + // ViewType specifies which output format to use + ViewType ViewType +} + +// ParseApply processes CLI arguments, returning an Apply value and errors. +// If errors are encountered, an Apply value is still returned representing +// the best effort interpretation of the arguments. +func ParseApply(args []string) (*Apply, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + apply := &Apply{ + State: &State{}, + Operation: &Operation{}, + Vars: &Vars{}, + } + + cmdFlags := extendedFlagSet("apply", apply.State, apply.Operation, apply.Vars) + cmdFlags.BoolVar(&apply.InputEnabled, "input", true, "input") + + 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 { + apply.PlanPath = args[0] + args = args[1:] + } + + if len(args) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected at most one positional argument.", + )) + } + + diags = diags.Append(apply.Operation.Parse()) + + switch { + default: + apply.ViewType = ViewHuman + } + + return apply, diags +} diff --git a/command/arguments/apply_test.go b/command/arguments/apply_test.go new file mode 100644 index 000000000..bed0ed5b3 --- /dev/null +++ b/command/arguments/apply_test.go @@ -0,0 +1,175 @@ +package arguments + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/addrs" +) + +func TestParseApply_basicValid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Apply + }{ + "defaults": { + nil, + &Apply{ + InputEnabled: true, + PlanPath: "", + ViewType: ViewHuman, + }, + }, + "disabled input and plan path": { + []string{"-input=false", "saved.tfplan"}, + &Apply{ + InputEnabled: false, + PlanPath: "saved.tfplan", + ViewType: ViewHuman, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseApply(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 TestParseApply_invalid(t *testing.T) { + got, diags := ParseApply([]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 TestParseApply_tooManyArguments(t *testing.T) { + got, diags := ParseApply([]string{"saved.tfplan", "please"}) + 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 TestParseApply_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 := ParseApply(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 TestParseApply_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 := ParseApply(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/arguments/extended.go b/command/arguments/extended.go new file mode 100644 index 000000000..1b345b9c9 --- /dev/null +++ b/command/arguments/extended.go @@ -0,0 +1,162 @@ +package arguments + +import ( + "flag" + "fmt" + "time" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/tfdiags" +) + +// DefaultParallelism is the limit Terraform places on total parallel +// operations as it walks the dependency graph. +const DefaultParallelism = 10 + +// State describes arguments which are used to define how Terraform interacts +// with state. +type State struct { + // Lock controls whether or not the state manager is used to lock state + // during operations. + Lock bool + + // LockTimeout allows setting a time limit on acquiring the state lock. + // The default is 0, meaning no limit. + LockTimeout time.Duration + + // StatePath specifies a non-default location for the state file. The + // default value is blank, which is interpeted as "terraform.tfstate". + StatePath string + + // StateOutPath specifies a different path to write the final state file. + // The default value is blank, which results in state being written back to + // StatePath. + StateOutPath string + + // BackupPath specifies the path where a backup copy of the state file will + // be stored before the new state is written. The default value is blank, + // which is interpreted as StateOutPath + + // ".backup". + BackupPath string +} + +// Operation describes arguments which are used to configure how a Terraform +// operation such as a plan or apply executes. +type Operation struct { + // AutoApprove skips the manual verification step for the apply operation. + AutoApprove bool + + // Parallelism is the limit Terraform places on total parallel operations + // as it walks the dependency graph. + Parallelism int + + // Refresh controls whether or not the operation should refresh existing + // state before proceeding. Default is true. + Refresh bool + + // Targets allow limiting an operation to a set of resource addresses and + // their dependencies. + Targets []addrs.Targetable + + targetsRaw []string +} + +// Parse must be called on Operation after initial flag parse. This processes +// the raw target flags into addrs.Targetable values, returning diagnostics if +// invalid. +func (o *Operation) Parse() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + o.Targets = nil + + for _, tr := range o.targetsRaw { + traversal, syntaxDiags := hclsyntax.ParseTraversalAbs([]byte(tr), "", hcl.Pos{Line: 1, Column: 1}) + if syntaxDiags.HasErrors() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Invalid target %q", tr), + syntaxDiags[0].Detail, + )) + continue + } + + target, targetDiags := addrs.ParseTarget(traversal) + if targetDiags.HasErrors() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Invalid target %q", tr), + targetDiags[0].Description().Detail, + )) + continue + } + + o.Targets = append(o.Targets, target.Subject) + } + + return diags +} + +// Vars describes arguments which specify non-default variable values. This +// interfce is unfortunately obscure, because the order of the CLI arguments +// determines the final value of the gathered variables. In future it might be +// desirable for the arguments package to handle the gathering of variables +// directly, returning a map of variable values. +type Vars struct { + vars *flagNameValueSlice + varFiles *flagNameValueSlice +} + +func (v *Vars) All() []FlagNameValue { + if v.vars == nil { + return nil + } + return v.vars.AllItems() +} + +func (v *Vars) Empty() bool { + if v.vars == nil { + return true + } + return v.vars.Empty() +} + +// extendedFlagSet creates a FlagSet with common backend, operation, and vars +// flags used in many commands. Target structs for each subset of flags must be +// provided in order to support those flags. +func extendedFlagSet(name string, state *State, operation *Operation, vars *Vars) *flag.FlagSet { + f := defaultFlagSet(name) + + if state == nil && operation == nil && vars == nil { + panic("use defaultFlagSet") + } + + if state != nil { + f.BoolVar(&state.Lock, "lock", true, "lock") + f.DurationVar(&state.LockTimeout, "lock-timeout", 0, "lock-timeout") + f.StringVar(&state.StatePath, "state", "", "state-path") + f.StringVar(&state.StateOutPath, "state-out", "", "state-path") + f.StringVar(&state.BackupPath, "backup", "", "backup-path") + } + + if operation != nil { + f.BoolVar(&operation.AutoApprove, "auto-approve", false, "auto-approve") + f.IntVar(&operation.Parallelism, "parallelism", DefaultParallelism, "parallelism") + f.BoolVar(&operation.Refresh, "refresh", true, "refresh") + f.Var((*flagStringSlice)(&operation.targetsRaw), "target", "target") + } + + // Gather all -var and -var-file arguments into one heterogenous structure + // to preserve the overall order. + if vars != nil { + varsFlags := newFlagNameValueSlice("-var") + varFilesFlags := varsFlags.Alias("-var-file") + vars.vars = &varsFlags + vars.varFiles = &varFilesFlags + f.Var(vars.vars, "var", "var") + f.Var(vars.varFiles, "var-file", "var-file") + } + + return f +} diff --git a/command/arguments/flags.go b/command/arguments/flags.go new file mode 100644 index 000000000..d36486d94 --- /dev/null +++ b/command/arguments/flags.go @@ -0,0 +1,86 @@ +package arguments + +import ( + "flag" + "fmt" +) + +// flagStringSlice is a flag.Value implementation which allows collecting +// multiple instances of a single flag into a slice. This is used for flags +// such as -target=aws_instance.foo and -var x=y. +type flagStringSlice []string + +var _ flag.Value = (*flagStringSlice)(nil) + +func (v *flagStringSlice) String() string { + return "" +} +func (v *flagStringSlice) Set(raw string) error { + *v = append(*v, raw) + + return nil +} + +// flagNameValueSlice is a flag.Value implementation that appends raw flag +// names and values to a slice. This is used to collect a sequence of flags +// with possibly different names, preserving the overall order. +// +// FIXME: this is a copy of rawFlags from command/meta_config.go, with the +// eventual aim of replacing it altogether by gathering variables in the +// arguments package. +type flagNameValueSlice struct { + flagName string + items *[]FlagNameValue +} + +var _ flag.Value = flagNameValueSlice{} + +func newFlagNameValueSlice(flagName string) flagNameValueSlice { + var items []FlagNameValue + return flagNameValueSlice{ + flagName: flagName, + items: &items, + } +} + +func (f flagNameValueSlice) Empty() bool { + if f.items == nil { + return true + } + return len(*f.items) == 0 +} + +func (f flagNameValueSlice) AllItems() []FlagNameValue { + if f.items == nil { + return nil + } + return *f.items +} + +func (f flagNameValueSlice) Alias(flagName string) flagNameValueSlice { + return flagNameValueSlice{ + flagName: flagName, + items: f.items, + } +} + +func (f flagNameValueSlice) String() string { + return "" +} + +func (f flagNameValueSlice) Set(str string) error { + *f.items = append(*f.items, FlagNameValue{ + Name: f.flagName, + Value: str, + }) + return nil +} + +type FlagNameValue struct { + Name string + Value string +} + +func (f FlagNameValue) String() string { + return fmt.Sprintf("%s=%q", f.Name, f.Value) +} diff --git a/command/meta.go b/command/meta.go index 9f0ad8da6..69f01ebd8 100644 --- a/command/meta.go +++ b/command/meta.go @@ -376,6 +376,9 @@ func (m *Meta) InterruptibleContext() (context.Context, context.CancelFunc) { // operation itself is unsuccessful. Use the "Result" field of the // returned operation object to recognize operation-level failure. func (m *Meta) RunOperation(b backend.Enhanced, opReq *backend.Operation) (*backend.RunningOperation, error) { + if opReq.View == nil { + panic("RunOperation called with nil View") + } if opReq.ConfigDir != "" { opReq.ConfigDir = m.normalizePath(opReq.ConfigDir) } @@ -392,14 +395,12 @@ func (m *Meta) RunOperation(b backend.Enhanced, opReq *backend.Operation) (*back op.Stop() // Notify the user - m.Ui.Output(outputInterrupt) + opReq.View.Interrupted() // Still get the result, since there is still one select { case <-m.ShutdownCh: - m.Ui.Error( - "Two interrupts received. Exiting immediately. Note that data\n" + - "loss may have occurred.") + opReq.View.FatalInterrupt() // cancel the operation completely op.Cancel() @@ -782,3 +783,14 @@ func isAutoVarFile(path string) bool { return strings.HasSuffix(path, ".auto.tfvars") || strings.HasSuffix(path, ".auto.tfvars.json") } + +// FIXME: as an interim refactoring step, we apply the contents of the state +// arguments directly to the Meta object. Future work would ideally update the +// code paths which use these arguments to be passed them directly for clarity. +func (m *Meta) applyStateArguments(args *arguments.State) { + m.stateLock = args.Lock + m.stateLockTimeout = args.LockTimeout + m.statePath = args.StatePath + m.stateOutPath = args.StateOutPath + m.backupPath = args.BackupPath +} diff --git a/command/views/apply.go b/command/views/apply.go new file mode 100644 index 000000000..0686013aa --- /dev/null +++ b/command/views/apply.go @@ -0,0 +1,99 @@ +package views + +import ( + "fmt" + + "github.com/hashicorp/terraform/command/arguments" + "github.com/hashicorp/terraform/command/format" + "github.com/hashicorp/terraform/states" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/terraform/tfdiags" +) + +// The Apply view is used for the apply command. +type Apply interface { + ResourceCount(stateOutPath string) + Outputs(outputValues map[string]*states.OutputValue) + + Operation() Operation + Hooks() []terraform.Hook + + Diagnostics(diags tfdiags.Diagnostics) + HelpPrompt() +} + +// NewApply returns an initialized Apply implementation for the given ViewType. +func NewApply(vt arguments.ViewType, destroy bool, runningInAutomation bool, view *View) Apply { + switch vt { + case arguments.ViewHuman: + return &ApplyHuman{ + View: *view, + destroy: destroy, + inAutomation: runningInAutomation, + countHook: &countHook{}, + } + default: + panic(fmt.Sprintf("unknown view type %v", vt)) + } +} + +// The ApplyHuman implementation renders human-readable text logs, suitable for +// a scrolling terminal. +type ApplyHuman struct { + View + + destroy bool + inAutomation bool + + countHook *countHook +} + +var _ Apply = (*ApplyHuman)(nil) + +func (v *ApplyHuman) ResourceCount(stateOutPath string) { + if v.destroy { + v.streams.Printf( + v.colorize.Color("[reset][bold][green]\nDestroy complete! Resources: %d destroyed.\n"), + v.countHook.Removed, + ) + } else { + v.streams.Printf( + v.colorize.Color("[reset][bold][green]\nApply complete! Resources: %d added, %d changed, %d destroyed.\n"), + v.countHook.Added, + v.countHook.Changed, + v.countHook.Removed, + ) + } + if (v.countHook.Added > 0 || v.countHook.Changed > 0) && stateOutPath != "" { + v.streams.Printf("\n%s\n\n", format.WordWrap(stateOutPathPostApply, v.View.outputColumns())) + v.streams.Printf("State path: %s\n", stateOutPath) + } +} + +func (v *ApplyHuman) Outputs(outputValues map[string]*states.OutputValue) { + if len(outputValues) > 0 { + v.streams.Print(v.colorize.Color("[reset][bold][green]\nOutputs:\n\n")) + NewOutput(arguments.ViewHuman, &v.View).Output("", outputValues) + } +} + +func (v *ApplyHuman) Operation() Operation { + return NewOperation(arguments.ViewHuman, v.inAutomation, &v.View) +} + +func (v *ApplyHuman) Hooks() []terraform.Hook { + return []terraform.Hook{ + v.countHook, + NewUiHook(&v.View), + } +} + +func (v *ApplyHuman) HelpPrompt() { + command := "apply" + if v.destroy { + command = "destroy" + } + v.View.HelpPrompt(command) +} + +const stateOutPathPostApply = "The state of your infrastructure has been saved to the path below. This state is required to modify and destroy your infrastructure, so keep it safe. To inspect the complete state use the `terraform show` command." diff --git a/command/views/apply_test.go b/command/views/apply_test.go new file mode 100644 index 000000000..5d6f84e78 --- /dev/null +++ b/command/views/apply_test.go @@ -0,0 +1,214 @@ +package views + +import ( + "strings" + "testing" + + "github.com/hashicorp/terraform/command/arguments" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/states" + "github.com/zclconf/go-cty/cty" +) + +// This test is mostly because I am paranoid about having two consecutive +// boolean arguments. +func TestApply_new(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + defer done(t) + v := NewApply(arguments.ViewHuman, false, true, NewView(streams)) + hv, ok := v.(*ApplyHuman) + if !ok { + t.Fatalf("unexpected return type %t", v) + } + + if hv.destroy != false { + t.Fatalf("unexpected destroy value") + } + + if hv.inAutomation != true { + t.Fatalf("unexpected inAutomation value") + } +} + +// Basic test coverage of Outputs, since most of its functionality is tested +// elsewhere. +func TestApplyHuman_outputs(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewApply(arguments.ViewHuman, false, false, NewView(streams)) + + v.Outputs(map[string]*states.OutputValue{ + "foo": {Value: cty.StringVal("secret")}, + }) + + got := done(t).Stdout() + for _, want := range []string{"Outputs:", `foo = "secret"`} { + if !strings.Contains(got, want) { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } + } +} + +// Outputs should do nothing if there are no outputs to render. +func TestApplyHuman_outputsEmpty(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewApply(arguments.ViewHuman, false, false, NewView(streams)) + + v.Outputs(map[string]*states.OutputValue{}) + + got := done(t).Stdout() + if got != "" { + t.Errorf("output should be empty, but got: %q", got) + } +} + +// Ensure that the correct view type and in-automation settings propagate to the +// Operation view. +func TestApplyHuman_operation(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + defer done(t) + v := NewApply(arguments.ViewHuman, false, 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") + } +} + +// This view is used for both apply and destroy commands, so the help output +// needs to cover both. +func TestApplyHuman_help(t *testing.T) { + testCases := map[string]bool{ + "apply": false, + "destroy": true, + } + + for name, destroy := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewApply(arguments.ViewHuman, destroy, false, NewView(streams)) + v.HelpPrompt() + got := done(t).Stderr() + if !strings.Contains(got, name) { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, name) + } + }) + } +} + +// Hooks and ResourceCount are tangled up and easiest to test together. +func TestApplyHuman_resourceCount(t *testing.T) { + testCases := map[string]struct { + destroy bool + want string + }{ + "apply": { + false, + "Apply complete! Resources: 1 added, 2 changed, 3 destroyed.", + }, + "destroy": { + true, + "Destroy complete! Resources: 3 destroyed.", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewApply(arguments.ViewHuman, tc.destroy, false, NewView(streams)) + hooks := v.Hooks() + + var count *countHook + for _, hook := range hooks { + if ch, ok := hook.(*countHook); ok { + count = ch + } + } + if count == nil { + t.Fatalf("expected Hooks to include a countHook: %#v", hooks) + } + + count.Added = 1 + count.Changed = 2 + count.Removed = 3 + + v.ResourceCount("") + + got := done(t).Stdout() + if !strings.Contains(got, tc.want) { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, tc.want) + } + }) + } +} + +func TestApplyHuman_resourceCountStatePath(t *testing.T) { + testCases := map[string]struct { + added int + changed int + removed int + statePath string + wantContains bool + }{ + "default state path": { + added: 1, + changed: 2, + removed: 3, + statePath: "", + wantContains: false, + }, + "only removed": { + added: 0, + changed: 0, + removed: 5, + statePath: "foo.tfstate", + wantContains: false, + }, + "added": { + added: 5, + changed: 0, + removed: 0, + statePath: "foo.tfstate", + wantContains: true, + }, + "changed": { + added: 5, + changed: 0, + removed: 0, + statePath: "foo.tfstate", + wantContains: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewApply(arguments.ViewHuman, false, false, NewView(streams)) + hooks := v.Hooks() + + var count *countHook + for _, hook := range hooks { + if ch, ok := hook.(*countHook); ok { + count = ch + } + } + if count == nil { + t.Fatalf("expected Hooks to include a countHook: %#v", hooks) + } + + count.Added = tc.added + count.Changed = tc.changed + count.Removed = tc.removed + + v.ResourceCount(tc.statePath) + + got := done(t).Stdout() + want := "State path: " + tc.statePath + contains := strings.Contains(got, want) + if contains && !tc.wantContains { + t.Errorf("wrong result\ngot: %q\nshould not contain: %q", got, want) + } else if !contains && tc.wantContains { + t.Errorf("wrong result\ngot: %q\nshould contain: %q", got, want) + } + }) + } +} diff --git a/command/hook_count.go b/command/views/hook_count.go similarity index 84% rename from command/hook_count.go rename to command/views/hook_count.go index 40a834cd0..25e1a5e7d 100644 --- a/command/hook_count.go +++ b/command/views/hook_count.go @@ -1,4 +1,4 @@ -package command +package views import ( "sync" @@ -11,9 +11,9 @@ import ( "github.com/hashicorp/terraform/terraform" ) -// CountHook is a hook that counts the number of resources +// countHook is a hook that counts the number of resources // added, removed, changed during the course of an apply. -type CountHook struct { +type countHook struct { Added int Changed int Removed int @@ -29,9 +29,9 @@ type CountHook struct { terraform.NilHook } -var _ terraform.Hook = (*CountHook)(nil) +var _ terraform.Hook = (*countHook)(nil) -func (h *CountHook) Reset() { +func (h *countHook) Reset() { h.Lock() defer h.Unlock() @@ -41,7 +41,7 @@ func (h *CountHook) Reset() { h.Removed = 0 } -func (h *CountHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { +func (h *countHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { h.Lock() defer h.Unlock() @@ -54,7 +54,7 @@ func (h *CountHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generati return terraform.HookActionContinue, nil } -func (h *CountHook) PostApply(addr addrs.AbsResourceInstance, gen states.Generation, newState cty.Value, err error) (terraform.HookAction, error) { +func (h *countHook) PostApply(addr addrs.AbsResourceInstance, gen states.Generation, newState cty.Value, err error) (terraform.HookAction, error) { h.Lock() defer h.Unlock() @@ -82,7 +82,7 @@ func (h *CountHook) PostApply(addr addrs.AbsResourceInstance, gen states.Generat return terraform.HookActionContinue, nil } -func (h *CountHook) PostDiff(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { +func (h *countHook) PostDiff(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { h.Lock() defer h.Unlock() diff --git a/command/hook_count_test.go b/command/views/hook_count_test.go similarity index 93% rename from command/hook_count_test.go rename to command/views/hook_count_test.go index ec7eba984..83e75e09f 100644 --- a/command/hook_count_test.go +++ b/command/views/hook_count_test.go @@ -1,4 +1,4 @@ -package command +package views import ( "reflect" @@ -15,11 +15,11 @@ import ( ) func TestCountHook_impl(t *testing.T) { - var _ terraform.Hook = new(CountHook) + var _ terraform.Hook = new(countHook) } func TestCountHookPostDiff_DestroyDeposed(t *testing.T) { - h := new(CountHook) + h := new(countHook) resources := map[string]*legacy.InstanceDiff{ "lorem": &legacy.InstanceDiff{DestroyDeposed: true}, @@ -35,7 +35,7 @@ func TestCountHookPostDiff_DestroyDeposed(t *testing.T) { h.PostDiff(addr, states.DeposedKey("deadbeef"), plans.Delete, cty.DynamicVal, cty.DynamicVal) } - expected := new(CountHook) + expected := new(countHook) expected.ToAdd = 0 expected.ToChange = 0 expected.ToRemoveAndAdd = 0 @@ -47,7 +47,7 @@ func TestCountHookPostDiff_DestroyDeposed(t *testing.T) { } func TestCountHookPostDiff_DestroyOnly(t *testing.T) { - h := new(CountHook) + h := new(countHook) resources := map[string]*legacy.InstanceDiff{ "foo": &legacy.InstanceDiff{Destroy: true}, @@ -66,7 +66,7 @@ func TestCountHookPostDiff_DestroyOnly(t *testing.T) { h.PostDiff(addr, states.CurrentGen, plans.Delete, cty.DynamicVal, cty.DynamicVal) } - expected := new(CountHook) + expected := new(countHook) expected.ToAdd = 0 expected.ToChange = 0 expected.ToRemoveAndAdd = 0 @@ -78,7 +78,7 @@ func TestCountHookPostDiff_DestroyOnly(t *testing.T) { } func TestCountHookPostDiff_AddOnly(t *testing.T) { - h := new(CountHook) + h := new(countHook) resources := map[string]*legacy.InstanceDiff{ "foo": &legacy.InstanceDiff{ @@ -108,7 +108,7 @@ func TestCountHookPostDiff_AddOnly(t *testing.T) { h.PostDiff(addr, states.CurrentGen, plans.Create, cty.DynamicVal, cty.DynamicVal) } - expected := new(CountHook) + expected := new(countHook) expected.ToAdd = 3 expected.ToChange = 0 expected.ToRemoveAndAdd = 0 @@ -120,7 +120,7 @@ func TestCountHookPostDiff_AddOnly(t *testing.T) { } func TestCountHookPostDiff_ChangeOnly(t *testing.T) { - h := new(CountHook) + h := new(countHook) resources := map[string]*legacy.InstanceDiff{ "foo": &legacy.InstanceDiff{ @@ -153,7 +153,7 @@ func TestCountHookPostDiff_ChangeOnly(t *testing.T) { h.PostDiff(addr, states.CurrentGen, plans.Update, cty.DynamicVal, cty.DynamicVal) } - expected := new(CountHook) + expected := new(countHook) expected.ToAdd = 0 expected.ToChange = 3 expected.ToRemoveAndAdd = 0 @@ -165,7 +165,7 @@ func TestCountHookPostDiff_ChangeOnly(t *testing.T) { } func TestCountHookPostDiff_Mixed(t *testing.T) { - h := new(CountHook) + h := new(countHook) resources := map[string]plans.Action{ "foo": plans.Delete, @@ -184,7 +184,7 @@ func TestCountHookPostDiff_Mixed(t *testing.T) { h.PostDiff(addr, states.CurrentGen, a, cty.DynamicVal, cty.DynamicVal) } - expected := new(CountHook) + expected := new(countHook) expected.ToAdd = 0 expected.ToChange = 1 expected.ToRemoveAndAdd = 0 @@ -197,7 +197,7 @@ func TestCountHookPostDiff_Mixed(t *testing.T) { } func TestCountHookPostDiff_NoChange(t *testing.T) { - h := new(CountHook) + h := new(countHook) resources := map[string]*legacy.InstanceDiff{ "foo": &legacy.InstanceDiff{}, @@ -216,7 +216,7 @@ func TestCountHookPostDiff_NoChange(t *testing.T) { h.PostDiff(addr, states.CurrentGen, plans.NoOp, cty.DynamicVal, cty.DynamicVal) } - expected := new(CountHook) + expected := new(countHook) expected.ToAdd = 0 expected.ToChange = 0 expected.ToRemoveAndAdd = 0 @@ -229,7 +229,7 @@ func TestCountHookPostDiff_NoChange(t *testing.T) { } func TestCountHookPostDiff_DataSource(t *testing.T) { - h := new(CountHook) + h := new(countHook) resources := map[string]plans.Action{ "foo": plans.Delete, @@ -248,7 +248,7 @@ func TestCountHookPostDiff_DataSource(t *testing.T) { h.PostDiff(addr, states.CurrentGen, a, cty.DynamicVal, cty.DynamicVal) } - expected := new(CountHook) + expected := new(countHook) expected.ToAdd = 0 expected.ToChange = 0 expected.ToRemoveAndAdd = 0 @@ -261,7 +261,7 @@ func TestCountHookPostDiff_DataSource(t *testing.T) { } func TestCountHookApply_ChangeOnly(t *testing.T) { - h := new(CountHook) + h := new(countHook) resources := map[string]*legacy.InstanceDiff{ "foo": &legacy.InstanceDiff{ @@ -295,7 +295,7 @@ func TestCountHookApply_ChangeOnly(t *testing.T) { h.PostApply(addr, states.CurrentGen, cty.DynamicVal, nil) } - expected := &CountHook{pending: make(map[string]plans.Action)} + expected := &countHook{pending: make(map[string]plans.Action)} expected.Added = 0 expected.Changed = 3 expected.Removed = 0 @@ -306,7 +306,7 @@ func TestCountHookApply_ChangeOnly(t *testing.T) { } func TestCountHookApply_DestroyOnly(t *testing.T) { - h := new(CountHook) + h := new(countHook) resources := map[string]*legacy.InstanceDiff{ "foo": &legacy.InstanceDiff{Destroy: true}, @@ -326,7 +326,7 @@ func TestCountHookApply_DestroyOnly(t *testing.T) { h.PostApply(addr, states.CurrentGen, cty.DynamicVal, nil) } - expected := &CountHook{pending: make(map[string]plans.Action)} + expected := &countHook{pending: make(map[string]plans.Action)} expected.Added = 0 expected.Changed = 0 expected.Removed = 4 diff --git a/command/views/operation.go b/command/views/operation.go index 51d296d4b..972429d5e 100644 --- a/command/views/operation.go +++ b/command/views/operation.go @@ -15,6 +15,8 @@ import ( ) type Operation interface { + Interrupted() + FatalInterrupt() Stopping() Cancelled(destroy bool) @@ -51,6 +53,24 @@ type OperationHuman struct { var _ Operation = (*OperationHuman)(nil) +func (v *OperationHuman) Interrupted() { + v.streams.Println(format.WordWrap(interrupted, v.outputColumns())) +} + +func (v *OperationHuman) FatalInterrupt() { + v.streams.Eprintln(format.WordWrap(fatalInterrupt, v.errorColumns())) +} + +const fatalInterrupt = ` +Two interrupts received. Exiting immediately. Note that data loss may have occurred. +` + +const interrupted = ` +Interrupt received. +Please wait for Terraform to exit or data loss may occur. +Gracefully shutting down... +` + func (v *OperationHuman) Stopping() { v.streams.Println("Stopping operation...") }