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:
parent
c0b22007fc
commit
68558ccd54
|
@ -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
|
||||
|
|
|
@ -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.`
|
||||
|
|
|
@ -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.
|
||||
`
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
`
|
||||
|
|
|
@ -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,46 +79,17 @@ 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) {
|
||||
b, cleanup := TestLocal(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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
`
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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:
|
||||
`
|
|
@ -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},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()))
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue