Merge pull request #27841 from hashicorp/alisdair/command-views-apply

cli: Migrate apply to command views
This commit is contained in:
Alisdair McDiarmid 2021-02-22 11:53:57 -05:00 committed by GitHub
commit 56b756cfd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1374 additions and 437 deletions

View File

@ -8,7 +8,6 @@ import (
"github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/plans/planfile" "github.com/hashicorp/terraform/plans/planfile"
"github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags" "github.com/hashicorp/terraform/tfdiags"
) )
@ -22,105 +21,177 @@ type ApplyCommand struct {
Destroy bool Destroy bool
} }
func (c *ApplyCommand) Run(args []string) int { func (c *ApplyCommand) Run(rawArgs []string) int {
var refresh, autoApprove bool // Parse and apply global view arguments
args = c.Meta.process(args) common, rawArgs := arguments.ParseView(rawArgs)
cmdName := "apply" c.View.Configure(common)
if c.Destroy {
cmdName = "destroy"
}
cmdFlags := c.Meta.extendedFlagSet(cmdName) // Parse and validate flags
cmdFlags.BoolVar(&autoApprove, "auto-approve", false, "skip interactive approval of plan before applying") args, diags := arguments.ParseApply(rawArgs)
cmdFlags.BoolVar(&refresh, "refresh", true, "refresh")
cmdFlags.IntVar(&c.Meta.parallelism, "parallelism", DefaultParallelism, "parallelism") // Instantiate the view, even if there are flag errors, so that we render
cmdFlags.StringVar(&c.Meta.statePath, "state", "", "path") // diagnostics according to the desired view
cmdFlags.StringVar(&c.Meta.stateOutPath, "state-out", "", "path") var view views.Apply
cmdFlags.StringVar(&c.Meta.backupPath, "backup", "", "path") view = views.NewApply(args.ViewType, c.Destroy, c.RunningInAutomation, c.View)
cmdFlags.BoolVar(&c.Meta.stateLock, "lock", true, "lock state")
cmdFlags.DurationVar(&c.Meta.stateLockTimeout, "lock-timeout", 0, "lock timeout")
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
if err := cmdFlags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error()))
return 1
}
diags := c.parseTargetFlags()
if diags.HasErrors() { if diags.HasErrors() {
c.showDiagnostics(diags) view.Diagnostics(diags)
return 1 view.HelpPrompt()
}
args = cmdFlags.Args()
var planPath string
if len(args) > 0 {
planPath = args[0]
args = args[1:]
}
configPath, err := ModulePath(args)
if err != nil {
c.Ui.Error(err.Error())
return 1 return 1
} }
// Check for user-supplied plugin path // Check for user-supplied plugin path
var err error
if c.pluginPath, err = c.loadPluginPath(); err != nil { if c.pluginPath, err = c.loadPluginPath(); err != nil {
c.Ui.Error(fmt.Sprintf("Error loading plugin path: %s", err)) diags = diags.Append(err)
view.Diagnostics(diags)
return 1 return 1
} }
// Try to load plan if path is specified // Attempt to load the plan file, if specified
var planFile *planfile.Reader planFile, diags := c.LoadPlanFile(args.PlanPath)
if planPath != "" { if diags.HasErrors() {
planFile, err = c.PlanFile(planPath) view.Diagnostics(diags)
if err != nil {
c.Ui.Error(err.Error())
return 1 return 1
} }
// Check for invalid combination of plan file and variable overrides
if planFile != nil && !args.Vars.Empty() {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Can't set variables when applying a saved plan",
"The -var and -var-file options cannot be used when applying a saved plan file, because a saved plan includes the variable values that were set when it was created.",
))
view.Diagnostics(diags)
return 1
}
// FIXME: the -input flag value is needed to initialize the backend and the
// operation, but there is no clear path to pass this value down, so we
// continue to mutate the Meta object state for now.
c.Meta.input = args.InputEnabled
// FIXME: the -parallelism flag is used to control the concurrency of
// Terraform operations. At the moment, this value is used both to
// initialize the backend via the ContextOpts field inside CLIOpts, and to
// set a largely unused field on the Operation request. Again, there is no
// clear path to pass this value down, so we continue to mutate the Meta
// object state for now.
c.Meta.parallelism = args.Operation.Parallelism
// Prepare the backend, passing the plan file if present, and the
// backend-specific arguments
be, beDiags := c.PrepareBackend(planFile, args.State)
diags = diags.Append(beDiags)
if diags.HasErrors() {
view.Diagnostics(diags)
return 1
}
// Build the operation request
opReq, opDiags := c.OperationRequest(be, view, planFile, args.Operation)
diags = diags.Append(opDiags)
// Collect variable value and add them to the operation request
diags = diags.Append(c.GatherVariables(opReq, args.Vars))
// Before we delegate to the backend, we'll print any warning diagnostics
// we've accumulated here, since the backend will start fresh with its own
// diagnostics.
view.Diagnostics(diags)
if diags.HasErrors() {
return 1
}
diags = nil
// Run the operation
op, err := c.RunOperation(be, opReq)
if err != nil {
diags = diags.Append(err)
view.Diagnostics(diags)
return 1
}
if op.Result != backend.OperationSuccess {
return op.Result.ExitStatus()
}
// // Render the resource count and outputs
view.ResourceCount(args.State.StateOutPath)
if !c.Destroy && op.State != nil {
view.Outputs(op.State.RootModule().OutputValues)
}
view.Diagnostics(diags)
if diags.HasErrors() {
return 1
}
return 0
}
func (c *ApplyCommand) LoadPlanFile(path string) (*planfile.Reader, tfdiags.Diagnostics) {
var planFile *planfile.Reader
var diags tfdiags.Diagnostics
// Try to load plan if path is specified
if path != "" {
var err error
planFile, err = c.PlanFile(path)
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
fmt.Sprintf("Failed to load %q as a plan file", path),
fmt.Sprintf("Error: %s", err),
))
return nil, diags
}
// If the path doesn't look like a plan, both planFile and err will be // If the path doesn't look like a plan, both planFile and err will be
// nil. In that case, the user is probably trying to use the positional // nil. In that case, the user is probably trying to use the positional
// argument to specify a configuration path. Point them at -chdir. // argument to specify a configuration path. Point them at -chdir.
if planFile == nil { if planFile == nil {
c.Ui.Error(fmt.Sprintf("Failed to load %q as a plan file. Did you mean to use -chdir?", planPath)) diags = diags.Append(tfdiags.Sourceless(
return 1 tfdiags.Error,
fmt.Sprintf("Failed to load %q as a plan file", path),
"The specified path is a directory, not a plan file. You can use the global -chdir flag to use this directory as the configuration root.",
))
return nil, diags
} }
// If we successfully loaded a plan but this is a destroy operation, // If we successfully loaded a plan but this is a destroy operation,
// explain that this is not supported. // explain that this is not supported.
if c.Destroy { if c.Destroy {
c.Ui.Error("Destroy can't be called with a plan file.")
return 1
}
}
if planFile != nil {
// Reset the config path for backend loading
configPath = ""
if !c.variableArgs.Empty() {
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error, tfdiags.Error,
"Can't set variables when applying a saved plan", "Destroy can't be called with a plan file",
"The -var and -var-file options cannot be used when applying a saved plan file, because a saved plan includes the variable values that were set when it was created.", fmt.Sprintf("If this plan was created using plan -destroy, apply it using:\n terraform apply %q", path),
)) ))
c.showDiagnostics(diags) return nil, diags
return 1
} }
} }
// Set up our count hook that keeps track of resource changes return planFile, diags
countHook := new(CountHook) }
func (c *ApplyCommand) PrepareBackend(planFile *planfile.Reader, args *arguments.State) (backend.Enhanced, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// FIXME: we need to apply the state arguments to the meta object here
// because they are later used when initializing the backend. Carving a
// path to pass these arguments to the functions that need them is
// difficult but would make their use easier to understand.
c.Meta.applyStateArguments(args)
// Load the backend // Load the backend
var be backend.Enhanced var be backend.Enhanced
var beDiags tfdiags.Diagnostics var beDiags tfdiags.Diagnostics
if planFile == nil { if planFile == nil {
backendConfig, configDiags := c.loadBackendConfig(configPath) backendConfig, configDiags := c.loadBackendConfig(".")
diags = diags.Append(configDiags) diags = diags.Append(configDiags)
if configDiags.HasErrors() { if configDiags.HasErrors() {
c.showDiagnostics(diags) return nil, diags
return 1
} }
be, beDiags = c.Backend(&BackendOpts{ be, beDiags = c.Backend(&BackendOpts{
@ -134,8 +205,7 @@ func (c *ApplyCommand) Run(args []string) int {
"Failed to read plan from plan file", "Failed to read plan from plan file",
fmt.Sprintf("Cannot read the plan from the given plan file: %s.", err), fmt.Sprintf("Cannot read the plan from the given plan file: %s.", err),
)) ))
c.showDiagnostics(diags) return nil, diags
return 1
} }
if plan.Backend.Config == nil { if plan.Backend.Config == nil {
// Should never happen; always indicates a bug in the creation of the plan file // Should never happen; always indicates a bug in the creation of the plan file
@ -144,103 +214,80 @@ func (c *ApplyCommand) Run(args []string) int {
"Failed to read plan from plan file", "Failed to read plan from plan file",
"The given plan file does not have a valid backend configuration. This is a bug in the Terraform command that generated this plan file.", "The given plan file does not have a valid backend configuration. This is a bug in the Terraform command that generated this plan file.",
)) ))
c.showDiagnostics(diags) return nil, diags
return 1
} }
be, beDiags = c.BackendForPlan(plan.Backend) be, beDiags = c.BackendForPlan(plan.Backend)
} }
diags = diags.Append(beDiags) diags = diags.Append(beDiags)
if beDiags.HasErrors() { if beDiags.HasErrors() {
c.showDiagnostics(diags) return nil, diags
return 1
} }
return be, diags
}
func (c *ApplyCommand) OperationRequest(be backend.Enhanced, view views.Apply, planFile *planfile.Reader, args *arguments.Operation,
) (*backend.Operation, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// Applying changes with dev overrides in effect could make it impossible // Applying changes with dev overrides in effect could make it impossible
// to switch back to a release version if the schema isn't compatible, // to switch back to a release version if the schema isn't compatible,
// so we'll warn about it. // so we'll warn about it.
diags = diags.Append(c.providerDevOverrideRuntimeWarnings()) diags = diags.Append(c.providerDevOverrideRuntimeWarnings())
// Before we delegate to the backend, we'll print any warning diagnostics
// we've accumulated here, since the backend will start fresh with its own
// diagnostics.
c.showDiagnostics(diags)
diags = nil
// Build the operation // Build the operation
opReq := c.Operation(be) opReq := c.Operation(be)
opReq.AutoApprove = autoApprove opReq.AutoApprove = args.AutoApprove
opReq.ConfigDir = configPath opReq.ConfigDir = "."
opReq.Destroy = c.Destroy opReq.Destroy = c.Destroy
opReq.Hooks = []terraform.Hook{countHook, c.uiHook()} opReq.Hooks = view.Hooks()
opReq.PlanFile = planFile opReq.PlanFile = planFile
opReq.PlanRefresh = refresh opReq.PlanRefresh = args.Refresh
opReq.ShowDiagnostics = c.showDiagnostics opReq.Targets = args.Targets
opReq.Type = backend.OperationTypeApply opReq.Type = backend.OperationTypeApply
opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) opReq.View = view.Operation()
// FIXME: To allow errors to be easily rendered, the showDiagnostics method
// accepts ...interface{}. The backend no longer needs this, as only
// tfdiags.Diagnostics values are used. Once we have migrated plan and refresh
// to use views, we can remove ShowDiagnostics and update the backend code to
// call view.Diagnostics() instead.
opReq.ShowDiagnostics = func(vals ...interface{}) {
var diags tfdiags.Diagnostics
diags = diags.Append(vals...)
view.Diagnostics(diags)
}
var err error
opReq.ConfigLoader, err = c.initConfigLoader() opReq.ConfigLoader, err = c.initConfigLoader()
if err != nil { if err != nil {
c.showDiagnostics(err) diags = diags.Append(fmt.Errorf("Failed to initialize config loader: %s", err))
return 1 return nil, diags
} }
{ return opReq, diags
var moreDiags tfdiags.Diagnostics
opReq.Variables, moreDiags = c.collectVariableValues()
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
c.showDiagnostics(diags)
return 1
}
} }
op, err := c.RunOperation(be, opReq) func (c *ApplyCommand) GatherVariables(opReq *backend.Operation, args *arguments.Vars) tfdiags.Diagnostics {
if err != nil { var diags tfdiags.Diagnostics
c.showDiagnostics(err)
return 1
}
if op.Result != backend.OperationSuccess { // FIXME the arguments package currently trivially gathers variable related
return op.Result.ExitStatus() // arguments in a heterogenous slice, in order to minimize the number of
} // code paths gathering variables during the transition to this structure.
// Once all commands that gather variables have been converted to this
// structure, we could move the variable gathering code to the arguments
// package directly, removing this shim layer.
// Show the count results from the operation varArgs := args.All()
if c.Destroy { items := make([]rawFlag, len(varArgs))
c.Ui.Output(c.Colorize().Color(fmt.Sprintf( for i := range varArgs {
"[reset][bold][green]\n"+ items[i].Name = varArgs[i].Name
"Destroy complete! Resources: %d destroyed.", items[i].Value = varArgs[i].Value
countHook.Removed)))
} else {
c.Ui.Output(c.Colorize().Color(fmt.Sprintf(
"[reset][bold][green]\n"+
"Apply complete! Resources: %d added, %d changed, %d destroyed.",
countHook.Added,
countHook.Changed,
countHook.Removed)))
} }
c.Meta.variableArgs = rawFlags{items: &items}
opReq.Variables, diags = c.collectVariableValues()
// only show the state file help message if the state is local. return diags
if (countHook.Added > 0 || countHook.Changed > 0) && c.Meta.stateOutPath != "" {
c.Ui.Output(c.Colorize().Color(fmt.Sprintf(
"[reset]\n"+
"The state of your infrastructure has been saved to the path\n"+
"below. This state is required to modify and destroy your\n"+
"infrastructure, so keep it safe. To inspect the complete state\n"+
"use the `terraform show` command.\n\n"+
"State path: %s",
c.Meta.stateOutPath)))
}
if !c.Destroy && op.State != nil {
outputValues := op.State.RootModule().OutputValues
if len(outputValues) > 0 {
c.Ui.Output(c.Colorize().Color("[reset][bold][green]\nOutputs:\n\n"))
view := views.NewOutput(arguments.ViewHuman, c.View)
view.Output("", outputValues)
}
}
return op.Result.ExitStatus()
} }
func (c *ApplyCommand) Help() string { func (c *ApplyCommand) Help() string {
@ -369,7 +416,3 @@ Options:
` `
return strings.TrimSpace(helpText) return strings.TrimSpace(helpText)
} }
const outputInterrupt = `Interrupt received.
Please wait for Terraform to exit or data loss may occur.
Gracefully shutting down...`

View File

@ -1,7 +1,6 @@
package command package command
import ( import (
"bytes"
"os" "os"
"strings" "strings"
"testing" "testing"
@ -57,14 +56,11 @@ func TestApply_destroy(t *testing.T) {
}, },
} }
ui := new(cli.MockUi)
view, done := testView(t) view, done := testView(t)
defer done(t)
c := &ApplyCommand{ c := &ApplyCommand{
Destroy: true, Destroy: true,
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(p), testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view, View: view,
}, },
} }
@ -74,9 +70,11 @@ func TestApply_destroy(t *testing.T) {
"-auto-approve", "-auto-approve",
"-state", statePath, "-state", statePath,
} }
if code := c.Run(args); code != 0 { code := c.Run(args)
t.Log(ui.OutputWriter.String()) output := done(t)
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) if code != 0 {
t.Log(output.Stdout())
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
} }
// Verify a new state exists // Verify a new state exists
@ -150,15 +148,14 @@ func TestApply_destroyApproveNo(t *testing.T) {
}) })
statePath := testStateFile(t, originalState) statePath := testStateFile(t, originalState)
// Disable test mode so input would be asked
test = false
defer func() { test = true }()
// Answer approval request with "no"
defaultInputReader = bytes.NewBufferString("no\n")
defaultInputWriter = new(bytes.Buffer)
p := applyFixtureProvider() p := applyFixtureProvider()
defer testInputMap(t, map[string]string{
"approve": "no",
})()
// Do not use the NewMockUi initializer here, as we want to delay
// the call to init until after setting up the input mocks
ui := new(cli.MockUi) ui := new(cli.MockUi)
view, done := testView(t) view, done := testView(t)
c := &ApplyCommand{ c := &ApplyCommand{
@ -173,10 +170,12 @@ func TestApply_destroyApproveNo(t *testing.T) {
args := []string{ args := []string{
"-state", statePath, "-state", statePath,
} }
if code := c.Run(args); code != 1 { code := c.Run(args)
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) output := done(t)
if code != 1 {
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
} }
if got, want := done(t).Stdout(), "Destroy cancelled"; !strings.Contains(got, want) { if got, want := output.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)
} }
@ -198,20 +197,36 @@ func TestApply_destroyApproveYes(t *testing.T) {
defer os.RemoveAll(td) defer os.RemoveAll(td)
defer testChdir(t, td)() defer testChdir(t, td)()
statePath := testTempFile(t) // Create some existing state
originalState := states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
AttrsJSON: []byte(`{"id":"bar"}`),
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
)
})
statePath := testStateFile(t, originalState)
p := applyFixtureProvider() p := applyFixtureProvider()
// Disable test mode so input would be asked defer testInputMap(t, map[string]string{
test = false "approve": "yes",
defer func() { test = true }() })()
// Answer approval request with "yes"
defaultInputReader = bytes.NewBufferString("yes\n")
defaultInputWriter = new(bytes.Buffer)
// Do not use the NewMockUi initializer here, as we want to delay
// the call to init until after setting up the input mocks
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{
@ -224,8 +239,11 @@ func TestApply_destroyApproveYes(t *testing.T) {
args := []string{ args := []string{
"-state", statePath, "-state", statePath,
} }
if code := c.Run(args); code != 0 { code := c.Run(args)
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) output := done(t)
if code != 0 {
t.Log(output.Stdout())
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
} }
if _, err := os.Stat(statePath); err != nil { if _, err := os.Stat(statePath); err != nil {
@ -277,13 +295,11 @@ func TestApply_destroyLockedState(t *testing.T) {
defer unlock() defer unlock()
p := testProvider() p := testProvider()
ui := new(cli.MockUi) view, done := testView(t)
view, _ := testView(t)
c := &ApplyCommand{ c := &ApplyCommand{
Destroy: true, Destroy: true,
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(p), testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view, View: view,
}, },
} }
@ -294,13 +310,14 @@ func TestApply_destroyLockedState(t *testing.T) {
"-state", statePath, "-state", statePath,
} }
if code := c.Run(args); code == 0 { code := c.Run(args)
t.Fatal("expected error") output := done(t)
if code == 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
} }
output := ui.ErrorWriter.String() if !strings.Contains(output.Stderr(), "lock") {
if !strings.Contains(output, "lock") { t.Fatal("command output does not look like a lock error:", output.Stderr())
t.Fatal("command output does not look like a lock error:", output)
} }
} }
@ -314,13 +331,11 @@ func TestApply_destroyPlan(t *testing.T) {
planPath := testPlanFileNoop(t) planPath := testPlanFileNoop(t)
p := testProvider() p := testProvider()
ui := new(cli.MockUi) view, done := testView(t)
view, _ := testView(t)
c := &ApplyCommand{ c := &ApplyCommand{
Destroy: true, Destroy: true,
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(p), testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view, View: view,
}, },
} }
@ -329,12 +344,13 @@ func TestApply_destroyPlan(t *testing.T) {
args := []string{ args := []string{
planPath, planPath,
} }
if code := c.Run(args); code != 1 { code := c.Run(args)
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) output := done(t)
if code != 1 {
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
} }
output := ui.ErrorWriter.String() if !strings.Contains(output.Stderr(), "plan file") {
if !strings.Contains(output, "plan file") { t.Fatal("expected command output to refer to plan file, but got:", output.Stderr())
t.Fatal("expected command output to refer to plan file, but got:", output)
} }
} }
@ -347,13 +363,11 @@ func TestApply_destroyPath(t *testing.T) {
p := applyFixtureProvider() p := applyFixtureProvider()
ui := new(cli.MockUi) view, done := testView(t)
view, _ := testView(t)
c := &ApplyCommand{ c := &ApplyCommand{
Destroy: true, Destroy: true,
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(p), testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view, View: view,
}, },
} }
@ -362,12 +376,13 @@ func TestApply_destroyPath(t *testing.T) {
"-auto-approve", "-auto-approve",
testFixturePath("apply"), testFixturePath("apply"),
} }
if code := c.Run(args); code != 1 { code := c.Run(args)
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) output := done(t)
if code != 1 {
t.Fatalf("bad: %d\n\n%s", code, output.Stdout())
} }
output := ui.ErrorWriter.String() if !strings.Contains(output.Stderr(), "-chdir") {
if !strings.Contains(output, "-chdir") { t.Fatal("expected command output to refer to -chdir flag, but got:", output.Stderr())
t.Fatal("expected command output to refer to -chdir flag, but got:", output)
} }
} }
@ -442,13 +457,11 @@ func TestApply_destroyTargetedDependencies(t *testing.T) {
} }
} }
ui := new(cli.MockUi) view, done := testView(t)
view, _ := testView(t)
c := &ApplyCommand{ c := &ApplyCommand{
Destroy: true, Destroy: true,
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(p), testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view, View: view,
}, },
} }
@ -459,8 +472,11 @@ func TestApply_destroyTargetedDependencies(t *testing.T) {
"-target", "test_instance.foo", "-target", "test_instance.foo",
"-state", statePath, "-state", statePath,
} }
if code := c.Run(args); code != 0 { code := c.Run(args)
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) output := done(t)
if code != 0 {
t.Log(output.Stdout())
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
} }
// Verify a new state exists // Verify a new state exists
@ -593,13 +609,11 @@ func TestApply_destroyTargeted(t *testing.T) {
} }
} }
ui := new(cli.MockUi) view, done := testView(t)
view, _ := testView(t)
c := &ApplyCommand{ c := &ApplyCommand{
Destroy: true, Destroy: true,
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(p), testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view, View: view,
}, },
} }
@ -610,8 +624,11 @@ func TestApply_destroyTargeted(t *testing.T) {
"-target", "test_load_balancer.foo", "-target", "test_load_balancer.foo",
"-state", statePath, "-state", statePath,
} }
if code := c.Run(args); code != 0 { code := c.Run(args)
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) output := done(t)
if code != 0 {
t.Log(output.Stdout())
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
} }
// Verify a new state exists // Verify a new state exists

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,69 @@
package arguments
import (
"github.com/hashicorp/terraform/tfdiags"
)
// Apply represents the command-line arguments for the apply command.
type Apply struct {
// State, Operation, and Vars are the common extended flags
State *State
Operation *Operation
Vars *Vars
// InputEnabled is used to disable interactive input for unspecified
// variable and backend config values. Default is true.
InputEnabled bool
// PlanPath contains an optional path to a stored plan file
PlanPath string
// ViewType specifies which output format to use
ViewType ViewType
}
// ParseApply processes CLI arguments, returning an Apply value and errors.
// If errors are encountered, an Apply value is still returned representing
// the best effort interpretation of the arguments.
func ParseApply(args []string) (*Apply, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
apply := &Apply{
State: &State{},
Operation: &Operation{},
Vars: &Vars{},
}
cmdFlags := extendedFlagSet("apply", apply.State, apply.Operation, apply.Vars)
cmdFlags.BoolVar(&apply.InputEnabled, "input", true, "input")
if err := cmdFlags.Parse(args); err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to parse command-line flags",
err.Error(),
))
}
args = cmdFlags.Args()
if len(args) > 0 {
apply.PlanPath = args[0]
args = args[1:]
}
if len(args) > 0 {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Too many command line arguments",
"Expected at most one positional argument.",
))
}
diags = diags.Append(apply.Operation.Parse())
switch {
default:
apply.ViewType = ViewHuman
}
return apply, diags
}

