Merge pull request #27808 from hashicorp/alisdair/command-views-operation-view

backend/local: Replace CLI with view instance
This commit is contained in:
Alisdair McDiarmid 2021-02-18 12:14:33 -05:00 committed by GitHub
commit 7f8b8a9046
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 854 additions and 495 deletions

View File

@ -13,6 +13,7 @@ import (
"github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/command/clistate" "github.com/hashicorp/terraform/command/clistate"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/configs" "github.com/hashicorp/terraform/configs"
"github.com/hashicorp/terraform/configs/configload" "github.com/hashicorp/terraform/configs/configload"
"github.com/hashicorp/terraform/configs/configschema" "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. // the variables set in the plan are used instead, and they must be valid.
AllowUnsetVariables bool AllowUnsetVariables bool
// View implements the logic for all UI interactions.
View views.Operation
// Input/output/control options. // Input/output/control options.
UIIn terraform.UIInput UIIn terraform.UIInput
UIOut terraform.UIOutput UIOut terraform.UIOutput

View File

@ -12,13 +12,11 @@ import (
"sync" "sync"
"github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/states/statemgr"
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags" "github.com/hashicorp/terraform/tfdiags"
"github.com/mitchellh/cli"
"github.com/mitchellh/colorstring"
"github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty"
) )
@ -33,15 +31,6 @@ const (
// locally. This is the "default" backend and implements normal Terraform // locally. This is the "default" backend and implements normal Terraform
// behavior as it is well known. // behavior as it is well known.
type Local struct { 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 // 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 // to use the defaults. If the actual paths for the local backend state are
// needed, use the StatePaths method. // needed, use the StatePaths method.
@ -93,15 +82,6 @@ type Local struct {
// If this is nil, local performs normal state loading and storage. // If this is nil, local performs normal state loading and storage.
Backend backend.Backend 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 locks operations
opLock sync.Mutex 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 // the structure with the following rules. If a rule isn't specified and the
// name conflicts, assume that the field is overwritten if set. // name conflicts, assume that the field is overwritten if set.
func (b *Local) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) { 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 // Determine the function to call for our operation
var f func(context.Context, context.Context, *backend.Operation, *backend.RunningOperation) var f func(context.Context, context.Context, *backend.Operation, *backend.RunningOperation)
switch op.Type { switch op.Type {
@ -348,14 +332,13 @@ func (b *Local) opWait(
stopCtx context.Context, stopCtx context.Context,
cancelCtx context.Context, cancelCtx context.Context,
tfCtx *terraform.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 // Wait for the operation to finish or for us to be interrupted so
// we can handle it properly. // we can handle it properly.
select { select {
case <-stopCtx.Done(): case <-stopCtx.Done():
if b.CLI != nil { view.Stopping()
b.CLI.Output("Stopping operation...")
}
// try to force a PersistState just in case the process is terminated // try to force a PersistState just in case the process is terminated
// before we can complete. // 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. // 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 // If this isn't transient, we will catch it again below, and
// attempt to save the state another way. // attempt to save the state another way.
if b.CLI != nil { var diags tfdiags.Diagnostics
b.CLI.Error(fmt.Sprintf(earlyStateWriteErrorFmt, err)) diags = diags.Append(tfdiags.Sourceless(
} tfdiags.Error,
"Error saving current state",
fmt.Sprintf(earlyStateWriteErrorFmt, err),
))
view.Diagnostics(diags)
} }
// Stop execution // Stop execution
@ -390,20 +377,6 @@ func (b *Local) opWait(
return 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 // StatePaths returns the StatePath, StateOutPath, and StateBackupPath as
// configured from the CLI. // configured from the CLI.
func (b *Local) StatePaths(name string) (stateIn, stateOut, backupOut string) { func (b *Local) StatePaths(name string) (stateIn, stateOut, backupOut string) {
@ -508,3 +481,7 @@ func (b *Local) stateWorkspaceDir() string {
return DefaultWorkspaceDir 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.`

View File

@ -1,14 +1,13 @@
package local package local
import ( import (
"bytes"
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"github.com/hashicorp/errwrap" "github.com/hashicorp/errwrap"
"github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/states/statefile" "github.com/hashicorp/terraform/states/statefile"
"github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/states/statemgr"
@ -96,9 +95,7 @@ func (b *Local) opApply(
} }
if !trivialPlan { if !trivialPlan {
// Display the plan of what we are going to apply/destroy. op.View.Plan(plan, runningOp.State, tfCtx.Schemas())
b.renderPlan(plan, runningOp.State, tfCtx.Schemas())
b.CLI.Output("")
} }
// We'll show any accumulated warnings before we display the prompt, // We'll show any accumulated warnings before we display the prompt,
@ -119,11 +116,7 @@ func (b *Local) opApply(
return return
} }
if v != "yes" { if v != "yes" {
if op.Destroy { op.View.Cancelled(op.Destroy)
b.CLI.Info("Destroy cancelled.")
} else {
b.CLI.Info("Apply cancelled.")
}
runningOp.Result = backend.OperationFailure runningOp.Result = backend.OperationFailure
return return
} }
@ -145,7 +138,7 @@ func (b *Local) opApply(
applyState = tfCtx.State() applyState = tfCtx.State()
}() }()
if b.opWait(doneCh, stopCtx, cancelCtx, tfCtx, opState) { if b.opWait(doneCh, stopCtx, cancelCtx, tfCtx, opState, op.View) {
return return
} }
@ -161,7 +154,7 @@ func (b *Local) opApply(
} }
stateFile.State = applyState stateFile.State = applyState
diags = diags.Append(b.backupStateForError(stateFile, err)) diags = diags.Append(b.backupStateForError(stateFile, err, op.View))
op.ReportResult(runningOp, diags) op.ReportResult(runningOp, diags)
return return
} }
@ -183,78 +176,77 @@ func (b *Local) opApply(
// to local disk to help the user recover. This is a "last ditch effort" sort // 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 // 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_. // everything we possibly can to get the state saved _somewhere_.
func (b *Local) backupStateForError(stateFile *statefile.File, err error) error { func (b *Local) backupStateForError(stateFile *statefile.File, err error, view views.Operation) tfdiags.Diagnostics {
b.CLI.Error(fmt.Sprintf("Failed to save state: %s\n", err)) 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") local := statemgr.NewFilesystem("errored.tfstate")
writeErr := local.WriteStateForMigration(stateFile, true) writeErr := local.WriteStateForMigration(stateFile, true)
if writeErr != nil { if writeErr != nil {
b.CLI.Error(fmt.Sprintf( diags = diags.Append(tfdiags.Sourceless(
"Also failed to create local state file for recovery: %s\n\n", writeErr, 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 // 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 // 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, // 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 // but at least the user has _some_ path to recover if we end up
// here for some reason. // here for some reason.
stateBuf := new(bytes.Buffer) if dumpErr := view.EmergencyDumpState(stateFile); dumpErr != nil {
jsonErr := statefile.Write(stateFile, stateBuf) diags = diags.Append(tfdiags.Sourceless(
if jsonErr != nil { tfdiags.Error,
b.CLI.Error(fmt.Sprintf( "Failed to serialize state",
"Also failed to JSON-serialize the state to print it: %s\n\n", jsonErr, fmt.Sprintf(stateWriteFatalErrorFmt, dumpErr),
)) ))
return errors.New(stateWriteFatalError)
} }
b.CLI.Output(stateBuf.String()) diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
return errors.New(stateWriteConsoleFallbackError) "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 Running "terraform apply" again at this point will create a forked state, making it harder to recover.
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.
To retry writing this state, use the following command: To retry writing this state, use the following command:
terraform state push errored.tfstate terraform state push errored.tfstate
` `
const stateWriteConsoleFallbackError = `Failed to persist state to backend. const stateWriteConsoleFallbackError = `The errors shown above prevented Terraform from writing the updated state to
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 configured backend and from creating a local backup file. As a fallback,
the raw state data is printed above as a JSON object. 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 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:
last } inclusive) and save it into a local file called errored.tfstate, then
run the following command:
terraform state push errored.tfstate 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 Error serializing state: %s
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 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.
importing each resource using its id from the target system.
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. 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.
`

View File

@ -9,13 +9,15 @@ import (
"sync" "sync"
"testing" "testing"
"github.com/mitchellh/cli"
"github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/clistate" "github.com/hashicorp/terraform/command/clistate"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/initwd"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/providers"
"github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/states/statemgr"
@ -33,7 +35,7 @@ func TestLocal_applyBasic(t *testing.T) {
"ami": cty.StringVal("bar"), "ami": cty.StringVal("bar"),
})} })}
op, configCleanup := testOperationApply(t, "./testdata/apply") op, configCleanup, done := testOperationApply(t, "./testdata/apply")
defer configCleanup() defer configCleanup()
run, err := b.Operation(context.Background(), op) run, err := b.Operation(context.Background(), op)
@ -64,6 +66,9 @@ test_instance.foo:
ami = bar ami = bar
`) `)
if errOutput := done(t).Stderr(); errOutput != "" {
t.Fatalf("unexpected error output:\n%s", errOutput)
}
} }
func TestLocal_applyEmptyDir(t *testing.T) { func TestLocal_applyEmptyDir(t *testing.T) {
@ -73,7 +78,7 @@ func TestLocal_applyEmptyDir(t *testing.T) {
p := TestLocalProvider(t, b, "test", &terraform.ProviderSchema{}) p := TestLocalProvider(t, b, "test", &terraform.ProviderSchema{})
p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{"id": cty.StringVal("yes")})} 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() defer configCleanup()
run, err := b.Operation(context.Background(), op) 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 // the backend should be unlocked after a run
assertBackendStateUnlocked(t, b) assertBackendStateUnlocked(t, b)
if errOutput := done(t).Stderr(); errOutput != "" {
t.Fatalf("unexpected error output:\n%s", errOutput)
}
} }
func TestLocal_applyEmptyDirDestroy(t *testing.T) { func TestLocal_applyEmptyDirDestroy(t *testing.T) {
@ -104,7 +113,7 @@ func TestLocal_applyEmptyDirDestroy(t *testing.T) {
p := TestLocalProvider(t, b, "test", &terraform.ProviderSchema{}) p := TestLocalProvider(t, b, "test", &terraform.ProviderSchema{})
p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{} p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{}
op, configCleanup := testOperationApply(t, "./testdata/empty") op, configCleanup, done := testOperationApply(t, "./testdata/empty")
defer configCleanup() defer configCleanup()
op.Destroy = true op.Destroy = true
@ -122,6 +131,10 @@ func TestLocal_applyEmptyDirDestroy(t *testing.T) {
} }
checkState(t, b.StateOutPath, `<no state>`) checkState(t, b.StateOutPath, `<no state>`)
if errOutput := done(t).Stderr(); errOutput != "" {
t.Fatalf("unexpected error output:\n%s", errOutput)
}
} }
func TestLocal_applyError(t *testing.T) { 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() defer configCleanup()
run, err := b.Operation(context.Background(), op) run, err := b.Operation(context.Background(), op)
@ -187,6 +200,10 @@ test_instance.foo:
// the backend should be unlocked after a run // the backend should be unlocked after a run
assertBackendStateUnlocked(t, b) assertBackendStateUnlocked(t, b)
if errOutput := done(t).Stderr(); errOutput != "" {
t.Fatalf("unexpected error output:\n%s", errOutput)
}
} }
func TestLocal_applyBackendFail(t *testing.T) { func TestLocal_applyBackendFail(t *testing.T) {
@ -209,11 +226,13 @@ func TestLocal_applyBackendFail(t *testing.T) {
} }
defer os.Chdir(wd) defer os.Chdir(wd)
op, configCleanup := testOperationApply(t, wd+"/testdata/apply") op, configCleanup, done := testOperationApply(t, wd+"/testdata/apply")
defer configCleanup() defer configCleanup()
record, playback := testRecordDiagnostics(t)
op.ShowDiagnostics = record
b.Backend = &backendWithFailingState{} b.Backend = &backendWithFailingState{}
b.CLI = new(cli.MockUi)
run, err := b.Operation(context.Background(), op) run, err := b.Operation(context.Background(), op)
if err != nil { if err != nil {
@ -224,9 +243,9 @@ func TestLocal_applyBackendFail(t *testing.T) {
t.Fatalf("apply succeeded; want error") t.Fatalf("apply succeeded; want error")
} }
msgStr := b.CLI.(*cli.MockUi).ErrorWriter.String() diagErr := playback().Err().Error()
if !strings.Contains(msgStr, "Failed to save state: fake failure") { if !strings.Contains(diagErr, "Error saving state: fake failure") {
t.Fatalf("missing \"fake failure\" message in output:\n%s", msgStr) t.Fatalf("missing \"fake failure\" message in diags:\n%s", diagErr)
} }
// The fallback behavior should've created a file errored.tfstate in the // 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 // the backend should be unlocked after a run
assertBackendStateUnlocked(t, b) assertBackendStateUnlocked(t, b)
if errOutput := done(t).Stderr(); errOutput != "" {
t.Fatalf("unexpected error output:\n%s", errOutput)
}
} }
func TestLocal_applyRefreshFalse(t *testing.T) { func TestLocal_applyRefreshFalse(t *testing.T) {
@ -249,7 +272,7 @@ func TestLocal_applyRefreshFalse(t *testing.T) {
p := TestLocalProvider(t, b, "test", planFixtureSchema()) p := TestLocalProvider(t, b, "test", planFixtureSchema())
testStateFile(t, b.StatePath, testPlanState()) testStateFile(t, b.StatePath, testPlanState())
op, configCleanup := testOperationApply(t, "./testdata/plan") op, configCleanup, done := testOperationApply(t, "./testdata/plan")
defer configCleanup() defer configCleanup()
run, err := b.Operation(context.Background(), op) run, err := b.Operation(context.Background(), op)
@ -264,6 +287,10 @@ func TestLocal_applyRefreshFalse(t *testing.T) {
if p.ReadResourceCalled { if p.ReadResourceCalled {
t.Fatal("ReadResource should not be called") t.Fatal("ReadResource should not be called")
} }
if errOutput := done(t).Stderr(); errOutput != "" {
t.Fatalf("unexpected error output:\n%s", errOutput)
}
} }
type backendWithFailingState struct { type backendWithFailingState struct {
@ -284,18 +311,22 @@ func (s failingState) WriteState(state *states.State) error {
return errors.New("fake failure") 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() t.Helper()
_, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir)
streams, done := terminal.StreamsForTesting(t)
view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams))
return &backend.Operation{ return &backend.Operation{
Type: backend.OperationTypeApply, Type: backend.OperationTypeApply,
ConfigDir: configDir, ConfigDir: configDir,
ConfigLoader: configLoader, ConfigLoader: configLoader,
ShowDiagnostics: testLogDiagnostics(t), ShowDiagnostics: testLogDiagnostics(t),
StateLocker: clistate.NewNoopLocker(), StateLocker: clistate.NewNoopLocker(),
}, configCleanup View: view,
}, configCleanup, done
} }
// applyFixtureSchema returns a schema suitable for processing the // applyFixtureSchema returns a schema suitable for processing the

View File

@ -1,22 +1,13 @@
package local package local
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"log" "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/backend"
"github.com/hashicorp/terraform/command/format"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
"github.com/hashicorp/terraform/plans/planfile" "github.com/hashicorp/terraform/plans/planfile"
"github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/states/statemgr"
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags" "github.com/hashicorp/terraform/tfdiags"
@ -32,8 +23,6 @@ func (b *Local) opPlan(
var diags tfdiags.Diagnostics var diags tfdiags.Diagnostics
outputColumns := b.outputColumns()
if op.PlanFile != nil { if op.PlanFile != nil {
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error, tfdiags.Error,
@ -92,7 +81,7 @@ func (b *Local) opPlan(
plan, planDiags = tfCtx.Plan() 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 // If we get in here then the operation was cancelled, which is always
// considered to be a failure. // considered to be a failure.
log.Printf("[INFO] backend/local: plan operation was force-cancelled by interrupt") 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. // Perform some output tasks
if b.CLI != nil { if runningOp.PlanEmpty {
schemas := tfCtx.Schemas() op.View.PlanNoChanges()
if runningOp.PlanEmpty { // Even if there are no changes, there still could be some warnings
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.
op.ShowDiagnostics(diags) op.ShowDiagnostics(diags)
return
// 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,
))
}
}
} }
// 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.
`

View File

@ -9,14 +9,16 @@ import (
"github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/clistate" "github.com/hashicorp/terraform/command/clistate"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/initwd"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
"github.com/hashicorp/terraform/plans/planfile" "github.com/hashicorp/terraform/plans/planfile"
"github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
"github.com/mitchellh/cli"
"github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty"
) )
@ -25,7 +27,7 @@ func TestLocal_planBasic(t *testing.T) {
defer cleanup() defer cleanup()
p := TestLocalProvider(t, b, "test", planFixtureSchema()) p := TestLocalProvider(t, b, "test", planFixtureSchema())
op, configCleanup := testOperationPlan(t, "./testdata/plan") op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup() defer configCleanup()
op.PlanRefresh = true op.PlanRefresh = true
@ -44,6 +46,10 @@ func TestLocal_planBasic(t *testing.T) {
// the backend should be unlocked after a run // the backend should be unlocked after a run
assertBackendStateUnlocked(t, b) assertBackendStateUnlocked(t, b)
if errOutput := done(t).Stderr(); errOutput != "" {
t.Fatalf("unexpected error output:\n%s", errOutput)
}
} }
func TestLocal_planInAutomation(t *testing.T) { 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` const msg = `You didn't use the -out option`
// When we're "in automation" we omit certain text from the // When we're "in automation" we omit certain text from the plan output.
// plan output. However, testing for the absense of text is // However, the responsibility for this omission is in the view, so here we
// unreliable in the face of future copy changes, so we'll // test for its presence while the "in automation" setting is false, to
// mitigate that by running both with and without the flag // validate that we are calling the correct view method.
// set so we can ensure that the expected messages _are_ //
// included the first time. // Ideally this test would be replaced by a call-logging mock view, but
b.RunningInAutomation = false // that's future work.
b.CLI = cli.NewMockUi() op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
{ defer configCleanup()
op, configCleanup := testOperationPlan(t, "./testdata/plan") op.PlanRefresh = true
defer configCleanup()
op.PlanRefresh = true
run, err := b.Operation(context.Background(), op) run, err := b.Operation(context.Background(), op)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }
<-run.Done() <-run.Done()
if run.Result != backend.OperationSuccess { if run.Result != backend.OperationSuccess {
t.Fatalf("plan operation failed") 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)
}
} }
// On the second run, we expect the next-steps messaging to be absent if output := done(t).Stdout(); !strings.Contains(output, msg) {
// since we're now "running in automation". t.Fatalf("missing next-steps message when not in automation\nwant: %s\noutput:\n%s", msg, output)
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")
}
} }
} }
func TestLocal_planNoConfig(t *testing.T) { func TestLocal_planNoConfig(t *testing.T) {
@ -112,9 +89,7 @@ func TestLocal_planNoConfig(t *testing.T) {
defer cleanup() defer cleanup()
TestLocalProvider(t, b, "test", &terraform.ProviderSchema{}) TestLocalProvider(t, b, "test", &terraform.ProviderSchema{})
b.CLI = cli.NewMockUi() op, configCleanup, done := testOperationPlan(t, "./testdata/empty")
op, configCleanup := testOperationPlan(t, "./testdata/empty")
record, playback := testRecordDiagnostics(t) record, playback := testRecordDiagnostics(t)
op.ShowDiagnostics = record op.ShowDiagnostics = record
defer configCleanup() defer configCleanup()
@ -137,6 +112,10 @@ func TestLocal_planNoConfig(t *testing.T) {
// the backend should be unlocked after a run // the backend should be unlocked after a run
assertBackendStateUnlocked(t, b) 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 // 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) b, cleanup := TestLocal(t)
defer cleanup() defer cleanup()
op, configCleanup := testOperationPlan(t, "./testdata/plan") op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup() defer configCleanup()
op.PlanRefresh = true op.PlanRefresh = true
@ -161,6 +140,10 @@ func TestLocal_plan_context_error(t *testing.T) {
// the backend should be unlocked after a run // the backend should be unlocked after a run
assertBackendStateUnlocked(t, b) assertBackendStateUnlocked(t, b)
if errOutput := done(t).Stderr(); errOutput != "" {
t.Fatalf("unexpected error output:\n%s", errOutput)
}
} }
func TestLocal_planOutputsChanged(t *testing.T) { 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 // unknown" situation because that's already common for printing out
// resource changes and we already have many tests for that. // resource changes and we already have many tests for that.
})) }))
b.CLI = cli.NewMockUi()
outDir := testTempDir(t) outDir := testTempDir(t)
defer os.RemoveAll(outDir) defer os.RemoveAll(outDir)
planPath := filepath.Join(outDir, "plan.tfplan") 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() defer configCleanup()
op.PlanRefresh = true op.PlanRefresh = true
op.PlanOutPath = planPath op.PlanOutPath = planPath
@ -238,8 +220,8 @@ Changes to Outputs:
~ sensitive_after = (sensitive value) ~ sensitive_after = (sensitive value)
~ sensitive_before = (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) 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"}, OutputValue: addrs.OutputValue{Name: "changed"},
}, cty.StringVal("before"), false) }, cty.StringVal("before"), false)
})) }))
b.CLI = cli.NewMockUi()
outDir := testTempDir(t) outDir := testTempDir(t)
defer os.RemoveAll(outDir) defer os.RemoveAll(outDir)
planPath := filepath.Join(outDir, "plan.tfplan") 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() defer configCleanup()
op.PlanRefresh = true op.PlanRefresh = true
op.PlanOutPath = planPath op.PlanOutPath = planPath
@ -288,8 +269,7 @@ func TestLocal_planModuleOutputsChanged(t *testing.T) {
expectedOutput := strings.TrimSpace(` expectedOutput := strings.TrimSpace(`
No changes. Infrastructure is up-to-date. No changes. Infrastructure is up-to-date.
`) `)
output := b.CLI.(*cli.MockUi).OutputWriter.String() if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) {
if !strings.Contains(output, expectedOutput) {
t.Fatalf("Unexpected output:\n%s\n\nwant output containing:\n%s", 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() defer cleanup()
p := TestLocalProvider(t, b, "test", planFixtureSchema()) p := TestLocalProvider(t, b, "test", planFixtureSchema())
testStateFile(t, b.StatePath, testPlanState_tainted()) testStateFile(t, b.StatePath, testPlanState_tainted())
b.CLI = cli.NewMockUi()
outDir := testTempDir(t) outDir := testTempDir(t)
defer os.RemoveAll(outDir) defer os.RemoveAll(outDir)
planPath := filepath.Join(outDir, "plan.tfplan") planPath := filepath.Join(outDir, "plan.tfplan")
op, configCleanup := testOperationPlan(t, "./testdata/plan") op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup() defer configCleanup()
op.PlanRefresh = true op.PlanRefresh = true
op.PlanOutPath = planPath op.PlanOutPath = planPath
@ -348,8 +327,7 @@ Terraform will perform the following actions:
} }
Plan: 1 to add, 0 to change, 1 to destroy.` Plan: 1 to add, 0 to change, 1 to destroy.`
output := b.CLI.(*cli.MockUi).OutputWriter.String() if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) {
if !strings.Contains(output, expectedOutput) {
t.Fatalf("Unexpected output:\n%s", output) t.Fatalf("Unexpected output:\n%s", output)
} }
} }
@ -382,11 +360,10 @@ func TestLocal_planDeposedOnly(t *testing.T) {
}, },
) )
})) }))
b.CLI = cli.NewMockUi()
outDir := testTempDir(t) outDir := testTempDir(t)
defer os.RemoveAll(outDir) defer os.RemoveAll(outDir)
planPath := filepath.Join(outDir, "plan.tfplan") planPath := filepath.Join(outDir, "plan.tfplan")
op, configCleanup := testOperationPlan(t, "./testdata/plan") op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup() defer configCleanup()
op.PlanRefresh = true op.PlanRefresh = true
op.PlanOutPath = planPath op.PlanOutPath = planPath
@ -464,9 +441,8 @@ Terraform will perform the following actions:
} }
Plan: 1 to add, 0 to change, 1 to destroy.` Plan: 1 to add, 0 to change, 1 to destroy.`
output := b.CLI.(*cli.MockUi).OutputWriter.String() if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) {
if !strings.Contains(output, expectedOutput) { t.Fatalf("Unexpected output:\n%s", output)
t.Fatalf("Unexpected output:\n%s\n\nwant output containing:\n%s", output, expectedOutput)
} }
} }
@ -475,11 +451,10 @@ func TestLocal_planTainted_createBeforeDestroy(t *testing.T) {
defer cleanup() defer cleanup()
p := TestLocalProvider(t, b, "test", planFixtureSchema()) p := TestLocalProvider(t, b, "test", planFixtureSchema())
testStateFile(t, b.StatePath, testPlanState_tainted()) testStateFile(t, b.StatePath, testPlanState_tainted())
b.CLI = cli.NewMockUi()
outDir := testTempDir(t) outDir := testTempDir(t)
defer os.RemoveAll(outDir) defer os.RemoveAll(outDir)
planPath := filepath.Join(outDir, "plan.tfplan") planPath := filepath.Join(outDir, "plan.tfplan")
op, configCleanup := testOperationPlan(t, "./testdata/plan-cbd") op, configCleanup, done := testOperationPlan(t, "./testdata/plan-cbd")
defer configCleanup() defer configCleanup()
op.PlanRefresh = true op.PlanRefresh = true
op.PlanOutPath = planPath op.PlanOutPath = planPath
@ -524,8 +499,7 @@ Terraform will perform the following actions:
} }
Plan: 1 to add, 0 to change, 1 to destroy.` Plan: 1 to add, 0 to change, 1 to destroy.`
output := b.CLI.(*cli.MockUi).OutputWriter.String() if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) {
if !strings.Contains(output, expectedOutput) {
t.Fatalf("Unexpected output:\n%s", output) t.Fatalf("Unexpected output:\n%s", output)
} }
} }
@ -537,7 +511,7 @@ func TestLocal_planRefreshFalse(t *testing.T) {
p := TestLocalProvider(t, b, "test", planFixtureSchema()) p := TestLocalProvider(t, b, "test", planFixtureSchema())
testStateFile(t, b.StatePath, testPlanState()) testStateFile(t, b.StatePath, testPlanState())
op, configCleanup := testOperationPlan(t, "./testdata/plan") op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup() defer configCleanup()
run, err := b.Operation(context.Background(), op) run, err := b.Operation(context.Background(), op)
@ -556,6 +530,10 @@ func TestLocal_planRefreshFalse(t *testing.T) {
if !run.PlanEmpty { if !run.PlanEmpty {
t.Fatal("plan should be empty") 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) { func TestLocal_planDestroy(t *testing.T) {
@ -569,7 +547,7 @@ func TestLocal_planDestroy(t *testing.T) {
defer os.RemoveAll(outDir) defer os.RemoveAll(outDir)
planPath := filepath.Join(outDir, "plan.tfplan") planPath := filepath.Join(outDir, "plan.tfplan")
op, configCleanup := testOperationPlan(t, "./testdata/plan") op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup() defer configCleanup()
op.Destroy = true op.Destroy = true
op.PlanRefresh = true op.PlanRefresh = true
@ -606,6 +584,10 @@ func TestLocal_planDestroy(t *testing.T) {
t.Fatalf("bad: %#v", r.Action.String()) 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) { func TestLocal_planDestroy_withDataSources(t *testing.T) {
@ -615,13 +597,11 @@ func TestLocal_planDestroy_withDataSources(t *testing.T) {
TestLocalProvider(t, b, "test", planFixtureSchema()) TestLocalProvider(t, b, "test", planFixtureSchema())
testStateFile(t, b.StatePath, testPlanState_withDataSource()) testStateFile(t, b.StatePath, testPlanState_withDataSource())
b.CLI = cli.NewMockUi()
outDir := testTempDir(t) outDir := testTempDir(t)
defer os.RemoveAll(outDir) defer os.RemoveAll(outDir)
planPath := filepath.Join(outDir, "plan.tfplan") 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() defer configCleanup()
op.Destroy = true op.Destroy = true
op.PlanRefresh = 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.` Plan: 0 to add, 0 to change, 1 to destroy.`
output := b.CLI.(*cli.MockUi).OutputWriter.String() if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) {
if !strings.Contains(output, expectedOutput) {
t.Fatalf("Unexpected output:\n%s", output) t.Fatalf("Unexpected output:\n%s", output)
} }
} }
@ -698,7 +677,7 @@ func TestLocal_planOutPathNoChange(t *testing.T) {
defer os.RemoveAll(outDir) defer os.RemoveAll(outDir)
planPath := filepath.Join(outDir, "plan.tfplan") planPath := filepath.Join(outDir, "plan.tfplan")
op, configCleanup := testOperationPlan(t, "./testdata/plan") op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup() defer configCleanup()
op.PlanOutPath = planPath op.PlanOutPath = planPath
cfg := cty.ObjectVal(map[string]cty.Value{ cfg := cty.ObjectVal(map[string]cty.Value{
@ -729,20 +708,28 @@ func TestLocal_planOutPathNoChange(t *testing.T) {
if !plan.Changes.Empty() { if !plan.Changes.Empty() {
t.Fatalf("expected empty plan to be written") 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() t.Helper()
_, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir)
streams, done := terminal.StreamsForTesting(t)
view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams))
return &backend.Operation{ return &backend.Operation{
Type: backend.OperationTypePlan, Type: backend.OperationTypePlan,
ConfigDir: configDir, ConfigDir: configDir,
ConfigLoader: configLoader, ConfigLoader: configLoader,
ShowDiagnostics: testLogDiagnostics(t), ShowDiagnostics: testLogDiagnostics(t),
StateLocker: clistate.NewNoopLocker(), StateLocker: clistate.NewNoopLocker(),
}, configCleanup View: view,
}, configCleanup, done
} }
// testPlanState is just a common state that we use for testing plan. // testPlanState is just a common state that we use for testing plan.

View File

@ -82,7 +82,7 @@ func (b *Local) opRefresh(
log.Printf("[INFO] backend/local: refresh calling Refresh") 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 return
} }

View File

@ -8,9 +8,12 @@ import (
"github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/clistate" "github.com/hashicorp/terraform/command/clistate"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/initwd"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/providers"
"github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
@ -30,8 +33,9 @@ func TestLocal_refresh(t *testing.T) {
"id": cty.StringVal("yes"), "id": cty.StringVal("yes"),
})} })}
op, configCleanup := testOperationRefresh(t, "./testdata/refresh") op, configCleanup, done := testOperationRefresh(t, "./testdata/refresh")
defer configCleanup() defer configCleanup()
defer done(t)
run, err := b.Operation(context.Background(), op) run, err := b.Operation(context.Background(), op)
if err != nil { if err != nil {
@ -94,8 +98,9 @@ func TestLocal_refreshInput(t *testing.T) {
b.OpInput = true b.OpInput = true
b.ContextOpts.UIInput = &terraform.MockUIInput{InputReturnString: "bar"} 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 configCleanup()
defer done(t)
op.UIIn = b.ContextOpts.UIInput op.UIIn = b.ContextOpts.UIInput
run, err := b.Operation(context.Background(), op) run, err := b.Operation(context.Background(), op)
@ -128,8 +133,9 @@ func TestLocal_refreshValidate(t *testing.T) {
// Enable validation // Enable validation
b.OpValidation = true b.OpValidation = true
op, configCleanup := testOperationRefresh(t, "./testdata/refresh") op, configCleanup, done := testOperationRefresh(t, "./testdata/refresh")
defer configCleanup() defer configCleanup()
defer done(t)
run, err := b.Operation(context.Background(), op) run, err := b.Operation(context.Background(), op)
if err != nil { if err != nil {
@ -174,8 +180,9 @@ func TestLocal_refreshValidateProviderConfigured(t *testing.T) {
// Enable validation // Enable validation
b.OpValidation = true b.OpValidation = true
op, configCleanup := testOperationRefresh(t, "./testdata/refresh-provider-config") op, configCleanup, done := testOperationRefresh(t, "./testdata/refresh-provider-config")
defer configCleanup() defer configCleanup()
defer done(t)
run, err := b.Operation(context.Background(), op) run, err := b.Operation(context.Background(), op)
if err != nil { if err != nil {
@ -200,8 +207,9 @@ func TestLocal_refresh_context_error(t *testing.T) {
b, cleanup := TestLocal(t) b, cleanup := TestLocal(t)
defer cleanup() defer cleanup()
testStateFile(t, b.StatePath, testRefreshState()) testStateFile(t, b.StatePath, testRefreshState())
op, configCleanup := testOperationRefresh(t, "./testdata/apply") op, configCleanup, done := testOperationRefresh(t, "./testdata/apply")
defer configCleanup() defer configCleanup()
defer done(t)
// we coerce a failure in Context() by omitting the provider schema // 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"), "id": cty.StringVal("yes"),
})} })}
op, configCleanup := testOperationRefresh(t, "./testdata/refresh") op, configCleanup, done := testOperationRefresh(t, "./testdata/refresh")
defer configCleanup() defer configCleanup()
defer done(t)
record, playback := testRecordDiagnostics(t) record, playback := testRecordDiagnostics(t)
op.ShowDiagnostics = record op.ShowDiagnostics = record
@ -252,18 +261,22 @@ func TestLocal_refreshEmptyState(t *testing.T) {
assertBackendStateUnlocked(t, b) 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() t.Helper()
_, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir)
streams, done := terminal.StreamsForTesting(t)
view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams))
return &backend.Operation{ return &backend.Operation{
Type: backend.OperationTypeRefresh, Type: backend.OperationTypeRefresh,
ConfigDir: configDir, ConfigDir: configDir,
ConfigLoader: configLoader, ConfigLoader: configLoader,
ShowDiagnostics: testLogDiagnostics(t), ShowDiagnostics: testLogDiagnostics(t),
StateLocker: clistate.NewNoopLocker(), StateLocker: clistate.NewNoopLocker(),
}, configCleanup View: view,
}, configCleanup, done
} }
// testRefreshState is just a common state that we use for testing refresh. // testRefreshState is just a common state that we use for testing refresh.

View File

@ -4,18 +4,13 @@ import (
"log" "log"
"github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/command/format"
) )
// backend.CLI impl. // backend.CLI impl.
func (b *Local) CLIInit(opts *backend.CLIOpts) error { 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.ContextOpts = opts.ContextOpts
b.OpInput = opts.Input b.OpInput = opts.Input
b.OpValidation = opts.Validation b.OpValidation = opts.Validation
b.RunningInAutomation = opts.RunningInAutomation
// configure any new cli options // configure any new cli options
if opts.StatePath != "" { if opts.StatePath != "" {
@ -35,45 +30,3 @@ func (b *Local) CLIInit(opts *backend.CLIOpts) error {
return nil 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()))
}

View File

@ -778,6 +778,10 @@ func TestRemote_applyForceLocal(t *testing.T) {
op.UIOut = b.CLI op.UIOut = b.CLI
op.Workspace = backend.DefaultStateName 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) run, err := b.Operation(context.Background(), op)
if err != nil { if err != nil {
t.Fatalf("error starting operation: %v", err) 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") { if strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("unexpected remote backend header in output: %s", output) 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 summery in output: %s", output) t.Fatalf("expected plan summary in output: %s", output)
} }
if !run.State.HasResources() { if !run.State.HasResources() {
t.Fatalf("expected resources in state") t.Fatalf("expected resources in state")
@ -836,6 +840,10 @@ func TestRemote_applyWorkspaceWithoutOperations(t *testing.T) {
op.UIOut = b.CLI op.UIOut = b.CLI
op.Workspace = "no-operations" 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) run, err := b.Operation(ctx, op)
if err != nil { if err != nil {
t.Fatalf("error starting operation: %v", err) 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") { if strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("unexpected remote backend header in output: %s", output) 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 summery in output: %s", output) t.Fatalf("expected plan summary in output: %s", output)
} }
if !run.State.HasResources() { if !run.State.HasResources() {
t.Fatalf("expected resources in state") t.Fatalf("expected resources in state")
@ -1388,6 +1396,11 @@ func TestRemote_applyVersionCheck(t *testing.T) {
op, configCleanup, playback := testOperationApplyWithDiagnostics(t, "./testdata/apply") op, configCleanup, playback := testOperationApplyWithDiagnostics(t, "./testdata/apply")
defer configCleanup() 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{ input := testInput(t, map[string]string{
"approve": "yes", "approve": "yes",
}) })

View File

@ -514,6 +514,10 @@ func TestRemote_planForceLocal(t *testing.T) {
op.Workspace = backend.DefaultStateName 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) run, err := b.Operation(context.Background(), op)
if err != nil { if err != nil {
t.Fatalf("error starting operation: %v", err) 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") { if strings.Contains(output, "Running plan in the remote backend") {
t.Fatalf("unexpected remote backend header in output: %s", output) 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) t.Fatalf("expected plan summary in output: %s", output)
} }
} }
@ -545,6 +549,10 @@ func TestRemote_planWithoutOperationsEntitlement(t *testing.T) {
op.Workspace = backend.DefaultStateName 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) run, err := b.Operation(context.Background(), op)
if err != nil { if err != nil {
t.Fatalf("error starting operation: %v", err) 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") { if strings.Contains(output, "Running plan in the remote backend") {
t.Fatalf("unexpected remote backend header in output: %s", output) 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) t.Fatalf("expected plan summary in output: %s", output)
} }
} }
@ -590,6 +598,10 @@ func TestRemote_planWorkspaceWithoutOperations(t *testing.T) {
op.Workspace = "no-operations" 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) run, err := b.Operation(ctx, op)
if err != nil { if err != nil {
t.Fatalf("error starting operation: %v", err) 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") { if strings.Contains(output, "Running plan in the remote backend") {
t.Fatalf("unexpected remote backend header in output: %s", output) 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) t.Fatalf("expected plan summary in output: %s", output)
} }
} }

