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.
This commit is contained in:
Alisdair McDiarmid 2021-02-17 13:01:30 -05:00
parent c0b22007fc
commit 68558ccd54
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/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

View File

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

View File

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

View File

@ -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, `<no state>`)
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

View File

@ -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 {
b.CLI.Output("\n" + b.Colorize().Color(strings.TrimSpace(planNoChanges)))
b.CLI.Output("\n" + strings.TrimSpace(format.WordWrap(planNoChangesDetail, outputColumns)))
op.View.PlanNoChanges()
// Even if there are no changes, there still could be some warnings
op.ShowDiagnostics(diags)
return
}
b.renderPlan(plan, plan.State, schemas)
// 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)
// 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,
))
}
}
}
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/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,16 +59,14 @@ 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")
// 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
@ -75,36 +79,9 @@ func TestLocal_planInAutomation(t *testing.T) {
t.Fatalf("plan operation failed")
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, msg) {
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)
}
}
// 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")
}
}
}
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.

View File

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

View File

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

View File

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

View File

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

View File

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

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 {
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{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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:
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()))
}