View File

@ -0,0 +1,175 @@
package arguments
import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform/addrs"
)
func TestParseApply_basicValid(t *testing.T) {
testCases := map[string]struct {
args []string
want *Apply
}{
"defaults": {
nil,
&Apply{
InputEnabled: true,
PlanPath: "",
ViewType: ViewHuman,
},
},
"disabled input and plan path": {
[]string{"-input=false", "saved.tfplan"},
&Apply{
InputEnabled: false,
PlanPath: "saved.tfplan",
ViewType: ViewHuman,
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got, diags := ParseApply(tc.args)
if len(diags) > 0 {
t.Fatalf("unexpected diags: %v", diags)
}
// Ignore the extended arguments for simplicity
got.State = nil
got.Operation = nil
got.Vars = nil
if *got != *tc.want {
t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want)
}
})
}
}
func TestParseApply_invalid(t *testing.T) {
got, diags := ParseApply([]string{"-frob"})
if len(diags) == 0 {
t.Fatal("expected diags but got none")
}
if got, want := diags.Err().Error(), "flag provided but not defined"; !strings.Contains(got, want) {
t.Fatalf("wrong diags\n got: %s\nwant: %s", got, want)
}
if got.ViewType != ViewHuman {
t.Fatalf("wrong view type, got %#v, want %#v", got.ViewType, ViewHuman)
}
}
func TestParseApply_tooManyArguments(t *testing.T) {
got, diags := ParseApply([]string{"saved.tfplan", "please"})
if len(diags) == 0 {
t.Fatal("expected diags but got none")
}
if got, want := diags.Err().Error(), "Too many command line arguments"; !strings.Contains(got, want) {
t.Fatalf("wrong diags\n got: %s\nwant: %s", got, want)
}
if got.ViewType != ViewHuman {
t.Fatalf("wrong view type, got %#v, want %#v", got.ViewType, ViewHuman)
}
}
func TestParseApply_targets(t *testing.T) {
foobarbaz, _ := addrs.ParseTargetStr("foo_bar.baz")
boop, _ := addrs.ParseTargetStr("module.boop")
testCases := map[string]struct {
args []string
want []addrs.Targetable
wantErr string
}{
"no targets by default": {
args: nil,
want: nil,
},
"one target": {
args: []string{"-target=foo_bar.baz"},
want: []addrs.Targetable{foobarbaz.Subject},
},
"two targets": {
args: []string{"-target=foo_bar.baz", "-target", "module.boop"},
want: []addrs.Targetable{foobarbaz.Subject, boop.Subject},
},
"invalid traversal": {
args: []string{"-target=foo."},
want: nil,
wantErr: "Dot must be followed by attribute name",
},
"invalid target": {
args: []string{"-target=data[0].foo"},
want: nil,
wantErr: "A data source name is required",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got, diags := ParseApply(tc.args)
if len(diags) > 0 {
if tc.wantErr == "" {
t.Fatalf("unexpected diags: %v", diags)
} else if got := diags.Err().Error(); !strings.Contains(got, tc.wantErr) {
t.Fatalf("wrong diags\n got: %s\nwant: %s", got, tc.wantErr)
}
}
if !cmp.Equal(got.Operation.Targets, tc.want) {
t.Fatalf("unexpected result\n%s", cmp.Diff(got.Operation.Targets, tc.want))
}
})
}
}
func TestParseApply_vars(t *testing.T) {
testCases := map[string]struct {
args []string
want []FlagNameValue
}{
"no var flags by default": {
args: nil,
want: nil,
},
"one var": {
args: []string{"-var", "foo=bar"},
want: []FlagNameValue{
{Name: "-var", Value: "foo=bar"},
},
},
"one var-file": {
args: []string{"-var-file", "cool.tfvars"},
want: []FlagNameValue{
{Name: "-var-file", Value: "cool.tfvars"},
},
},
"ordering preserved": {
args: []string{
"-var", "foo=bar",
"-var-file", "cool.tfvars",
"-var", "boop=beep",
},
want: []FlagNameValue{
{Name: "-var", Value: "foo=bar"},
{Name: "-var-file", Value: "cool.tfvars"},
{Name: "-var", Value: "boop=beep"},
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got, diags := ParseApply(tc.args)
if len(diags) > 0 {
t.Fatalf("unexpected diags: %v", diags)
}
if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) {
t.Fatalf("unexpected result\n%s", cmp.Diff(vars, tc.want))
}
if got, want := got.Vars.Empty(), len(tc.want) == 0; got != want {
t.Fatalf("expected Empty() to return %t, but was %t", want, got)
}
})
}
}