View File

@ -155,8 +155,6 @@ func testBackend(t *testing.T, obj cty.Value) (*Remote, func()) {
func testLocalBackend(t *testing.T, remote *Remote) backend.Enhanced { func testLocalBackend(t *testing.T, remote *Remote) backend.Enhanced {
b := backendLocal.NewWithBackend(remote) b := backendLocal.NewWithBackend(remote)
b.CLI = remote.CLI
// Add a test provider to the local backend. // Add a test provider to the local backend.
p := backendLocal.TestLocalProvider(t, b, "null", &terraform.ProviderSchema{ p := backendLocal.TestLocalProvider(t, b, "null", &terraform.ProviderSchema{
ResourceTypes: map[string]*configschema.Block{ ResourceTypes: map[string]*configschema.Block{

View File

@ -176,6 +176,7 @@ func (c *ApplyCommand) Run(args []string) int {
opReq.PlanRefresh = refresh opReq.PlanRefresh = refresh
opReq.ShowDiagnostics = c.showDiagnostics opReq.ShowDiagnostics = c.showDiagnostics
opReq.Type = backend.OperationTypeApply opReq.Type = backend.OperationTypeApply
opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View)
opReq.ConfigLoader, err = c.initConfigLoader() opReq.ConfigLoader, err = c.initConfigLoader()
if err != nil { if err != nil {

View File

@ -58,7 +58,8 @@ func TestApply_destroy(t *testing.T) {
} }
ui := new(cli.MockUi) ui := new(cli.MockUi)
view, _ := testView(t) view, done := testView(t)
defer done(t)
c := &ApplyCommand{ c := &ApplyCommand{
Destroy: true, Destroy: true,
Meta: Meta{ Meta: Meta{
@ -159,7 +160,7 @@ func TestApply_destroyApproveNo(t *testing.T) {
p := applyFixtureProvider() p := applyFixtureProvider()
ui := new(cli.MockUi) ui := new(cli.MockUi)
view, _ := testView(t) view, done := testView(t)
c := &ApplyCommand{ c := &ApplyCommand{
Destroy: true, Destroy: true,
Meta: Meta{ Meta: Meta{
@ -175,7 +176,7 @@ func TestApply_destroyApproveNo(t *testing.T) {
if code := c.Run(args); code != 1 { if code := c.Run(args); code != 1 {
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) 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) t.Fatalf("expected output to include %q, but was:\n%s", want, got)
} }

View File

@ -116,7 +116,7 @@ func TestApply_approveNo(t *testing.T) {
p := applyFixtureProvider() p := applyFixtureProvider()
ui := new(cli.MockUi) ui := new(cli.MockUi)
view, _ := testView(t) view, done := testView(t)
c := &ApplyCommand{ c := &ApplyCommand{
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(p), testingOverrides: metaOverridesForProvider(p),
@ -131,7 +131,7 @@ func TestApply_approveNo(t *testing.T) {
if code := c.Run(args); code != 1 { if code := c.Run(args); code != 1 {
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) 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) t.Fatalf("expected output to include %q, but was:\n%s", want, got)
} }

View File

@ -12,6 +12,8 @@ import (
"github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/backend" "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/configs"
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags" "github.com/hashicorp/terraform/tfdiags"
@ -199,6 +201,7 @@ func (c *ImportCommand) Run(args []string) int {
return 1 return 1
} }
} }
opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View)
// Check remote Terraform version is compatible // Check remote Terraform version is compatible
remoteVersionDiags := c.remoteBackendVersionCheck(b, opReq.Workspace) remoteVersionDiags := c.remoteBackendVersionCheck(b, opReq.Workspace)

View File

@ -5,6 +5,8 @@ import (
"strings" "strings"
"github.com/hashicorp/terraform/backend" "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/configs"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
@ -88,6 +90,7 @@ func (c *PlanCommand) Run(args []string) int {
opReq.PlanRefresh = refresh opReq.PlanRefresh = refresh
opReq.ShowDiagnostics = c.showDiagnostics opReq.ShowDiagnostics = c.showDiagnostics
opReq.Type = backend.OperationTypePlan opReq.Type = backend.OperationTypePlan
opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View)
opReq.ConfigLoader, err = c.initConfigLoader() opReq.ConfigLoader, err = c.initConfigLoader()
if err != nil { if err != nil {

View File

@ -971,7 +971,7 @@ func TestPlan_targeted(t *testing.T) {
} }
ui := new(cli.MockUi) ui := new(cli.MockUi)
view, _ := testView(t) view, done := testView(t)
c := &PlanCommand{ c := &PlanCommand{
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(p), testingOverrides: metaOverridesForProvider(p),
@ -988,7 +988,7 @@ func TestPlan_targeted(t *testing.T) {
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) 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) t.Fatalf("bad change summary, want %q, got:\n%s", want, got)
} }
} }

View File

@ -79,6 +79,7 @@ func (c *RefreshCommand) Run(args []string) int {
opReq.Hooks = []terraform.Hook{c.uiHook()} opReq.Hooks = []terraform.Hook{c.uiHook()}
opReq.ShowDiagnostics = c.showDiagnostics opReq.ShowDiagnostics = c.showDiagnostics
opReq.Type = backend.OperationTypeRefresh opReq.Type = backend.OperationTypeRefresh
opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View)
opReq.ConfigLoader, err = c.initConfigLoader() opReq.ConfigLoader, err = c.initConfigLoader()
if err != nil { if err != nil {

View File

@ -6,10 +6,11 @@ import (
"strings" "strings"
"github.com/hashicorp/terraform/backend" "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/format"
"github.com/hashicorp/terraform/command/jsonplan" "github.com/hashicorp/terraform/command/jsonplan"
"github.com/hashicorp/terraform/command/jsonstate" "github.com/hashicorp/terraform/command/jsonstate"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
"github.com/hashicorp/terraform/plans/planfile" "github.com/hashicorp/terraform/plans/planfile"
"github.com/hashicorp/terraform/states/statefile" "github.com/hashicorp/terraform/states/statefile"
@ -158,15 +159,8 @@ func (c *ShowCommand) Run(args []string) int {
return 0 return 0
} }
// FIXME: We currently call into the local backend for this, since view := views.NewShow(arguments.ViewHuman, c.View)
// the "terraform plan" logic lives there and our package call graph view.Plan(plan, stateFile.State, schemas)
// 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())
return 0 return 0
} }

View File

@ -147,7 +147,7 @@ func TestShow_plan(t *testing.T) {
planPath := testPlanFileNoop(t) planPath := testPlanFileNoop(t)
ui := cli.NewMockUi() ui := cli.NewMockUi()
view, _ := testView(t) view, done := testView(t)
c := &ShowCommand{ c := &ShowCommand{
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()), testingOverrides: metaOverridesForProvider(testProvider()),
@ -164,7 +164,7 @@ func TestShow_plan(t *testing.T) {
} }
want := `Terraform will perform the following actions` want := `Terraform will perform the following actions`
got := ui.OutputWriter.String() got := done(t).Stdout()
if !strings.Contains(got, want) { if !strings.Contains(got, want) {
t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got) 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) planPathWithChanges := showFixturePlanFile(t, plans.DeleteThenCreate)
ui := cli.NewMockUi() ui := cli.NewMockUi()
view, _ := testView(t) view, done := testView(t)
c := &ShowCommand{ c := &ShowCommand{
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(showFixtureProvider()), testingOverrides: metaOverridesForProvider(showFixtureProvider()),
@ -192,7 +192,7 @@ func TestShow_planWithChanges(t *testing.T) {
} }
want := `test_instance.foo must be replaced` want := `test_instance.foo must be replaced`
got := ui.OutputWriter.String() got := done(t).Stdout()
if !strings.Contains(got, want) { if !strings.Contains(got, want) {
t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got) t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got)
} }

122
command/views/operation.go Normal file
View File

@ -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
`

View File

@ -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)
}
}

145
command/views/plan.go Normal file
View File

@ -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:
`

View File

@ -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},
},
},
},
},
}
}

39
command/views/show.go Normal file
View File

@ -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)
}

View File

@ -112,3 +112,30 @@ const helpPrompt = `
For more help on using this command, run: For more help on using this command, run:
terraform %s -help 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()))
}