From 68558ccd54e4f1eed7213af9c1b62d7c0e049fdd Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Wed, 17 Feb 2021 13:01:30 -0500 Subject: [PATCH] backend/local: Replace CLI with view instance This commit extracts the remaining UI logic from the local backend, and removes access to the direct CLI output. This is replaced with an instance of a `views.Operation` interface, which codifies the current requirements for the local backend to interact with the user. The exception to this at present is interactivity: approving a plan still depends on the `UIIn` field for the backend. This is out of scope for this commit and can be revisited separately, at which time the `UIOut` field can also be removed. Changes in support of this: - Some instances of direct error output have been replaced with diagnostics, most notably in the emergency state backup handler. This requires reformatting the error messages to allow the diagnostic renderer to line-wrap them; - The "in-automation" logic has moved out of the backend and into the view implementation; - The plan, apply, refresh, and import commands instantiate a view and set it on the `backend.Operation` struct, as these are the only code paths which call the `local.Operation()` method that requires it; - The show command requires the plan rendering code which is now in the views package, so there is a stub implementation of a `views.Show` interface there. Other refactoring work in support of migrating these commands to the common views code structure will come in follow-up PRs, at which point we will be able to remove the UI instances from the unit tests for those commands. --- backend/backend.go | 4 + backend/local/backend.go | 61 +++---- backend/local/backend_apply.go | 98 +++++------ backend/local/backend_apply_test.go | 57 +++++-- backend/local/backend_plan.go | 232 ++------------------------ backend/local/backend_plan_test.go | 157 ++++++++--------- backend/local/backend_refresh.go | 2 +- backend/local/backend_refresh_test.go | 29 +++- backend/local/cli.go | 47 ------ backend/remote/backend_apply_test.go | 21 ++- backend/remote/backend_plan_test.go | 18 +- backend/remote/testing.go | 2 - command/apply.go | 1 + command/apply_destroy_test.go | 7 +- command/apply_test.go | 4 +- command/import.go | 3 + command/plan.go | 3 + command/plan_test.go | 4 +- command/refresh.go | 1 + command/show.go | 14 +- command/show_test.go | 8 +- command/views/operation.go | 122 ++++++++++++++ command/views/operation_test.go | 152 +++++++++++++++++ command/views/plan.go | 145 ++++++++++++++++ command/views/plan_test.go | 91 ++++++++++ command/views/show.go | 39 +++++ command/views/view.go | 27 +++ 27 files changed, 854 insertions(+), 495 deletions(-) create mode 100644 command/views/operation.go create mode 100644 command/views/operation_test.go create mode 100644 command/views/plan.go create mode 100644 command/views/plan_test.go create mode 100644 command/views/show.go diff --git a/backend/backend.go b/backend/backend.go index 495dd4baf..6b801d104 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/command/clistate" + "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/configs" "github.com/hashicorp/terraform/configs/configload" "github.com/hashicorp/terraform/configs/configschema" @@ -208,6 +209,9 @@ type Operation struct { // the variables set in the plan are used instead, and they must be valid. AllowUnsetVariables bool + // View implements the logic for all UI interactions. + View views.Operation + // Input/output/control options. UIIn terraform.UIInput UIOut terraform.UIOutput diff --git a/backend/local/backend.go b/backend/local/backend.go index 279be8691..ecfe0c90d 100644 --- a/backend/local/backend.go +++ b/backend/local/backend.go @@ -12,13 +12,11 @@ import ( "sync" "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/configs/configschema" - "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" - "github.com/mitchellh/cli" - "github.com/mitchellh/colorstring" "github.com/zclconf/go-cty/cty" ) @@ -33,15 +31,6 @@ const ( // locally. This is the "default" backend and implements normal Terraform // behavior as it is well known. type Local struct { - // CLI and Colorize control the CLI output. If CLI is nil then no CLI - // output will be done. If CLIColor is nil then no coloring will be done. - CLI cli.Ui - CLIColor *colorstring.Colorize - - // If CLI is set then Streams might also be set, to describe the physical - // input/output handles that CLI is connected to. - Streams *terminal.Streams - // The State* paths are set from the backend config, and may be left blank // to use the defaults. If the actual paths for the local backend state are // needed, use the StatePaths method. @@ -93,15 +82,6 @@ type Local struct { // If this is nil, local performs normal state loading and storage. Backend backend.Backend - // RunningInAutomation indicates that commands are being run by an - // automated system rather than directly at a command prompt. - // - // This is a hint not to produce messages that expect that a user can - // run a follow-up command, perhaps because Terraform is running in - // some sort of workflow automation tool that abstracts away the - // exact commands that are being run. - RunningInAutomation bool - // opLock locks operations opLock sync.Mutex } @@ -289,6 +269,10 @@ func (b *Local) StateMgr(name string) (statemgr.Full, error) { // the structure with the following rules. If a rule isn't specified and the // name conflicts, assume that the field is overwritten if set. func (b *Local) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) { + if op.View == nil { + panic("Operation called with nil View") + } + // Determine the function to call for our operation var f func(context.Context, context.Context, *backend.Operation, *backend.RunningOperation) switch op.Type { @@ -348,14 +332,13 @@ func (b *Local) opWait( stopCtx context.Context, cancelCtx context.Context, tfCtx *terraform.Context, - opStateMgr statemgr.Persister) (canceled bool) { + opStateMgr statemgr.Persister, + view views.Operation) (canceled bool) { // Wait for the operation to finish or for us to be interrupted so // we can handle it properly. select { case <-stopCtx.Done(): - if b.CLI != nil { - b.CLI.Output("Stopping operation...") - } + view.Stopping() // try to force a PersistState just in case the process is terminated // before we can complete. @@ -363,9 +346,13 @@ func (b *Local) opWait( // We can't error out from here, but warn the user if there was an error. // If this isn't transient, we will catch it again below, and // attempt to save the state another way. - if b.CLI != nil { - b.CLI.Error(fmt.Sprintf(earlyStateWriteErrorFmt, err)) - } + var diags tfdiags.Diagnostics + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Error saving current state", + fmt.Sprintf(earlyStateWriteErrorFmt, err), + )) + view.Diagnostics(diags) } // Stop execution @@ -390,20 +377,6 @@ func (b *Local) opWait( return } -// Colorize returns the Colorize structure that can be used for colorizing -// output. This is guaranteed to always return a non-nil value and so is useful -// as a helper to wrap any potentially colored strings. -func (b *Local) Colorize() *colorstring.Colorize { - if b.CLIColor != nil { - return b.CLIColor - } - - return &colorstring.Colorize{ - Colors: colorstring.DefaultColors, - Disable: true, - } -} - // StatePaths returns the StatePath, StateOutPath, and StateBackupPath as // configured from the CLI. func (b *Local) StatePaths(name string) (stateIn, stateOut, backupOut string) { @@ -508,3 +481,7 @@ func (b *Local) stateWorkspaceDir() string { return DefaultWorkspaceDir } + +const earlyStateWriteErrorFmt = `Error: %s + +Terraform encountered an error attempting to save the state before cancelling the current operation. Once the operation is complete another attempt will be made to save the final state.` diff --git a/backend/local/backend_apply.go b/backend/local/backend_apply.go index 0cad0980f..4baba83fb 100644 --- a/backend/local/backend_apply.go +++ b/backend/local/backend_apply.go @@ -1,14 +1,13 @@ package local import ( - "bytes" "context" - "errors" "fmt" "log" "github.com/hashicorp/errwrap" "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states/statefile" "github.com/hashicorp/terraform/states/statemgr" @@ -96,9 +95,7 @@ func (b *Local) opApply( } if !trivialPlan { - // Display the plan of what we are going to apply/destroy. - b.renderPlan(plan, runningOp.State, tfCtx.Schemas()) - b.CLI.Output("") + op.View.Plan(plan, runningOp.State, tfCtx.Schemas()) } // We'll show any accumulated warnings before we display the prompt, @@ -119,11 +116,7 @@ func (b *Local) opApply( return } if v != "yes" { - if op.Destroy { - b.CLI.Info("Destroy cancelled.") - } else { - b.CLI.Info("Apply cancelled.") - } + op.View.Cancelled(op.Destroy) runningOp.Result = backend.OperationFailure return } @@ -145,7 +138,7 @@ func (b *Local) opApply( applyState = tfCtx.State() }() - if b.opWait(doneCh, stopCtx, cancelCtx, tfCtx, opState) { + if b.opWait(doneCh, stopCtx, cancelCtx, tfCtx, opState, op.View) { return } @@ -161,7 +154,7 @@ func (b *Local) opApply( } stateFile.State = applyState - diags = diags.Append(b.backupStateForError(stateFile, err)) + diags = diags.Append(b.backupStateForError(stateFile, err, op.View)) op.ReportResult(runningOp, diags) return } @@ -183,78 +176,77 @@ func (b *Local) opApply( // to local disk to help the user recover. This is a "last ditch effort" sort // of thing, so we really don't want to end up in this codepath; we should do // everything we possibly can to get the state saved _somewhere_. -func (b *Local) backupStateForError(stateFile *statefile.File, err error) error { - b.CLI.Error(fmt.Sprintf("Failed to save state: %s\n", err)) +func (b *Local) backupStateForError(stateFile *statefile.File, err error, view views.Operation) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to save state", + fmt.Sprintf("Error saving state: %s", err), + )) local := statemgr.NewFilesystem("errored.tfstate") writeErr := local.WriteStateForMigration(stateFile, true) if writeErr != nil { - b.CLI.Error(fmt.Sprintf( - "Also failed to create local state file for recovery: %s\n\n", writeErr, + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to create local state file", + fmt.Sprintf("Error creating local state file for recovery: %s", writeErr), )) + // To avoid leaving the user with no state at all, our last resort // is to print the JSON state out onto the terminal. This is an awful // UX, so we should definitely avoid doing this if at all possible, // but at least the user has _some_ path to recover if we end up // here for some reason. - stateBuf := new(bytes.Buffer) - jsonErr := statefile.Write(stateFile, stateBuf) - if jsonErr != nil { - b.CLI.Error(fmt.Sprintf( - "Also failed to JSON-serialize the state to print it: %s\n\n", jsonErr, + if dumpErr := view.EmergencyDumpState(stateFile); dumpErr != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to serialize state", + fmt.Sprintf(stateWriteFatalErrorFmt, dumpErr), )) - return errors.New(stateWriteFatalError) } - b.CLI.Output(stateBuf.String()) - - return errors.New(stateWriteConsoleFallbackError) + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to persist state to backend", + stateWriteConsoleFallbackError, + )) + return diags } - return errors.New(stateWriteBackedUpError) + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to persist state to backend", + stateWriteBackedUpError, + )) + + return diags } -const stateWriteBackedUpError = `Failed to persist state to backend. +const stateWriteBackedUpError = `The error shown above has prevented Terraform from writing the updated state to the configured backend. To allow for recovery, the state has been written to the file "errored.tfstate" in the current working directory. -The error shown above has prevented Terraform from writing the updated state -to the configured backend. To allow for recovery, the state has been written -to the file "errored.tfstate" in the current working directory. - -Running "terraform apply" again at this point will create a forked state, -making it harder to recover. +Running "terraform apply" again at this point will create a forked state, making it harder to recover. To retry writing this state, use the following command: terraform state push errored.tfstate ` -const stateWriteConsoleFallbackError = `Failed to persist state to backend. - -The errors shown above prevented Terraform from writing the updated state to +const stateWriteConsoleFallbackError = `The errors shown above prevented Terraform from writing the updated state to the configured backend and from creating a local backup file. As a fallback, the raw state data is printed above as a JSON object. -To retry writing this state, copy the state data (from the first { to the -last } inclusive) and save it into a local file called errored.tfstate, then -run the following command: +To retry writing this state, copy the state data (from the first { to the last } inclusive) and save it into a local file called errored.tfstate, then run the following command: terraform state push errored.tfstate ` -const stateWriteFatalError = `Failed to save state after apply. +const stateWriteFatalErrorFmt = `Failed to save state after apply. -A catastrophic error has prevented Terraform from persisting the state file -or creating a backup. Unfortunately this means that the record of any resources -created during this apply has been lost, and such resources may exist outside -of Terraform's management. +Error serializing state: %s -For resources that support import, it is possible to recover by manually -importing each resource using its id from the target system. +A catastrophic error has prevented Terraform from persisting the state file or creating a backup. Unfortunately this means that the record of any resources created during this apply has been lost, and such resources may exist outside of Terraform's management. + +For resources that support import, it is possible to recover by manually importing each resource using its id from the target system. This is a serious bug in Terraform and should be reported. ` - -const earlyStateWriteErrorFmt = `Error saving current state: %s - -Terraform encountered an error attempting to save the state before cancelling -the current operation. Once the operation is complete another attempt will be -made to save the final state. -` diff --git a/backend/local/backend_apply_test.go b/backend/local/backend_apply_test.go index 61a075073..88df036f9 100644 --- a/backend/local/backend_apply_test.go +++ b/backend/local/backend_apply_test.go @@ -9,13 +9,15 @@ import ( "sync" "testing" - "github.com/mitchellh/cli" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/clistate" + "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states/statemgr" @@ -33,7 +35,7 @@ func TestLocal_applyBasic(t *testing.T) { "ami": cty.StringVal("bar"), })} - op, configCleanup := testOperationApply(t, "./testdata/apply") + op, configCleanup, done := testOperationApply(t, "./testdata/apply") defer configCleanup() run, err := b.Operation(context.Background(), op) @@ -64,6 +66,9 @@ test_instance.foo: ami = bar `) + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } func TestLocal_applyEmptyDir(t *testing.T) { @@ -73,7 +78,7 @@ func TestLocal_applyEmptyDir(t *testing.T) { p := TestLocalProvider(t, b, "test", &terraform.ProviderSchema{}) p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{"id": cty.StringVal("yes")})} - op, configCleanup := testOperationApply(t, "./testdata/empty") + op, configCleanup, done := testOperationApply(t, "./testdata/empty") defer configCleanup() run, err := b.Operation(context.Background(), op) @@ -95,6 +100,10 @@ func TestLocal_applyEmptyDir(t *testing.T) { // the backend should be unlocked after a run assertBackendStateUnlocked(t, b) + + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } func TestLocal_applyEmptyDirDestroy(t *testing.T) { @@ -104,7 +113,7 @@ func TestLocal_applyEmptyDirDestroy(t *testing.T) { p := TestLocalProvider(t, b, "test", &terraform.ProviderSchema{}) p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{} - op, configCleanup := testOperationApply(t, "./testdata/empty") + op, configCleanup, done := testOperationApply(t, "./testdata/empty") defer configCleanup() op.Destroy = true @@ -122,6 +131,10 @@ func TestLocal_applyEmptyDirDestroy(t *testing.T) { } checkState(t, b.StateOutPath, ``) + + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } func TestLocal_applyError(t *testing.T) { @@ -166,7 +179,7 @@ func TestLocal_applyError(t *testing.T) { } } - op, configCleanup := testOperationApply(t, "./testdata/apply-error") + op, configCleanup, done := testOperationApply(t, "./testdata/apply-error") defer configCleanup() run, err := b.Operation(context.Background(), op) @@ -187,6 +200,10 @@ test_instance.foo: // the backend should be unlocked after a run assertBackendStateUnlocked(t, b) + + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } func TestLocal_applyBackendFail(t *testing.T) { @@ -209,11 +226,13 @@ func TestLocal_applyBackendFail(t *testing.T) { } defer os.Chdir(wd) - op, configCleanup := testOperationApply(t, wd+"/testdata/apply") + op, configCleanup, done := testOperationApply(t, wd+"/testdata/apply") defer configCleanup() + record, playback := testRecordDiagnostics(t) + op.ShowDiagnostics = record + b.Backend = &backendWithFailingState{} - b.CLI = new(cli.MockUi) run, err := b.Operation(context.Background(), op) if err != nil { @@ -224,9 +243,9 @@ func TestLocal_applyBackendFail(t *testing.T) { t.Fatalf("apply succeeded; want error") } - msgStr := b.CLI.(*cli.MockUi).ErrorWriter.String() - if !strings.Contains(msgStr, "Failed to save state: fake failure") { - t.Fatalf("missing \"fake failure\" message in output:\n%s", msgStr) + diagErr := playback().Err().Error() + if !strings.Contains(diagErr, "Error saving state: fake failure") { + t.Fatalf("missing \"fake failure\" message in diags:\n%s", diagErr) } // The fallback behavior should've created a file errored.tfstate in the @@ -240,6 +259,10 @@ test_instance.foo: // the backend should be unlocked after a run assertBackendStateUnlocked(t, b) + + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } func TestLocal_applyRefreshFalse(t *testing.T) { @@ -249,7 +272,7 @@ func TestLocal_applyRefreshFalse(t *testing.T) { p := TestLocalProvider(t, b, "test", planFixtureSchema()) testStateFile(t, b.StatePath, testPlanState()) - op, configCleanup := testOperationApply(t, "./testdata/plan") + op, configCleanup, done := testOperationApply(t, "./testdata/plan") defer configCleanup() run, err := b.Operation(context.Background(), op) @@ -264,6 +287,10 @@ func TestLocal_applyRefreshFalse(t *testing.T) { if p.ReadResourceCalled { t.Fatal("ReadResource should not be called") } + + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } type backendWithFailingState struct { @@ -284,18 +311,22 @@ func (s failingState) WriteState(state *states.State) error { return errors.New("fake failure") } -func testOperationApply(t *testing.T, configDir string) (*backend.Operation, func()) { +func testOperationApply(t *testing.T, configDir string) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + return &backend.Operation{ Type: backend.OperationTypeApply, ConfigDir: configDir, ConfigLoader: configLoader, ShowDiagnostics: testLogDiagnostics(t), StateLocker: clistate.NewNoopLocker(), - }, configCleanup + View: view, + }, configCleanup, done } // applyFixtureSchema returns a schema suitable for processing the diff --git a/backend/local/backend_plan.go b/backend/local/backend_plan.go index 0be65a645..abcc20daa 100644 --- a/backend/local/backend_plan.go +++ b/backend/local/backend_plan.go @@ -1,22 +1,13 @@ package local import ( - "bytes" "context" "fmt" "log" - "sort" - "strings" - "github.com/mitchellh/cli" - "github.com/mitchellh/colorstring" - - "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/backend" - "github.com/hashicorp/terraform/command/format" "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans/planfile" - "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" @@ -32,8 +23,6 @@ func (b *Local) opPlan( var diags tfdiags.Diagnostics - outputColumns := b.outputColumns() - if op.PlanFile != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, @@ -92,7 +81,7 @@ func (b *Local) opPlan( plan, planDiags = tfCtx.Plan() }() - if b.opWait(doneCh, stopCtx, cancelCtx, tfCtx, opState) { + if b.opWait(doneCh, stopCtx, cancelCtx, tfCtx, opState, op.View) { // If we get in here then the operation was cancelled, which is always // considered to be a failure. log.Printf("[INFO] backend/local: plan operation was force-cancelled by interrupt") @@ -141,211 +130,22 @@ func (b *Local) opPlan( } } - // Perform some output tasks if we have a CLI to output to. - if b.CLI != nil { - schemas := tfCtx.Schemas() + // Perform some output tasks + if runningOp.PlanEmpty { + op.View.PlanNoChanges() - if runningOp.PlanEmpty { - b.CLI.Output("\n" + b.Colorize().Color(strings.TrimSpace(planNoChanges))) - b.CLI.Output("\n" + strings.TrimSpace(format.WordWrap(planNoChangesDetail, outputColumns))) - // Even if there are no changes, there still could be some warnings - op.ShowDiagnostics(diags) - return - } - - b.renderPlan(plan, plan.State, schemas) - - // If we've accumulated any warnings along the way then we'll show them - // here just before we show the summary and next steps. If we encountered - // errors then we would've returned early at some other point above. + // Even if there are no changes, there still could be some warnings op.ShowDiagnostics(diags) - - // Give the user some next-steps, unless we're running in an automation - // tool which is presumed to provide its own UI for further actions. - if !b.RunningInAutomation { - - b.outputHorizRule() - - if path := op.PlanOutPath; path == "" { - b.CLI.Output(fmt.Sprintf( - "\n" + strings.TrimSpace(format.WordWrap(planHeaderNoOutput, outputColumns)) + "\n", - )) - } else { - b.CLI.Output(fmt.Sprintf( - "\n"+strings.TrimSpace(format.WordWrap(planHeaderYesOutput, outputColumns))+"\n", - path, path, - )) - } - } + return } + + // Render the plan + op.View.Plan(plan, plan.State, tfCtx.Schemas()) + + // If we've accumulated any warnings along the way then we'll show them + // here just before we show the summary and next steps. If we encountered + // errors then we would've returned early at some other point above. + op.ShowDiagnostics(diags) + + op.View.PlanNextStep(op.PlanOutPath) } - -func (b *Local) renderPlan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas) { - RenderPlan(plan, baseState, schemas, b.CLI, b.Colorize(), b.outputColumns()) -} - -// RenderPlan renders the given plan to the given UI. -// -// This is exported only so that the "terraform show" command can re-use it. -// Ideally it would be somewhere outside of this backend code so that both -// can call into it, but we're leaving it here for now in order to avoid -// disruptive refactoring. -// -// If you find yourself wanting to call this function from a third callsite, -// please consider whether it's time to do the more disruptive refactoring -// so that something other than the local backend package is offering this -// functionality. -// -// The difference between baseState and priorState is that baseState is the -// result of implicitly running refresh (unless that was disabled) while -// priorState is a snapshot of the state as it was before we took any actions -// at all. priorState can optionally be nil if the caller has only a saved -// plan and not the prior state it was built from. In that case, changes to -// output values will not currently be rendered because their prior values -// are currently stored only in the prior state. (see the docstring for -// func planHasSideEffects for why this is and when that might change) -func RenderPlan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas, ui cli.Ui, colorize *colorstring.Colorize, width int) { - counts := map[plans.Action]int{} - var rChanges []*plans.ResourceInstanceChangeSrc - for _, change := range plan.Changes.Resources { - if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode { - // Avoid rendering data sources on deletion - continue - } - - rChanges = append(rChanges, change) - counts[change.Action]++ - } - - headerBuf := &bytes.Buffer{} - fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, width))) - if counts[plans.Create] > 0 { - fmt.Fprintf(headerBuf, "%s create\n", format.DiffActionSymbol(plans.Create)) - } - if counts[plans.Update] > 0 { - fmt.Fprintf(headerBuf, "%s update in-place\n", format.DiffActionSymbol(plans.Update)) - } - if counts[plans.Delete] > 0 { - fmt.Fprintf(headerBuf, "%s destroy\n", format.DiffActionSymbol(plans.Delete)) - } - if counts[plans.DeleteThenCreate] > 0 { - fmt.Fprintf(headerBuf, "%s destroy and then create replacement\n", format.DiffActionSymbol(plans.DeleteThenCreate)) - } - if counts[plans.CreateThenDelete] > 0 { - fmt.Fprintf(headerBuf, "%s create replacement and then destroy\n", format.DiffActionSymbol(plans.CreateThenDelete)) - } - if counts[plans.Read] > 0 { - fmt.Fprintf(headerBuf, "%s read (data resources)\n", format.DiffActionSymbol(plans.Read)) - } - - ui.Output(colorize.Color(headerBuf.String())) - - ui.Output("Terraform will perform the following actions:\n") - - // Note: we're modifying the backing slice of this plan object in-place - // here. The ordering of resource changes in a plan is not significant, - // but we can only do this safely here because we can assume that nobody - // is concurrently modifying our changes while we're trying to print it. - sort.Slice(rChanges, func(i, j int) bool { - iA := rChanges[i].Addr - jA := rChanges[j].Addr - if iA.String() == jA.String() { - return rChanges[i].DeposedKey < rChanges[j].DeposedKey - } - return iA.Less(jA) - }) - - for _, rcs := range rChanges { - if rcs.Action == plans.NoOp { - continue - } - - providerSchema := schemas.ProviderSchema(rcs.ProviderAddr.Provider) - if providerSchema == nil { - // Should never happen - ui.Output(fmt.Sprintf("(schema missing for %s)\n", rcs.ProviderAddr)) - continue - } - rSchema, _ := providerSchema.SchemaForResourceAddr(rcs.Addr.Resource.Resource) - if rSchema == nil { - // Should never happen - ui.Output(fmt.Sprintf("(schema missing for %s)\n", rcs.Addr)) - continue - } - - // check if the change is due to a tainted resource - tainted := false - if !baseState.Empty() { - if is := baseState.ResourceInstance(rcs.Addr); is != nil { - if obj := is.GetGeneration(rcs.DeposedKey.Generation()); obj != nil { - tainted = obj.Status == states.ObjectTainted - } - } - } - - ui.Output(format.ResourceChange( - rcs, - tainted, - rSchema, - colorize, - )) - } - - // stats is similar to counts above, but: - // - it considers only resource changes - // - it simplifies "replace" into both a create and a delete - stats := map[plans.Action]int{} - for _, change := range rChanges { - switch change.Action { - case plans.CreateThenDelete, plans.DeleteThenCreate: - stats[plans.Create]++ - stats[plans.Delete]++ - default: - stats[change.Action]++ - } - } - ui.Output(colorize.Color(fmt.Sprintf( - "[reset][bold]Plan:[reset] "+ - "%d to add, %d to change, %d to destroy.", - stats[plans.Create], stats[plans.Update], stats[plans.Delete], - ))) - - // If there is at least one planned change to the root module outputs - // then we'll render a summary of those too. - var changedRootModuleOutputs []*plans.OutputChangeSrc - for _, output := range plan.Changes.Outputs { - if !output.Addr.Module.IsRoot() { - continue - } - if output.ChangeSrc.Action == plans.NoOp { - continue - } - changedRootModuleOutputs = append(changedRootModuleOutputs, output) - } - if len(changedRootModuleOutputs) > 0 { - ui.Output(colorize.Color("[reset]\n[bold]Changes to Outputs:[reset]" + format.OutputChanges(changedRootModuleOutputs, colorize))) - } -} - -const planHeaderIntro = ` -Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: -` - -const planHeaderNoOutput = ` -Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now. -` - -const planHeaderYesOutput = ` -Saved the plan to: %s - -To perform exactly these actions, run the following command to apply: - terraform apply %q -` - -const planNoChanges = ` -[reset][bold][green]No changes. Infrastructure is up-to-date.[reset][green] -` - -const planNoChangesDetail = ` -That Terraform did not detect any differences between your configuration and the remote system(s). As a result, there are no actions to take. -` diff --git a/backend/local/backend_plan_test.go b/backend/local/backend_plan_test.go index 690e4873d..a01f284cc 100644 --- a/backend/local/backend_plan_test.go +++ b/backend/local/backend_plan_test.go @@ -9,14 +9,16 @@ import ( "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/clistate" + "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans/planfile" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/terraform" - "github.com/mitchellh/cli" "github.com/zclconf/go-cty/cty" ) @@ -25,7 +27,7 @@ func TestLocal_planBasic(t *testing.T) { defer cleanup() p := TestLocalProvider(t, b, "test", planFixtureSchema()) - op, configCleanup := testOperationPlan(t, "./testdata/plan") + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") defer configCleanup() op.PlanRefresh = true @@ -44,6 +46,10 @@ func TestLocal_planBasic(t *testing.T) { // the backend should be unlocked after a run assertBackendStateUnlocked(t, b) + + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } func TestLocal_planInAutomation(t *testing.T) { @@ -53,58 +59,29 @@ func TestLocal_planInAutomation(t *testing.T) { const msg = `You didn't use the -out option` - // When we're "in automation" we omit certain text from the - // plan output. However, testing for the absense of text is - // unreliable in the face of future copy changes, so we'll - // mitigate that by running both with and without the flag - // set so we can ensure that the expected messages _are_ - // included the first time. - b.RunningInAutomation = false - b.CLI = cli.NewMockUi() - { - op, configCleanup := testOperationPlan(t, "./testdata/plan") - defer configCleanup() - op.PlanRefresh = true + // When we're "in automation" we omit certain text from the plan output. + // However, the responsibility for this omission is in the view, so here we + // test for its presence while the "in automation" setting is false, to + // validate that we are calling the correct view method. + // + // Ideally this test would be replaced by a call-logging mock view, but + // that's future work. + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + op.PlanRefresh = true - run, err := b.Operation(context.Background(), op) - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - <-run.Done() - if run.Result != backend.OperationSuccess { - t.Fatalf("plan operation failed") - } - - output := b.CLI.(*cli.MockUi).OutputWriter.String() - if !strings.Contains(output, msg) { - t.Fatalf("missing next-steps message when not in automation\nwant: %s\noutput:\n%s", msg, output) - } + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("plan operation failed") } - // On the second run, we expect the next-steps messaging to be absent - // since we're now "running in automation". - b.RunningInAutomation = true - b.CLI = cli.NewMockUi() - { - op, configCleanup := testOperationPlan(t, "./testdata/plan") - defer configCleanup() - op.PlanRefresh = true - - run, err := b.Operation(context.Background(), op) - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - <-run.Done() - if run.Result != backend.OperationSuccess { - t.Fatalf("plan operation failed") - } - - output := b.CLI.(*cli.MockUi).OutputWriter.String() - if strings.Contains(output, msg) { - t.Fatalf("next-steps message present when in automation") - } + if output := done(t).Stdout(); !strings.Contains(output, msg) { + t.Fatalf("missing next-steps message when not in automation\nwant: %s\noutput:\n%s", msg, output) } - } func TestLocal_planNoConfig(t *testing.T) { @@ -112,9 +89,7 @@ func TestLocal_planNoConfig(t *testing.T) { defer cleanup() TestLocalProvider(t, b, "test", &terraform.ProviderSchema{}) - b.CLI = cli.NewMockUi() - - op, configCleanup := testOperationPlan(t, "./testdata/empty") + op, configCleanup, done := testOperationPlan(t, "./testdata/empty") record, playback := testRecordDiagnostics(t) op.ShowDiagnostics = record defer configCleanup() @@ -137,6 +112,10 @@ func TestLocal_planNoConfig(t *testing.T) { // the backend should be unlocked after a run assertBackendStateUnlocked(t, b) + + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } // This test validates the state lacking behavior when the inner call to @@ -145,7 +124,7 @@ func TestLocal_plan_context_error(t *testing.T) { b, cleanup := TestLocal(t) defer cleanup() - op, configCleanup := testOperationPlan(t, "./testdata/plan") + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") defer configCleanup() op.PlanRefresh = true @@ -161,6 +140,10 @@ func TestLocal_plan_context_error(t *testing.T) { // the backend should be unlocked after a run assertBackendStateUnlocked(t, b) + + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } func TestLocal_planOutputsChanged(t *testing.T) { @@ -196,11 +179,10 @@ func TestLocal_planOutputsChanged(t *testing.T) { // unknown" situation because that's already common for printing out // resource changes and we already have many tests for that. })) - b.CLI = cli.NewMockUi() outDir := testTempDir(t) defer os.RemoveAll(outDir) planPath := filepath.Join(outDir, "plan.tfplan") - op, configCleanup := testOperationPlan(t, "./testdata/plan-outputs-changed") + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-outputs-changed") defer configCleanup() op.PlanRefresh = true op.PlanOutPath = planPath @@ -238,8 +220,8 @@ Changes to Outputs: ~ sensitive_after = (sensitive value) ~ sensitive_before = (sensitive value) `) - output := b.CLI.(*cli.MockUi).OutputWriter.String() - if !strings.Contains(output, expectedOutput) { + + if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) { t.Fatalf("Unexpected output:\n%s\n\nwant output containing:\n%s", output, expectedOutput) } } @@ -254,11 +236,10 @@ func TestLocal_planModuleOutputsChanged(t *testing.T) { OutputValue: addrs.OutputValue{Name: "changed"}, }, cty.StringVal("before"), false) })) - b.CLI = cli.NewMockUi() outDir := testTempDir(t) defer os.RemoveAll(outDir) planPath := filepath.Join(outDir, "plan.tfplan") - op, configCleanup := testOperationPlan(t, "./testdata/plan-module-outputs-changed") + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-module-outputs-changed") defer configCleanup() op.PlanRefresh = true op.PlanOutPath = planPath @@ -288,8 +269,7 @@ func TestLocal_planModuleOutputsChanged(t *testing.T) { expectedOutput := strings.TrimSpace(` No changes. Infrastructure is up-to-date. `) - output := b.CLI.(*cli.MockUi).OutputWriter.String() - if !strings.Contains(output, expectedOutput) { + if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) { t.Fatalf("Unexpected output:\n%s\n\nwant output containing:\n%s", output, expectedOutput) } } @@ -299,11 +279,10 @@ func TestLocal_planTainted(t *testing.T) { defer cleanup() p := TestLocalProvider(t, b, "test", planFixtureSchema()) testStateFile(t, b.StatePath, testPlanState_tainted()) - b.CLI = cli.NewMockUi() outDir := testTempDir(t) defer os.RemoveAll(outDir) planPath := filepath.Join(outDir, "plan.tfplan") - op, configCleanup := testOperationPlan(t, "./testdata/plan") + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") defer configCleanup() op.PlanRefresh = true op.PlanOutPath = planPath @@ -348,8 +327,7 @@ Terraform will perform the following actions: } Plan: 1 to add, 0 to change, 1 to destroy.` - output := b.CLI.(*cli.MockUi).OutputWriter.String() - if !strings.Contains(output, expectedOutput) { + if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) { t.Fatalf("Unexpected output:\n%s", output) } } @@ -382,11 +360,10 @@ func TestLocal_planDeposedOnly(t *testing.T) { }, ) })) - b.CLI = cli.NewMockUi() outDir := testTempDir(t) defer os.RemoveAll(outDir) planPath := filepath.Join(outDir, "plan.tfplan") - op, configCleanup := testOperationPlan(t, "./testdata/plan") + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") defer configCleanup() op.PlanRefresh = true op.PlanOutPath = planPath @@ -464,9 +441,8 @@ Terraform will perform the following actions: } Plan: 1 to add, 0 to change, 1 to destroy.` - output := b.CLI.(*cli.MockUi).OutputWriter.String() - if !strings.Contains(output, expectedOutput) { - t.Fatalf("Unexpected output:\n%s\n\nwant output containing:\n%s", output, expectedOutput) + if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) { + t.Fatalf("Unexpected output:\n%s", output) } } @@ -475,11 +451,10 @@ func TestLocal_planTainted_createBeforeDestroy(t *testing.T) { defer cleanup() p := TestLocalProvider(t, b, "test", planFixtureSchema()) testStateFile(t, b.StatePath, testPlanState_tainted()) - b.CLI = cli.NewMockUi() outDir := testTempDir(t) defer os.RemoveAll(outDir) planPath := filepath.Join(outDir, "plan.tfplan") - op, configCleanup := testOperationPlan(t, "./testdata/plan-cbd") + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-cbd") defer configCleanup() op.PlanRefresh = true op.PlanOutPath = planPath @@ -524,8 +499,7 @@ Terraform will perform the following actions: } Plan: 1 to add, 0 to change, 1 to destroy.` - output := b.CLI.(*cli.MockUi).OutputWriter.String() - if !strings.Contains(output, expectedOutput) { + if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) { t.Fatalf("Unexpected output:\n%s", output) } } @@ -537,7 +511,7 @@ func TestLocal_planRefreshFalse(t *testing.T) { p := TestLocalProvider(t, b, "test", planFixtureSchema()) testStateFile(t, b.StatePath, testPlanState()) - op, configCleanup := testOperationPlan(t, "./testdata/plan") + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") defer configCleanup() run, err := b.Operation(context.Background(), op) @@ -556,6 +530,10 @@ func TestLocal_planRefreshFalse(t *testing.T) { if !run.PlanEmpty { t.Fatal("plan should be empty") } + + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } func TestLocal_planDestroy(t *testing.T) { @@ -569,7 +547,7 @@ func TestLocal_planDestroy(t *testing.T) { defer os.RemoveAll(outDir) planPath := filepath.Join(outDir, "plan.tfplan") - op, configCleanup := testOperationPlan(t, "./testdata/plan") + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") defer configCleanup() op.Destroy = true op.PlanRefresh = true @@ -606,6 +584,10 @@ func TestLocal_planDestroy(t *testing.T) { t.Fatalf("bad: %#v", r.Action.String()) } } + + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } func TestLocal_planDestroy_withDataSources(t *testing.T) { @@ -615,13 +597,11 @@ func TestLocal_planDestroy_withDataSources(t *testing.T) { TestLocalProvider(t, b, "test", planFixtureSchema()) testStateFile(t, b.StatePath, testPlanState_withDataSource()) - b.CLI = cli.NewMockUi() - outDir := testTempDir(t) defer os.RemoveAll(outDir) planPath := filepath.Join(outDir, "plan.tfplan") - op, configCleanup := testOperationPlan(t, "./testdata/destroy-with-ds") + op, configCleanup, done := testOperationPlan(t, "./testdata/destroy-with-ds") defer configCleanup() op.Destroy = true op.PlanRefresh = true @@ -674,8 +654,7 @@ func TestLocal_planDestroy_withDataSources(t *testing.T) { Plan: 0 to add, 0 to change, 1 to destroy.` - output := b.CLI.(*cli.MockUi).OutputWriter.String() - if !strings.Contains(output, expectedOutput) { + if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) { t.Fatalf("Unexpected output:\n%s", output) } } @@ -698,7 +677,7 @@ func TestLocal_planOutPathNoChange(t *testing.T) { defer os.RemoveAll(outDir) planPath := filepath.Join(outDir, "plan.tfplan") - op, configCleanup := testOperationPlan(t, "./testdata/plan") + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") defer configCleanup() op.PlanOutPath = planPath cfg := cty.ObjectVal(map[string]cty.Value{ @@ -729,20 +708,28 @@ func TestLocal_planOutPathNoChange(t *testing.T) { if !plan.Changes.Empty() { t.Fatalf("expected empty plan to be written") } + + if errOutput := done(t).Stderr(); errOutput != "" { + t.Fatalf("unexpected error output:\n%s", errOutput) + } } -func testOperationPlan(t *testing.T, configDir string) (*backend.Operation, func()) { +func testOperationPlan(t *testing.T, configDir string) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + return &backend.Operation{ Type: backend.OperationTypePlan, ConfigDir: configDir, ConfigLoader: configLoader, ShowDiagnostics: testLogDiagnostics(t), StateLocker: clistate.NewNoopLocker(), - }, configCleanup + View: view, + }, configCleanup, done } // testPlanState is just a common state that we use for testing plan. diff --git a/backend/local/backend_refresh.go b/backend/local/backend_refresh.go index d712a1c90..ec3f9bde1 100644 --- a/backend/local/backend_refresh.go +++ b/backend/local/backend_refresh.go @@ -82,7 +82,7 @@ func (b *Local) opRefresh( log.Printf("[INFO] backend/local: refresh calling Refresh") }() - if b.opWait(doneCh, stopCtx, cancelCtx, tfCtx, opState) { + if b.opWait(doneCh, stopCtx, cancelCtx, tfCtx, opState, op.View) { return } diff --git a/backend/local/backend_refresh_test.go b/backend/local/backend_refresh_test.go index 9066d30bc..91c8087b4 100644 --- a/backend/local/backend_refresh_test.go +++ b/backend/local/backend_refresh_test.go @@ -8,9 +8,12 @@ import ( "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/clistate" + "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/terraform" @@ -30,8 +33,9 @@ func TestLocal_refresh(t *testing.T) { "id": cty.StringVal("yes"), })} - op, configCleanup := testOperationRefresh(t, "./testdata/refresh") + op, configCleanup, done := testOperationRefresh(t, "./testdata/refresh") defer configCleanup() + defer done(t) run, err := b.Operation(context.Background(), op) if err != nil { @@ -94,8 +98,9 @@ func TestLocal_refreshInput(t *testing.T) { b.OpInput = true b.ContextOpts.UIInput = &terraform.MockUIInput{InputReturnString: "bar"} - op, configCleanup := testOperationRefresh(t, "./testdata/refresh-var-unset") + op, configCleanup, done := testOperationRefresh(t, "./testdata/refresh-var-unset") defer configCleanup() + defer done(t) op.UIIn = b.ContextOpts.UIInput run, err := b.Operation(context.Background(), op) @@ -128,8 +133,9 @@ func TestLocal_refreshValidate(t *testing.T) { // Enable validation b.OpValidation = true - op, configCleanup := testOperationRefresh(t, "./testdata/refresh") + op, configCleanup, done := testOperationRefresh(t, "./testdata/refresh") defer configCleanup() + defer done(t) run, err := b.Operation(context.Background(), op) if err != nil { @@ -174,8 +180,9 @@ func TestLocal_refreshValidateProviderConfigured(t *testing.T) { // Enable validation b.OpValidation = true - op, configCleanup := testOperationRefresh(t, "./testdata/refresh-provider-config") + op, configCleanup, done := testOperationRefresh(t, "./testdata/refresh-provider-config") defer configCleanup() + defer done(t) run, err := b.Operation(context.Background(), op) if err != nil { @@ -200,8 +207,9 @@ func TestLocal_refresh_context_error(t *testing.T) { b, cleanup := TestLocal(t) defer cleanup() testStateFile(t, b.StatePath, testRefreshState()) - op, configCleanup := testOperationRefresh(t, "./testdata/apply") + op, configCleanup, done := testOperationRefresh(t, "./testdata/apply") defer configCleanup() + defer done(t) // we coerce a failure in Context() by omitting the provider schema @@ -228,8 +236,9 @@ func TestLocal_refreshEmptyState(t *testing.T) { "id": cty.StringVal("yes"), })} - op, configCleanup := testOperationRefresh(t, "./testdata/refresh") + op, configCleanup, done := testOperationRefresh(t, "./testdata/refresh") defer configCleanup() + defer done(t) record, playback := testRecordDiagnostics(t) op.ShowDiagnostics = record @@ -252,18 +261,22 @@ func TestLocal_refreshEmptyState(t *testing.T) { assertBackendStateUnlocked(t, b) } -func testOperationRefresh(t *testing.T, configDir string) (*backend.Operation, func()) { +func testOperationRefresh(t *testing.T, configDir string) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + return &backend.Operation{ Type: backend.OperationTypeRefresh, ConfigDir: configDir, ConfigLoader: configLoader, ShowDiagnostics: testLogDiagnostics(t), StateLocker: clistate.NewNoopLocker(), - }, configCleanup + View: view, + }, configCleanup, done } // testRefreshState is just a common state that we use for testing refresh. diff --git a/backend/local/cli.go b/backend/local/cli.go index c698c0af9..9963cbee6 100644 --- a/backend/local/cli.go +++ b/backend/local/cli.go @@ -4,18 +4,13 @@ import ( "log" "github.com/hashicorp/terraform/backend" - "github.com/hashicorp/terraform/command/format" ) // backend.CLI impl. func (b *Local) CLIInit(opts *backend.CLIOpts) error { - b.CLI = opts.CLI - b.CLIColor = opts.CLIColor - b.Streams = opts.Streams b.ContextOpts = opts.ContextOpts b.OpInput = opts.Input b.OpValidation = opts.Validation - b.RunningInAutomation = opts.RunningInAutomation // configure any new cli options if opts.StatePath != "" { @@ -35,45 +30,3 @@ func (b *Local) CLIInit(opts *backend.CLIOpts) error { return nil } - -// outputColumns returns the number of text character cells any non-error -// output should be wrapped to. -// -// This is the number of columns to use if you are calling b.CLI.Output or -// b.CLI.Info. -func (b *Local) outputColumns() int { - if b.Streams == nil { - // We can potentially get here in tests, if they don't populate the - // CLIOpts fully. - return 78 // placeholder just so we don't panic - } - return b.Streams.Stdout.Columns() -} - -// errorColumns returns the number of text character cells any error -// output should be wrapped to. -// -// This is the number of columns to use if you are calling b.CLI.Error or -// b.CLI.Warn. -func (b *Local) errorColumns() int { - if b.Streams == nil { - // We can potentially get here in tests, if they don't populate the - // CLIOpts fully. - return 78 // placeholder just so we don't panic - } - return b.Streams.Stderr.Columns() -} - -// outputHorizRule will call b.CLI.Output with enough horizontal line -// characters to fill an entire row of output. -// -// This function does nothing if the backend doesn't have a CLI attached. -// -// If UI color is enabled, the rule will get a dark grey coloring to try to -// visually de-emphasize it. -func (b *Local) outputHorizRule() { - if b.CLI == nil { - return - } - b.CLI.Output(format.HorizontalRule(b.CLIColor, b.outputColumns())) -} diff --git a/backend/remote/backend_apply_test.go b/backend/remote/backend_apply_test.go index 1665271d9..fae4b476b 100644 --- a/backend/remote/backend_apply_test.go +++ b/backend/remote/backend_apply_test.go @@ -778,6 +778,10 @@ func TestRemote_applyForceLocal(t *testing.T) { op.UIOut = b.CLI op.Workspace = backend.DefaultStateName + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + run, err := b.Operation(context.Background(), op) if err != nil { t.Fatalf("error starting operation: %v", err) @@ -799,8 +803,8 @@ func TestRemote_applyForceLocal(t *testing.T) { if strings.Contains(output, "Running apply in the remote backend") { t.Fatalf("unexpected remote backend header in output: %s", output) } - if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { - t.Fatalf("expected plan summery in output: %s", output) + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) } if !run.State.HasResources() { t.Fatalf("expected resources in state") @@ -836,6 +840,10 @@ func TestRemote_applyWorkspaceWithoutOperations(t *testing.T) { op.UIOut = b.CLI op.Workspace = "no-operations" + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + run, err := b.Operation(ctx, op) if err != nil { t.Fatalf("error starting operation: %v", err) @@ -857,8 +865,8 @@ func TestRemote_applyWorkspaceWithoutOperations(t *testing.T) { if strings.Contains(output, "Running apply in the remote backend") { t.Fatalf("unexpected remote backend header in output: %s", output) } - if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { - t.Fatalf("expected plan summery in output: %s", output) + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) } if !run.State.HasResources() { t.Fatalf("expected resources in state") @@ -1388,6 +1396,11 @@ func TestRemote_applyVersionCheck(t *testing.T) { op, configCleanup, playback := testOperationApplyWithDiagnostics(t, "./testdata/apply") defer configCleanup() + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + defer done(t) + input := testInput(t, map[string]string{ "approve": "yes", }) diff --git a/backend/remote/backend_plan_test.go b/backend/remote/backend_plan_test.go index d273c46d1..54925fb32 100644 --- a/backend/remote/backend_plan_test.go +++ b/backend/remote/backend_plan_test.go @@ -514,6 +514,10 @@ func TestRemote_planForceLocal(t *testing.T) { op.Workspace = backend.DefaultStateName + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + run, err := b.Operation(context.Background(), op) if err != nil { t.Fatalf("error starting operation: %v", err) @@ -531,7 +535,7 @@ func TestRemote_planForceLocal(t *testing.T) { if strings.Contains(output, "Running plan in the remote backend") { t.Fatalf("unexpected remote backend header in output: %s", output) } - if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { t.Fatalf("expected plan summary in output: %s", output) } } @@ -545,6 +549,10 @@ func TestRemote_planWithoutOperationsEntitlement(t *testing.T) { op.Workspace = backend.DefaultStateName + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + run, err := b.Operation(context.Background(), op) if err != nil { t.Fatalf("error starting operation: %v", err) @@ -562,7 +570,7 @@ func TestRemote_planWithoutOperationsEntitlement(t *testing.T) { if strings.Contains(output, "Running plan in the remote backend") { t.Fatalf("unexpected remote backend header in output: %s", output) } - if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { t.Fatalf("expected plan summary in output: %s", output) } } @@ -590,6 +598,10 @@ func TestRemote_planWorkspaceWithoutOperations(t *testing.T) { op.Workspace = "no-operations" + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + run, err := b.Operation(ctx, op) if err != nil { t.Fatalf("error starting operation: %v", err) @@ -607,7 +619,7 @@ func TestRemote_planWorkspaceWithoutOperations(t *testing.T) { if strings.Contains(output, "Running plan in the remote backend") { t.Fatalf("unexpected remote backend header in output: %s", output) } - if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { t.Fatalf("expected plan summary in output: %s", output) } } diff --git a/backend/remote/testing.go b/backend/remote/testing.go index 249ddcf2b..035cdff88 100644 --- a/backend/remote/testing.go +++ b/backend/remote/testing.go @@ -155,8 +155,6 @@ func testBackend(t *testing.T, obj cty.Value) (*Remote, func()) { func testLocalBackend(t *testing.T, remote *Remote) backend.Enhanced { b := backendLocal.NewWithBackend(remote) - b.CLI = remote.CLI - // Add a test provider to the local backend. p := backendLocal.TestLocalProvider(t, b, "null", &terraform.ProviderSchema{ ResourceTypes: map[string]*configschema.Block{ diff --git a/command/apply.go b/command/apply.go index df8a726ba..9ddb6fd98 100644 --- a/command/apply.go +++ b/command/apply.go @@ -176,6 +176,7 @@ func (c *ApplyCommand) Run(args []string) int { opReq.PlanRefresh = refresh opReq.ShowDiagnostics = c.showDiagnostics opReq.Type = backend.OperationTypeApply + opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) opReq.ConfigLoader, err = c.initConfigLoader() if err != nil { diff --git a/command/apply_destroy_test.go b/command/apply_destroy_test.go index b309623c1..547ebdb9b 100644 --- a/command/apply_destroy_test.go +++ b/command/apply_destroy_test.go @@ -58,7 +58,8 @@ func TestApply_destroy(t *testing.T) { } ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) + defer done(t) c := &ApplyCommand{ Destroy: true, Meta: Meta{ @@ -159,7 +160,7 @@ func TestApply_destroyApproveNo(t *testing.T) { p := applyFixtureProvider() ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Destroy: true, Meta: Meta{ @@ -175,7 +176,7 @@ func TestApply_destroyApproveNo(t *testing.T) { if code := c.Run(args); code != 1 { t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) } - if got, want := ui.OutputWriter.String(), "Destroy cancelled"; !strings.Contains(got, want) { + if got, want := done(t).Stdout(), "Destroy cancelled"; !strings.Contains(got, want) { t.Fatalf("expected output to include %q, but was:\n%s", want, got) } diff --git a/command/apply_test.go b/command/apply_test.go index 9bc4aadb2..5b7836484 100644 --- a/command/apply_test.go +++ b/command/apply_test.go @@ -116,7 +116,7 @@ func TestApply_approveNo(t *testing.T) { p := applyFixtureProvider() ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), @@ -131,7 +131,7 @@ func TestApply_approveNo(t *testing.T) { if code := c.Run(args); code != 1 { t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) } - if got, want := ui.OutputWriter.String(), "Apply cancelled"; !strings.Contains(got, want) { + if got, want := done(t).Stdout(), "Apply cancelled"; !strings.Contains(got, want) { t.Fatalf("expected output to include %q, but was:\n%s", want, got) } diff --git a/command/import.go b/command/import.go index 3c20a53ce..e94907eff 100644 --- a/command/import.go +++ b/command/import.go @@ -12,6 +12,8 @@ import ( "github.com/hashicorp/terraform/addrs" "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" @@ -199,6 +201,7 @@ func (c *ImportCommand) Run(args []string) int { return 1 } } + opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) // Check remote Terraform version is compatible remoteVersionDiags := c.remoteBackendVersionCheck(b, opReq.Workspace) diff --git a/command/plan.go b/command/plan.go index c81ea75cc..e7695333f 100644 --- a/command/plan.go +++ b/command/plan.go @@ -5,6 +5,8 @@ import ( "strings" "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/plans" "github.com/hashicorp/terraform/terraform" @@ -88,6 +90,7 @@ func (c *PlanCommand) Run(args []string) int { 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 { diff --git a/command/plan_test.go b/command/plan_test.go index 6b0a67c16..5cf53e276 100644 --- a/command/plan_test.go +++ b/command/plan_test.go @@ -971,7 +971,7 @@ func TestPlan_targeted(t *testing.T) { } ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &PlanCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), @@ -988,7 +988,7 @@ func TestPlan_targeted(t *testing.T) { t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) } - if got, want := ui.OutputWriter.String(), "3 to add, 0 to change, 0 to destroy"; !strings.Contains(got, want) { + if got, want := done(t).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) } } diff --git a/command/refresh.go b/command/refresh.go index df70252fa..ebd609b01 100644 --- a/command/refresh.go +++ b/command/refresh.go @@ -79,6 +79,7 @@ func (c *RefreshCommand) Run(args []string) int { opReq.Hooks = []terraform.Hook{c.uiHook()} opReq.ShowDiagnostics = c.showDiagnostics opReq.Type = backend.OperationTypeRefresh + opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) opReq.ConfigLoader, err = c.initConfigLoader() if err != nil { diff --git a/command/show.go b/command/show.go index db5efee10..a7ddcb2de 100644 --- a/command/show.go +++ b/command/show.go @@ -6,10 +6,11 @@ import ( "strings" "github.com/hashicorp/terraform/backend" - localBackend "github.com/hashicorp/terraform/backend/local" + "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/format" "github.com/hashicorp/terraform/command/jsonplan" "github.com/hashicorp/terraform/command/jsonstate" + "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans/planfile" "github.com/hashicorp/terraform/states/statefile" @@ -158,15 +159,8 @@ func (c *ShowCommand) Run(args []string) int { return 0 } - // FIXME: We currently call into the local backend for this, since - // the "terraform plan" logic lives there and our package call graph - // means we can't orient this dependency the other way around. In - // future we'll hopefully be able to refactor the backend architecture - // a little so that CLI UI rendering always happens in this "command" - // package rather than in the backends themselves, but for now we're - // accepting this oddity because "terraform show" is a less commonly - // used way to render a plan than "terraform plan" is. - localBackend.RenderPlan(plan, stateFile.State, schemas, c.Ui, c.Colorize(), c.OutputColumns()) + view := views.NewShow(arguments.ViewHuman, c.View) + view.Plan(plan, stateFile.State, schemas) return 0 } diff --git a/command/show_test.go b/command/show_test.go index 2c2096e21..e0172ba58 100644 --- a/command/show_test.go +++ b/command/show_test.go @@ -147,7 +147,7 @@ func TestShow_plan(t *testing.T) { planPath := testPlanFileNoop(t) ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &ShowCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(testProvider()), @@ -164,7 +164,7 @@ func TestShow_plan(t *testing.T) { } want := `Terraform will perform the following actions` - got := ui.OutputWriter.String() + got := done(t).Stdout() if !strings.Contains(got, want) { t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got) } @@ -174,7 +174,7 @@ func TestShow_planWithChanges(t *testing.T) { planPathWithChanges := showFixturePlanFile(t, plans.DeleteThenCreate) ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &ShowCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(showFixtureProvider()), @@ -192,7 +192,7 @@ func TestShow_planWithChanges(t *testing.T) { } want := `test_instance.foo must be replaced` - got := ui.OutputWriter.String() + got := done(t).Stdout() if !strings.Contains(got, want) { t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got) } diff --git a/command/views/operation.go b/command/views/operation.go new file mode 100644 index 000000000..51d296d4b --- /dev/null +++ b/command/views/operation.go @@ -0,0 +1,122 @@ +package views + +import ( + "bytes" + "fmt" + "strings" + + "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/states/statefile" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/terraform/tfdiags" +) + +type Operation interface { + Stopping() + Cancelled(destroy bool) + + EmergencyDumpState(stateFile *statefile.File) error + + PlanNoChanges() + Plan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas) + PlanNextStep(planPath string) + + Diagnostics(diags tfdiags.Diagnostics) +} + +func NewOperation(vt arguments.ViewType, inAutomation bool, view *View) Operation { + switch vt { + case arguments.ViewHuman: + return &OperationHuman{View: *view, inAutomation: inAutomation} + default: + panic(fmt.Sprintf("unknown view type %v", vt)) + } +} + +type OperationHuman struct { + View + + // inAutomation indicates that commands are being run by an + // automated system rather than directly at a command prompt. + // + // This is a hint not to produce messages that expect that a user can + // run a follow-up command, perhaps because Terraform is running in + // some sort of workflow automation tool that abstracts away the + // exact commands that are being run. + inAutomation bool +} + +var _ Operation = (*OperationHuman)(nil) + +func (v *OperationHuman) Stopping() { + v.streams.Println("Stopping operation...") +} + +func (v *OperationHuman) Cancelled(destroy bool) { + if destroy { + v.streams.Println("Destroy cancelled.") + } else { + v.streams.Println("Apply cancelled.") + } +} + +func (v *OperationHuman) EmergencyDumpState(stateFile *statefile.File) error { + stateBuf := new(bytes.Buffer) + jsonErr := statefile.Write(stateFile, stateBuf) + if jsonErr != nil { + return jsonErr + } + v.streams.Eprintln(stateBuf) + return nil +} + +func (v *OperationHuman) PlanNoChanges() { + v.streams.Println("\n" + v.colorize.Color(strings.TrimSpace(planNoChanges))) + v.streams.Println("\n" + strings.TrimSpace(format.WordWrap(planNoChangesDetail, v.outputColumns()))) +} + +func (v *OperationHuman) Plan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas) { + renderPlan(plan, baseState, schemas, &v.View) +} + +// PlanNextStep gives the user some next-steps, unless we're running in an +// automation tool which is presumed to provide its own UI for further actions. +func (v *OperationHuman) PlanNextStep(planPath string) { + if v.inAutomation { + return + } + v.outputHorizRule() + + if planPath == "" { + v.streams.Print( + "\n" + strings.TrimSpace(format.WordWrap(planHeaderNoOutput, v.outputColumns())) + "\n", + ) + } else { + v.streams.Printf( + "\n"+strings.TrimSpace(format.WordWrap(planHeaderYesOutput, v.outputColumns()))+"\n", + planPath, planPath, + ) + } +} + +const planNoChanges = ` +[reset][bold][green]No changes. Infrastructure is up-to-date.[reset][green] +` + +const planNoChangesDetail = ` +That Terraform did not detect any differences between your configuration and the remote system(s). As a result, there are no actions to take. +` + +const planHeaderNoOutput = ` +Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now. +` + +const planHeaderYesOutput = ` +Saved the plan to: %s + +To perform exactly these actions, run the following command to apply: + terraform apply %q +` diff --git a/command/views/operation_test.go b/command/views/operation_test.go new file mode 100644 index 000000000..c792f67aa --- /dev/null +++ b/command/views/operation_test.go @@ -0,0 +1,152 @@ +package views + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/hashicorp/terraform/command/arguments" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/states" + "github.com/hashicorp/terraform/states/statefile" +) + +func TestOperation_stopping(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOperation(arguments.ViewHuman, false, NewView(streams)) + + v.Stopping() + + if got, want := done(t).Stdout(), "Stopping operation...\n"; got != want { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } +} + +func TestOperation_cancelled(t *testing.T) { + testCases := map[string]struct { + destroy bool + want string + }{ + "apply": { + destroy: false, + want: "Apply cancelled.\n", + }, + "destroy": { + destroy: true, + want: "Destroy cancelled.\n", + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOperation(arguments.ViewHuman, false, NewView(streams)) + + v.Cancelled(tc.destroy) + + if got, want := done(t).Stdout(), tc.want; got != want { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } + }) + } +} + +func TestOperation_emergencyDumpState(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOperation(arguments.ViewHuman, false, NewView(streams)) + + stateFile := statefile.New(nil, "foo", 1) + + err := v.EmergencyDumpState(stateFile) + if err != nil { + t.Fatalf("unexpected error dumping state: %s", err) + } + + // Check that the result (on stderr) looks like JSON state + raw := done(t).Stderr() + var state map[string]interface{} + if err := json.Unmarshal([]byte(raw), &state); err != nil { + t.Fatalf("unexpected error parsing dumped state: %s\nraw:\n%s", err, raw) + } +} + +func TestOperation_planNoChanges(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOperation(arguments.ViewHuman, false, NewView(streams)) + + v.PlanNoChanges() + + if got, want := done(t).Stdout(), "No changes. Infrastructure is up-to-date."; !strings.Contains(got, want) { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } +} + +func TestOperation_plan(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOperation(arguments.ViewHuman, true, NewView(streams)) + + plan := testPlan(t) + state := states.NewState() + schemas := testSchemas() + v.Plan(plan, state, schemas) + + want := ` +Terraform used the selected providers to generate the following execution +plan. Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # test_resource.foo will be created + + resource "test_resource" "foo" { + + foo = "bar" + + id = (known after apply) + } + +Plan: 1 to add, 0 to change, 0 to destroy. +` + + if got := done(t).Stdout(); got != want { + t.Errorf("unexpected output\ngot:\n%s\nwant:\n%s", got, want) + } +} + +func TestOperation_planNextStep(t *testing.T) { + testCases := map[string]struct { + path string + want string + }{ + "no state path": { + path: "", + want: "You didn't use the -out option", + }, + "state path": { + path: "good plan.tfplan", + want: `terraform apply "good plan.tfplan"`, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOperation(arguments.ViewHuman, false, NewView(streams)) + + v.PlanNextStep(tc.path) + + if got := done(t).Stdout(); !strings.Contains(got, tc.want) { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, tc.want) + } + }) + } +} + +// The in-automation state is on the view itself, so testing it separately is +// clearer. +func TestOperation_planNextStepInAutomation(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOperation(arguments.ViewHuman, true, NewView(streams)) + + v.PlanNextStep("") + + if got := done(t).Stdout(); got != "" { + t.Errorf("unexpected output\ngot: %q", got) + } +} diff --git a/command/views/plan.go b/command/views/plan.go new file mode 100644 index 000000000..ade7838c3 --- /dev/null +++ b/command/views/plan.go @@ -0,0 +1,145 @@ +package views + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/command/format" + "github.com/hashicorp/terraform/plans" + "github.com/hashicorp/terraform/states" + "github.com/hashicorp/terraform/terraform" +) + +// 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) { + counts := map[plans.Action]int{} + var rChanges []*plans.ResourceInstanceChangeSrc + for _, change := range plan.Changes.Resources { + if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode { + // Avoid rendering data sources on deletion + continue + } + + rChanges = append(rChanges, change) + counts[change.Action]++ + } + + headerBuf := &bytes.Buffer{} + fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, view.outputColumns()))) + if counts[plans.Create] > 0 { + fmt.Fprintf(headerBuf, "%s create\n", format.DiffActionSymbol(plans.Create)) + } + if counts[plans.Update] > 0 { + fmt.Fprintf(headerBuf, "%s update in-place\n", format.DiffActionSymbol(plans.Update)) + } + if counts[plans.Delete] > 0 { + fmt.Fprintf(headerBuf, "%s destroy\n", format.DiffActionSymbol(plans.Delete)) + } + if counts[plans.DeleteThenCreate] > 0 { + fmt.Fprintf(headerBuf, "%s destroy and then create replacement\n", format.DiffActionSymbol(plans.DeleteThenCreate)) + } + if counts[plans.CreateThenDelete] > 0 { + fmt.Fprintf(headerBuf, "%s create replacement and then destroy\n", format.DiffActionSymbol(plans.CreateThenDelete)) + } + if counts[plans.Read] > 0 { + fmt.Fprintf(headerBuf, "%s read (data resources)\n", format.DiffActionSymbol(plans.Read)) + } + + view.streams.Println(view.colorize.Color(headerBuf.String())) + + view.streams.Printf("Terraform will perform the following actions:\n\n") + + // Note: we're modifying the backing slice of this plan object in-place + // here. The ordering of resource changes in a plan is not significant, + // but we can only do this safely here because we can assume that nobody + // is concurrently modifying our changes while we're trying to print it. + sort.Slice(rChanges, func(i, j int) bool { + iA := rChanges[i].Addr + jA := rChanges[j].Addr + if iA.String() == jA.String() { + return rChanges[i].DeposedKey < rChanges[j].DeposedKey + } + return iA.Less(jA) + }) + + for _, rcs := range rChanges { + if rcs.Action == plans.NoOp { + continue + } + + providerSchema := schemas.ProviderSchema(rcs.ProviderAddr.Provider) + if providerSchema == nil { + // Should never happen + view.streams.Printf("(schema missing for %s)\n\n", rcs.ProviderAddr) + continue + } + rSchema, _ := providerSchema.SchemaForResourceAddr(rcs.Addr.Resource.Resource) + if rSchema == nil { + // Should never happen + view.streams.Printf("(schema missing for %s)\n\n", rcs.Addr) + continue + } + + // check if the change is due to a tainted resource + tainted := false + if !baseState.Empty() { + if is := baseState.ResourceInstance(rcs.Addr); is != nil { + if obj := is.GetGeneration(rcs.DeposedKey.Generation()); obj != nil { + tainted = obj.Status == states.ObjectTainted + } + } + } + + view.streams.Println(format.ResourceChange( + rcs, + tainted, + rSchema, + view.colorize, + )) + } + + // stats is similar to counts above, but: + // - it considers only resource changes + // - it simplifies "replace" into both a create and a delete + stats := map[plans.Action]int{} + for _, change := range rChanges { + switch change.Action { + case plans.CreateThenDelete, plans.DeleteThenCreate: + stats[plans.Create]++ + stats[plans.Delete]++ + default: + stats[change.Action]++ + } + } + view.streams.Printf( + view.colorize.Color("[reset][bold]Plan:[reset] %d to add, %d to change, %d to destroy.\n"), + stats[plans.Create], stats[plans.Update], stats[plans.Delete], + ) + + // If there is at least one planned change to the root module outputs + // then we'll render a summary of those too. + var changedRootModuleOutputs []*plans.OutputChangeSrc + for _, output := range plan.Changes.Outputs { + if !output.Addr.Module.IsRoot() { + continue + } + if output.ChangeSrc.Action == plans.NoOp { + continue + } + changedRootModuleOutputs = append(changedRootModuleOutputs, output) + } + if len(changedRootModuleOutputs) > 0 { + view.streams.Println( + view.colorize.Color("[reset]\n[bold]Changes to Outputs:[reset]") + + format.OutputChanges(changedRootModuleOutputs, view.colorize), + ) + } +} + +const planHeaderIntro = ` +Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: +` diff --git a/command/views/plan_test.go b/command/views/plan_test.go new file mode 100644 index 000000000..a8ab98804 --- /dev/null +++ b/command/views/plan_test.go @@ -0,0 +1,91 @@ +package views + +import ( + "testing" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/plans" + "github.com/hashicorp/terraform/providers" + "github.com/hashicorp/terraform/terraform" + "github.com/zclconf/go-cty/cty" +) + +// Helper functions to build a trivial test plan, to exercise the plan +// renderer. +func testPlan(t *testing.T) *plans.Plan { + t.Helper() + + plannedVal := cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("bar"), + }) + priorValRaw, err := plans.NewDynamicValue(cty.NullVal(plannedVal.Type()), plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + plannedValRaw, err := plans.NewDynamicValue(plannedVal, plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + + changes := plans.NewChanges() + changes.SyncWrapper().AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{ + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_resource", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + Before: priorValRaw, + After: plannedValRaw, + }, + }) + + return &plans.Plan{ + Changes: changes, + } +} + +func testSchemas() *terraform.Schemas { + provider := testProvider() + return &terraform.Schemas{ + Providers: map[addrs.Provider]*terraform.ProviderSchema{ + addrs.NewDefaultProvider("test"): provider.ProviderSchema(), + }, + } +} + +func testProvider() *terraform.MockProvider { + p := new(terraform.MockProvider) + p.ReadResourceFn = func(req providers.ReadResourceRequest) providers.ReadResourceResponse { + return providers.ReadResourceResponse{NewState: req.PriorState} + } + + p.GetProviderSchemaResponse = testProviderSchema() + + return p +} + +func testProviderSchema() *providers.GetProviderSchemaResponse { + return &providers.GetProviderSchemaResponse{ + Provider: providers.Schema{ + Block: &configschema.Block{}, + }, + ResourceTypes: map[string]providers.Schema{ + "test_resource": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + "foo": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + } +} diff --git a/command/views/show.go b/command/views/show.go new file mode 100644 index 000000000..91bd0c2f8 --- /dev/null +++ b/command/views/show.go @@ -0,0 +1,39 @@ +package views + +import ( + "fmt" + + "github.com/hashicorp/terraform/command/arguments" + "github.com/hashicorp/terraform/plans" + "github.com/hashicorp/terraform/states" + "github.com/hashicorp/terraform/terraform" +) + +// FIXME: this is a temporary partial definition of the view for the show +// command, in place to allow access to the plan renderer which is now in the +// views package. +type Show interface { + Plan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas) +} + +// FIXME: the show view should support both human and JSON types. This code is +// currently only used to render the plan in human-readable UI, so does not yet +// support JSON. +func NewShow(vt arguments.ViewType, view *View) Show { + switch vt { + case arguments.ViewHuman: + return &ShowHuman{View: *view} + default: + panic(fmt.Sprintf("unknown view type %v", vt)) + } +} + +type ShowHuman struct { + View +} + +var _ Show = (*ShowHuman)(nil) + +func (v *ShowHuman) Plan(plan *plans.Plan, baseState *states.State, schemas *terraform.Schemas) { + renderPlan(plan, baseState, schemas, &v.View) +} diff --git a/command/views/view.go b/command/views/view.go index 57977a243..69078ec63 100644 --- a/command/views/view.go +++ b/command/views/view.go @@ -112,3 +112,30 @@ const helpPrompt = ` For more help on using this command, run: terraform %s -help ` + +// outputColumns returns the number of text character cells any non-error +// output should be wrapped to. +// +// This is the number of columns to use if you are calling v.streams.Print or +// related functions. +func (v *View) outputColumns() int { + return v.streams.Stdout.Columns() +} + +// errorColumns returns the number of text character cells any error +// output should be wrapped to. +// +// This is the number of columns to use if you are calling v.streams.Eprint +// or related functions. +func (v *View) errorColumns() int { + return v.streams.Stderr.Columns() +} + +// outputHorizRule will call v.streams.Println with enough horizontal line +// characters to fill an entire row of output. +// +// If UI color is enabled, the rule will get a dark grey coloring to try to +// visually de-emphasize it. +func (v *View) outputHorizRule() { + v.streams.Println(format.HorizontalRule(v.colorize, v.outputColumns())) +}