View File

@ -0,0 +1,162 @@
package arguments
import (
"flag"
"fmt"
"time"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/tfdiags"
)
// DefaultParallelism is the limit Terraform places on total parallel
// operations as it walks the dependency graph.
const DefaultParallelism = 10
// State describes arguments which are used to define how Terraform interacts
// with state.
type State struct {
// Lock controls whether or not the state manager is used to lock state
// during operations.
Lock bool
// LockTimeout allows setting a time limit on acquiring the state lock.
// The default is 0, meaning no limit.
LockTimeout time.Duration
// StatePath specifies a non-default location for the state file. The
// default value is blank, which is interpeted as "terraform.tfstate".
StatePath string
// StateOutPath specifies a different path to write the final state file.
// The default value is blank, which results in state being written back to
// StatePath.
StateOutPath string
// BackupPath specifies the path where a backup copy of the state file will
// be stored before the new state is written. The default value is blank,
// which is interpreted as StateOutPath +
// ".backup".
BackupPath string
}
// Operation describes arguments which are used to configure how a Terraform
// operation such as a plan or apply executes.
type Operation struct {
// AutoApprove skips the manual verification step for the apply operation.
AutoApprove bool
// Parallelism is the limit Terraform places on total parallel operations
// as it walks the dependency graph.
Parallelism int
// Refresh controls whether or not the operation should refresh existing
// state before proceeding. Default is true.
Refresh bool
// Targets allow limiting an operation to a set of resource addresses and
// their dependencies.
Targets []addrs.Targetable
targetsRaw []string
}
// Parse must be called on Operation after initial flag parse. This processes
// the raw target flags into addrs.Targetable values, returning diagnostics if
// invalid.
func (o *Operation) Parse() tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
o.Targets = nil
for _, tr := range o.targetsRaw {
traversal, syntaxDiags := hclsyntax.ParseTraversalAbs([]byte(tr), "", hcl.Pos{Line: 1, Column: 1})
if syntaxDiags.HasErrors() {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
fmt.Sprintf("Invalid target %q", tr),
syntaxDiags[0].Detail,
))
continue
}
target, targetDiags := addrs.ParseTarget(traversal)
if targetDiags.HasErrors() {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
fmt.Sprintf("Invalid target %q", tr),
targetDiags[0].Description().Detail,
))
continue
}
o.Targets = append(o.Targets, target.Subject)
}
return diags
}
// Vars describes arguments which specify non-default variable values. This
// interfce is unfortunately obscure, because the order of the CLI arguments
// determines the final value of the gathered variables. In future it might be
// desirable for the arguments package to handle the gathering of variables
// directly, returning a map of variable values.
type Vars struct {
vars *flagNameValueSlice
varFiles *flagNameValueSlice
}
func (v *Vars) All() []FlagNameValue {
if v.vars == nil {
return nil
}
return v.vars.AllItems()
}
func (v *Vars) Empty() bool {
if v.vars == nil {
return true
}
return v.vars.Empty()
}
// extendedFlagSet creates a FlagSet with common backend, operation, and vars
// flags used in many commands. Target structs for each subset of flags must be
// provided in order to support those flags.
func extendedFlagSet(name string, state *State, operation *Operation, vars *Vars) *flag.FlagSet {
f := defaultFlagSet(name)
if state == nil && operation == nil && vars == nil {
panic("use defaultFlagSet")
}
if state != nil {
f.BoolVar(&state.Lock, "lock", true, "lock")
f.DurationVar(&state.LockTimeout, "lock-timeout", 0, "lock-timeout")
f.StringVar(&state.StatePath, "state", "", "state-path")
f.StringVar(&state.StateOutPath, "state-out", "", "state-path")
f.StringVar(&state.BackupPath, "backup", "", "backup-path")
}
if operation != nil {
f.BoolVar(&operation.AutoApprove, "auto-approve", false, "auto-approve")
f.IntVar(&operation.Parallelism, "parallelism", DefaultParallelism, "parallelism")
f.BoolVar(&operation.Refresh, "refresh", true, "refresh")
f.Var((*flagStringSlice)(&operation.targetsRaw), "target", "target")
}
// Gather all -var and -var-file arguments into one heterogenous structure
// to preserve the overall order.
if vars != nil {
varsFlags := newFlagNameValueSlice("-var")
varFilesFlags := varsFlags.Alias("-var-file")
vars.vars = &varsFlags
vars.varFiles = &varFilesFlags
f.Var(vars.vars, "var", "var")
f.Var(vars.varFiles, "var-file", "var-file")
}
return f
}

