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