View File

@ -0,0 +1,86 @@
package arguments
import (
"flag"
"fmt"
)
// flagStringSlice is a flag.Value implementation which allows collecting
// multiple instances of a single flag into a slice. This is used for flags
// such as -target=aws_instance.foo and -var x=y.
type flagStringSlice []string
var _ flag.Value = (*flagStringSlice)(nil)
func (v *flagStringSlice) String() string {
return ""
}
func (v *flagStringSlice) Set(raw string) error {
*v = append(*v, raw)
return nil
}
// flagNameValueSlice is a flag.Value implementation that appends raw flag
// names and values to a slice. This is used to collect a sequence of flags
// with possibly different names, preserving the overall order.
//
// FIXME: this is a copy of rawFlags from command/meta_config.go, with the
// eventual aim of replacing it altogether by gathering variables in the
// arguments package.
type flagNameValueSlice struct {
flagName string
items *[]FlagNameValue
}
var _ flag.Value = flagNameValueSlice{}
func newFlagNameValueSlice(flagName string) flagNameValueSlice {
var items []FlagNameValue
return flagNameValueSlice{
flagName: flagName,
items: &items,
}
}
func (f flagNameValueSlice) Empty() bool {
if f.items == nil {
return true
}
return len(*f.items) == 0
}
func (f flagNameValueSlice) AllItems() []FlagNameValue {
if f.items == nil {
return nil
}
return *f.items
}
func (f flagNameValueSlice) Alias(flagName string) flagNameValueSlice {
return flagNameValueSlice{
flagName: flagName,
items: f.items,
}
}
func (f flagNameValueSlice) String() string {
return ""
}
func (f flagNameValueSlice) Set(str string) error {
*f.items = append(*f.items, FlagNameValue{
Name: f.flagName,
Value: str,
})
return nil
}
type FlagNameValue struct {
Name string
Value string
}
func (f FlagNameValue) String() string {
return fmt.Sprintf("%s=%q", f.Name, f.Value)
}

View File

@ -376,6 +376,9 @@ func (m *Meta) InterruptibleContext() (context.Context, context.CancelFunc) {
// operation itself is unsuccessful. Use the "Result" field of the // operation itself is unsuccessful. Use the "Result" field of the
// returned operation object to recognize operation-level failure. // returned operation object to recognize operation-level failure.
func (m *Meta) RunOperation(b backend.Enhanced, opReq *backend.Operation) (*backend.RunningOperation, error) { func (m *Meta) RunOperation(b backend.Enhanced, opReq *backend.Operation) (*backend.RunningOperation, error) {
if opReq.View == nil {
panic("RunOperation called with nil View")
}
if opReq.ConfigDir != "" { if opReq.ConfigDir != "" {
opReq.ConfigDir = m.normalizePath(opReq.ConfigDir) opReq.ConfigDir = m.normalizePath(opReq.ConfigDir)
} }
@ -392,14 +395,12 @@ func (m *Meta) RunOperation(b backend.Enhanced, opReq *backend.Operation) (*back
op.Stop() op.Stop()
// Notify the user // Notify the user
m.Ui.Output(outputInterrupt) opReq.View.Interrupted()
// Still get the result, since there is still one // Still get the result, since there is still one
select { select {
case <-m.ShutdownCh: case <-m.ShutdownCh:
m.Ui.Error( opReq.View.FatalInterrupt()
"Two interrupts received. Exiting immediately. Note that data\n" +
"loss may have occurred.")
// cancel the operation completely // cancel the operation completely
op.Cancel() op.Cancel()
@ -782,3 +783,14 @@ func isAutoVarFile(path string) bool {
return strings.HasSuffix(path, ".auto.tfvars") || return strings.HasSuffix(path, ".auto.tfvars") ||
strings.HasSuffix(path, ".auto.tfvars.json") strings.HasSuffix(path, ".auto.tfvars.json")
} }
// FIXME: as an interim refactoring step, we apply the contents of the state
// arguments directly to the Meta object. Future work would ideally update the
// code paths which use these arguments to be passed them directly for clarity.
func (m *Meta) applyStateArguments(args *arguments.State) {
m.stateLock = args.Lock
m.stateLockTimeout = args.LockTimeout
m.statePath = args.StatePath
m.stateOutPath = args.StateOutPath
m.backupPath = args.BackupPath
}

99
command/views/apply.go Normal file
View File

@ -0,0 +1,99 @@
package views
import (
"fmt"
"github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/format"
"github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags"
)
// The Apply view is used for the apply command.
type Apply interface {
ResourceCount(stateOutPath string)
Outputs(outputValues map[string]*states.OutputValue)
Operation() Operation
Hooks() []terraform.Hook
Diagnostics(diags tfdiags.Diagnostics)
HelpPrompt()
}
// NewApply returns an initialized Apply implementation for the given ViewType.
func NewApply(vt arguments.ViewType, destroy bool, runningInAutomation bool, view *View) Apply {
switch vt {
case arguments.ViewHuman:
return &ApplyHuman{
View: *view,
destroy: destroy,
inAutomation: runningInAutomation,
countHook: &countHook{},
}
default:
panic(fmt.Sprintf("unknown view type %v", vt))
}
}
// The ApplyHuman implementation renders human-readable text logs, suitable for
// a scrolling terminal.
type ApplyHuman struct {
View
destroy bool
inAutomation bool
countHook *countHook
}
var _ Apply = (*ApplyHuman)(nil)
func (v *ApplyHuman) ResourceCount(stateOutPath string) {
if v.destroy {
v.streams.Printf(
v.colorize.Color("[reset][bold][green]\nDestroy complete! Resources: %d destroyed.\n"),
v.countHook.Removed,
)
} else {
v.streams.Printf(
v.colorize.Color("[reset][bold][green]\nApply complete! Resources: %d added, %d changed, %d destroyed.\n"),
v.countHook.Added,
v.countHook.Changed,
v.countHook.Removed,
)
}
if (v.countHook.Added > 0 || v.countHook.Changed > 0) && stateOutPath != "" {
v.streams.Printf("\n%s\n\n", format.WordWrap(stateOutPathPostApply, v.View.outputColumns()))
v.streams.Printf("State path: %s\n", stateOutPath)
}
}
func (v *ApplyHuman) Outputs(outputValues map[string]*states.OutputValue) {
if len(outputValues) > 0 {
v.streams.Print(v.colorize.Color("[reset][bold][green]\nOutputs:\n\n"))
NewOutput(arguments.ViewHuman, &v.View).Output("", outputValues)
}
}
func (v *ApplyHuman) Operation() Operation {
return NewOperation(arguments.ViewHuman, v.inAutomation, &v.View)
}
func (v *ApplyHuman) Hooks() []terraform.Hook {
return []terraform.Hook{
v.countHook,
NewUiHook(&v.View),
}
}
func (v *ApplyHuman) HelpPrompt() {
command := "apply"
if v.destroy {
command = "destroy"
}
v.View.HelpPrompt(command)
}
const stateOutPathPostApply = "The state of your infrastructure has been saved to the path below. This state is required to modify and destroy your infrastructure, so keep it safe. To inspect the complete state use the `terraform show` command."

214
command/views/apply_test.go Normal file
View File

@ -0,0 +1,214 @@
package views
import (
"strings"
"testing"
"github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/states"
"github.com/zclconf/go-cty/cty"
)
// This test is mostly because I am paranoid about having two consecutive
// boolean arguments.
func TestApply_new(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
defer done(t)
v := NewApply(arguments.ViewHuman, false, true, NewView(streams))
hv, ok := v.(*ApplyHuman)
if !ok {
t.Fatalf("unexpected return type %t", v)
}
if hv.destroy != false {
t.Fatalf("unexpected destroy value")
}
if hv.inAutomation != true {
t.Fatalf("unexpected inAutomation value")
}
}
// Basic test coverage of Outputs, since most of its functionality is tested
// elsewhere.
func TestApplyHuman_outputs(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
v := NewApply(arguments.ViewHuman, false, false, NewView(streams))
v.Outputs(map[string]*states.OutputValue{
"foo": {Value: cty.StringVal("secret")},
})
got := done(t).Stdout()
for _, want := range []string{"Outputs:", `foo = "secret"`} {
if !strings.Contains(got, want) {
t.Errorf("wrong result\ngot: %q\nwant: %q", got, want)
}
}
}
// Outputs should do nothing if there are no outputs to render.
func TestApplyHuman_outputsEmpty(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
v := NewApply(arguments.ViewHuman, false, false, NewView(streams))
v.Outputs(map[string]*states.OutputValue{})
got := done(t).Stdout()
if got != "" {
t.Errorf("output should be empty, but got: %q", got)
}
}
// Ensure that the correct view type and in-automation settings propagate to the
// Operation view.
func TestApplyHuman_operation(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
defer done(t)
v := NewApply(arguments.ViewHuman, false, true, NewView(streams)).Operation()
if hv, ok := v.(*OperationHuman); !ok {
t.Fatalf("unexpected return type %t", v)
} else if hv.inAutomation != true {
t.Fatalf("unexpected inAutomation value on Operation view")
}
}
// This view is used for both apply and destroy commands, so the help output
// needs to cover both.
func TestApplyHuman_help(t *testing.T) {
testCases := map[string]bool{
"apply": false,
"destroy": true,
}
for name, destroy := range testCases {
t.Run(name, func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
v := NewApply(arguments.ViewHuman, destroy, false, NewView(streams))
v.HelpPrompt()
got := done(t).Stderr()
if !strings.Contains(got, name) {
t.Errorf("wrong result\ngot: %q\nwant: %q", got, name)
}
})
}
}
// Hooks and ResourceCount are tangled up and easiest to test together.
func TestApplyHuman_resourceCount(t *testing.T) {
testCases := map[string]struct {
destroy bool
want string
}{
"apply": {
false,
"Apply complete! Resources: 1 added, 2 changed, 3 destroyed.",
},
"destroy": {
true,
"Destroy complete! Resources: 3 destroyed.",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
v := NewApply(arguments.ViewHuman, tc.destroy, false, NewView(streams))
hooks := v.Hooks()
var count *countHook
for _, hook := range hooks {
if ch, ok := hook.(*countHook); ok {
count = ch
}
}
if count == nil {
t.Fatalf("expected Hooks to include a countHook: %#v", hooks)
}
count.Added = 1
count.Changed = 2
count.Removed = 3
v.ResourceCount("")
got := done(t).Stdout()
if !strings.Contains(got, tc.want) {
t.Errorf("wrong result\ngot: %q\nwant: %q", got, tc.want)
}
})
}
}
func TestApplyHuman_resourceCountStatePath(t *testing.T) {
testCases := map[string]struct {
added int
changed int
removed int
statePath string
wantContains bool
}{
"default state path": {
added: 1,
changed: 2,
removed: 3,
statePath: "",
wantContains: false,
},
"only removed": {
added: 0,
changed: 0,
removed: 5,
statePath: "foo.tfstate",
wantContains: false,
},
"added": {
added: 5,
changed: 0,
removed: 0,
statePath: "foo.tfstate",
wantContains: true,
},
"changed": {
added: 5,
changed: 0,
removed: 0,
statePath: "foo.tfstate",
wantContains: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
v := NewApply(arguments.ViewHuman, false, false, NewView(streams))
hooks := v.Hooks()
var count *countHook
for _, hook := range hooks {
if ch, ok := hook.(*countHook); ok {
count = ch
}
}
if count == nil {
t.Fatalf("expected Hooks to include a countHook: %#v", hooks)
}
count.Added = tc.added
count.Changed = tc.changed
count.Removed = tc.removed
v.ResourceCount(tc.statePath)
got := done(t).Stdout()
want := "State path: " + tc.statePath
contains := strings.Contains(got, want)
if contains && !tc.wantContains {
t.Errorf("wrong result\ngot: %q\nshould not contain: %q", got, want)
} else if !contains && tc.wantContains {
t.Errorf("wrong result\ngot: %q\nshould contain: %q", got, want)
}
})
}
}

View File

@ -1,4 +1,4 @@
package command package views
import ( import (
"sync" "sync"
@ -11,9 +11,9 @@ import (
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
) )
// CountHook is a hook that counts the number of resources // countHook is a hook that counts the number of resources
// added, removed, changed during the course of an apply. // added, removed, changed during the course of an apply.
type CountHook struct { type countHook struct {
Added int Added int
Changed int Changed int
Removed int Removed int
@ -29,9 +29,9 @@ type CountHook struct {
terraform.NilHook terraform.NilHook
} }
var _ terraform.Hook = (*CountHook)(nil) var _ terraform.Hook = (*countHook)(nil)
func (h *CountHook) Reset() { func (h *countHook) Reset() {
h.Lock() h.Lock()
defer h.Unlock() defer h.Unlock()
@ -41,7 +41,7 @@ func (h *CountHook) Reset() {
h.Removed = 0 h.Removed = 0
} }
func (h *CountHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { func (h *countHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) {
h.Lock() h.Lock()
defer h.Unlock() defer h.Unlock()
@ -54,7 +54,7 @@ func (h *CountHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generati
return terraform.HookActionContinue, nil return terraform.HookActionContinue, nil
} }
func (h *CountHook) PostApply(addr addrs.AbsResourceInstance, gen states.Generation, newState cty.Value, err error) (terraform.HookAction, error) { func (h *countHook) PostApply(addr addrs.AbsResourceInstance, gen states.Generation, newState cty.Value, err error) (terraform.HookAction, error) {
h.Lock() h.Lock()
defer h.Unlock() defer h.Unlock()
@ -82,7 +82,7 @@ func (h *CountHook) PostApply(addr addrs.AbsResourceInstance, gen states.Generat
return terraform.HookActionContinue, nil return terraform.HookActionContinue, nil
} }
func (h *CountHook) PostDiff(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { func (h *countHook) PostDiff(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) {
h.Lock() h.Lock()
defer h.Unlock() defer h.Unlock()

View File

@ -1,4 +1,4 @@
package command package views
import ( import (
"reflect" "reflect"
@ -15,11 +15,11 @@ import (
) )
func TestCountHook_impl(t *testing.T) { func TestCountHook_impl(t *testing.T) {
var _ terraform.Hook = new(CountHook) var _ terraform.Hook = new(countHook)
} }
func TestCountHookPostDiff_DestroyDeposed(t *testing.T) { func TestCountHookPostDiff_DestroyDeposed(t *testing.T) {
h := new(CountHook) h := new(countHook)
resources := map[string]*legacy.InstanceDiff{ resources := map[string]*legacy.InstanceDiff{
"lorem": &legacy.InstanceDiff{DestroyDeposed: true}, "lorem": &legacy.InstanceDiff{DestroyDeposed: true},
@ -35,7 +35,7 @@ func TestCountHookPostDiff_DestroyDeposed(t *testing.T) {
h.PostDiff(addr, states.DeposedKey("deadbeef"), plans.Delete, cty.DynamicVal, cty.DynamicVal) h.PostDiff(addr, states.DeposedKey("deadbeef"), plans.Delete, cty.DynamicVal, cty.DynamicVal)
} }
expected := new(CountHook) expected := new(countHook)
expected.ToAdd = 0 expected.ToAdd = 0
expected.ToChange = 0 expected.ToChange = 0
expected.ToRemoveAndAdd = 0 expected.ToRemoveAndAdd = 0
@ -47,7 +47,7 @@ func TestCountHookPostDiff_DestroyDeposed(t *testing.T) {
} }
func TestCountHookPostDiff_DestroyOnly(t *testing.T) { func TestCountHookPostDiff_DestroyOnly(t *testing.T) {
h := new(CountHook) h := new(countHook)
resources := map[string]*legacy.InstanceDiff{ resources := map[string]*legacy.InstanceDiff{
"foo": &legacy.InstanceDiff{Destroy: true}, "foo": &legacy.InstanceDiff{Destroy: true},
@ -66,7 +66,7 @@ func TestCountHookPostDiff_DestroyOnly(t *testing.T) {
h.PostDiff(addr, states.CurrentGen, plans.Delete, cty.DynamicVal, cty.DynamicVal) h.PostDiff(addr, states.CurrentGen, plans.Delete, cty.DynamicVal, cty.DynamicVal)
} }
expected := new(CountHook) expected := new(countHook)
expected.ToAdd = 0 expected.ToAdd = 0
expected.ToChange = 0 expected.ToChange = 0
expected.ToRemoveAndAdd = 0 expected.ToRemoveAndAdd = 0
@ -78,7 +78,7 @@ func TestCountHookPostDiff_DestroyOnly(t *testing.T) {
} }
func TestCountHookPostDiff_AddOnly(t *testing.T) { func TestCountHookPostDiff_AddOnly(t *testing.T) {
h := new(CountHook) h := new(countHook)
resources := map[string]*legacy.InstanceDiff{ resources := map[string]*legacy.InstanceDiff{
"foo": &legacy.InstanceDiff{ "foo": &legacy.InstanceDiff{
@ -108,7 +108,7 @@ func TestCountHookPostDiff_AddOnly(t *testing.T) {
h.PostDiff(addr, states.CurrentGen, plans.Create, cty.DynamicVal, cty.DynamicVal) h.PostDiff(addr, states.CurrentGen, plans.Create, cty.DynamicVal, cty.DynamicVal)
} }
expected := new(CountHook) expected := new(countHook)
expected.ToAdd = 3 expected.ToAdd = 3
expected.ToChange = 0 expected.ToChange = 0
expected.ToRemoveAndAdd = 0 expected.ToRemoveAndAdd = 0
@ -120,7 +120,7 @@ func TestCountHookPostDiff_AddOnly(t *testing.T) {
} }
func TestCountHookPostDiff_ChangeOnly(t *testing.T) { func TestCountHookPostDiff_ChangeOnly(t *testing.T) {
h := new(CountHook) h := new(countHook)
resources := map[string]*legacy.InstanceDiff{ resources := map[string]*legacy.InstanceDiff{
"foo": &legacy.InstanceDiff{ "foo": &legacy.InstanceDiff{
@ -153,7 +153,7 @@ func TestCountHookPostDiff_ChangeOnly(t *testing.T) {
h.PostDiff(addr, states.CurrentGen, plans.Update, cty.DynamicVal, cty.DynamicVal) h.PostDiff(addr, states.CurrentGen, plans.Update, cty.DynamicVal, cty.DynamicVal)
} }
expected := new(CountHook) expected := new(countHook)
expected.ToAdd = 0 expected.ToAdd = 0
expected.ToChange = 3 expected.ToChange = 3
expected.ToRemoveAndAdd = 0 expected.ToRemoveAndAdd = 0
@ -165,7 +165,7 @@ func TestCountHookPostDiff_ChangeOnly(t *testing.T) {
} }
func TestCountHookPostDiff_Mixed(t *testing.T) { func TestCountHookPostDiff_Mixed(t *testing.T) {
h := new(CountHook) h := new(countHook)
resources := map[string]plans.Action{ resources := map[string]plans.Action{
"foo": plans.Delete, "foo": plans.Delete,
@ -184,7 +184,7 @@ func TestCountHookPostDiff_Mixed(t *testing.T) {
h.PostDiff(addr, states.CurrentGen, a, cty.DynamicVal, cty.DynamicVal) h.PostDiff(addr, states.CurrentGen, a, cty.DynamicVal, cty.DynamicVal)
} }
expected := new(CountHook) expected := new(countHook)
expected.ToAdd = 0 expected.ToAdd = 0
expected.ToChange = 1 expected.ToChange = 1
expected.ToRemoveAndAdd = 0 expected.ToRemoveAndAdd = 0
@ -197,7 +197,7 @@ func TestCountHookPostDiff_Mixed(t *testing.T) {
} }
func TestCountHookPostDiff_NoChange(t *testing.T) { func TestCountHookPostDiff_NoChange(t *testing.T) {
h := new(CountHook) h := new(countHook)
resources := map[string]*legacy.InstanceDiff{ resources := map[string]*legacy.InstanceDiff{
"foo": &legacy.InstanceDiff{}, "foo": &legacy.InstanceDiff{},
@ -216,7 +216,7 @@ func TestCountHookPostDiff_NoChange(t *testing.T) {
h.PostDiff(addr, states.CurrentGen, plans.NoOp, cty.DynamicVal, cty.DynamicVal) h.PostDiff(addr, states.CurrentGen, plans.NoOp, cty.DynamicVal, cty.DynamicVal)
} }
expected := new(CountHook) expected := new(countHook)
expected.ToAdd = 0 expected.ToAdd = 0
expected.ToChange = 0 expected.ToChange = 0
expected.ToRemoveAndAdd = 0 expected.ToRemoveAndAdd = 0
@ -229,7 +229,7 @@ func TestCountHookPostDiff_NoChange(t *testing.T) {
} }
func TestCountHookPostDiff_DataSource(t *testing.T) { func TestCountHookPostDiff_DataSource(t *testing.T) {
h := new(CountHook) h := new(countHook)
resources := map[string]plans.Action{ resources := map[string]plans.Action{
"foo": plans.Delete, "foo": plans.Delete,
@ -248,7 +248,7 @@ func TestCountHookPostDiff_DataSource(t *testing.T) {
h.PostDiff(addr, states.CurrentGen, a, cty.DynamicVal, cty.DynamicVal) h.PostDiff(addr, states.CurrentGen, a, cty.DynamicVal, cty.DynamicVal)
} }
expected := new(CountHook) expected := new(countHook)
expected.ToAdd = 0 expected.ToAdd = 0
expected.ToChange = 0 expected.ToChange = 0
expected.ToRemoveAndAdd = 0 expected.ToRemoveAndAdd = 0
@ -261,7 +261,7 @@ func TestCountHookPostDiff_DataSource(t *testing.T) {
} }
func TestCountHookApply_ChangeOnly(t *testing.T) { func TestCountHookApply_ChangeOnly(t *testing.T) {
h := new(CountHook) h := new(countHook)
resources := map[string]*legacy.InstanceDiff{ resources := map[string]*legacy.InstanceDiff{
"foo": &legacy.InstanceDiff{ "foo": &legacy.InstanceDiff{
@ -295,7 +295,7 @@ func TestCountHookApply_ChangeOnly(t *testing.T) {
h.PostApply(addr, states.CurrentGen, cty.DynamicVal, nil) h.PostApply(addr, states.CurrentGen, cty.DynamicVal, nil)
} }
expected := &CountHook{pending: make(map[string]plans.Action)} expected := &countHook{pending: make(map[string]plans.Action)}
expected.Added = 0 expected.Added = 0
expected.Changed = 3 expected.Changed = 3
expected.Removed = 0 expected.Removed = 0
@ -306,7 +306,7 @@ func TestCountHookApply_ChangeOnly(t *testing.T) {
} }
func TestCountHookApply_DestroyOnly(t *testing.T) { func TestCountHookApply_DestroyOnly(t *testing.T) {
h := new(CountHook) h := new(countHook)
resources := map[string]*legacy.InstanceDiff{ resources := map[string]*legacy.InstanceDiff{
"foo": &legacy.InstanceDiff{Destroy: true}, "foo": &legacy.InstanceDiff{Destroy: true},
@ -326,7 +326,7 @@ func TestCountHookApply_DestroyOnly(t *testing.T) {
h.PostApply(addr, states.CurrentGen, cty.DynamicVal, nil) h.PostApply(addr, states.CurrentGen, cty.DynamicVal, nil)
} }
expected := &CountHook{pending: make(map[string]plans.Action)} expected := &countHook{pending: make(map[string]plans.Action)}
expected.Added = 0 expected.Added = 0
expected.Changed = 0 expected.Changed = 0
expected.Removed = 4 expected.Removed = 4

View File

@ -15,6 +15,8 @@ import (
) )
type Operation interface { type Operation interface {
Interrupted()
FatalInterrupt()
Stopping() Stopping()
Cancelled(destroy bool) Cancelled(destroy bool)
@ -51,6 +53,24 @@ type OperationHuman struct {
var _ Operation = (*OperationHuman)(nil) var _ Operation = (*OperationHuman)(nil)
func (v *OperationHuman) Interrupted() {
v.streams.Println(format.WordWrap(interrupted, v.outputColumns()))
}
func (v *OperationHuman) FatalInterrupt() {
v.streams.Eprintln(format.WordWrap(fatalInterrupt, v.errorColumns()))
}
const fatalInterrupt = `
Two interrupts received. Exiting immediately. Note that data loss may have occurred.
`
const interrupted = `
Interrupt received.
Please wait for Terraform to exit or data loss may occur.
Gracefully shutting down...
`
func (v *OperationHuman) Stopping() { func (v *OperationHuman) Stopping() {
v.streams.Println("Stopping operation...") v.streams.Println("Stopping operation...")
} }