diff --git a/backend/backend.go b/backend/backend.go index 801e5c465..2d8987e11 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -193,8 +193,8 @@ type Operation struct { // The options below are more self-explanatory and affect the runtime // behavior of the operation. + PlanMode plans.Mode AutoApprove bool - Destroy bool Parallelism int Targets []addrs.Targetable Variables map[string]UnparsedVariableValue diff --git a/backend/local/backend_apply.go b/backend/local/backend_apply.go index 457104107..95bf07c79 100644 --- a/backend/local/backend_apply.go +++ b/backend/local/backend_apply.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/errwrap" "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/command/views" + "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states/statefile" "github.com/hashicorp/terraform/states/statemgr" @@ -26,7 +27,7 @@ func (b *Local) opApply( // If we have a nil module at this point, then set it to an empty tree // to avoid any potential crashes. - if op.PlanFile == nil && !op.Destroy && !op.HasConfig() { + if op.PlanFile == nil && op.PlanMode != plans.DestroyMode && !op.HasConfig() { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "No configuration files", @@ -76,7 +77,7 @@ func (b *Local) opApply( mustConfirm := hasUI && !op.AutoApprove && !trivialPlan if mustConfirm { var desc, query string - if op.Destroy { + if op.PlanMode == plans.DestroyMode { if op.Workspace != "default" { query = "Do you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" } else { @@ -116,7 +117,7 @@ func (b *Local) opApply( return } if v != "yes" { - op.View.Cancelled(op.Destroy) + op.View.Cancelled(op.PlanMode) runningOp.Result = backend.OperationFailure return } diff --git a/backend/local/backend_apply_test.go b/backend/local/backend_apply_test.go index d220f13a3..95848d113 100644 --- a/backend/local/backend_apply_test.go +++ b/backend/local/backend_apply_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states/statemgr" @@ -115,7 +116,7 @@ func TestLocal_applyEmptyDirDestroy(t *testing.T) { op, configCleanup, done := testOperationApply(t, "./testdata/empty") defer configCleanup() - op.Destroy = true + op.PlanMode = plans.DestroyMode run, err := b.Operation(context.Background(), op) if err != nil { diff --git a/backend/local/backend_local.go b/backend/local/backend_local.go index 65a2f3566..6f9ec0285 100644 --- a/backend/local/backend_local.go +++ b/backend/local/backend_local.go @@ -10,7 +10,6 @@ import ( "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/configs" "github.com/hashicorp/terraform/configs/configload" - "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans/planfile" "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" @@ -66,12 +65,7 @@ func (b *Local) context(op *backend.Operation) (*terraform.Context, *configload. } // Copy set options from the operation - switch { - case op.Destroy: - opts.PlanMode = plans.DestroyMode - default: - opts.PlanMode = plans.NormalMode - } + opts.PlanMode = op.PlanMode opts.Targets = op.Targets opts.UIInput = op.UIIn opts.Hooks = op.Hooks diff --git a/backend/local/backend_plan.go b/backend/local/backend_plan.go index f4d46dd37..a352a763b 100644 --- a/backend/local/backend_plan.go +++ b/backend/local/backend_plan.go @@ -35,7 +35,7 @@ func (b *Local) opPlan( } // Local planning requires a config, unless we're planning to destroy. - if !op.Destroy && !op.HasConfig() { + if op.PlanMode != plans.DestroyMode && !op.HasConfig() { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "No configuration files", diff --git a/backend/local/backend_plan_test.go b/backend/local/backend_plan_test.go index 66ccb9e0a..02ed4d5b9 100644 --- a/backend/local/backend_plan_test.go +++ b/backend/local/backend_plan_test.go @@ -544,7 +544,7 @@ func TestLocal_planDestroy(t *testing.T) { op, configCleanup, done := testOperationPlan(t, "./testdata/plan") defer configCleanup() - op.Destroy = true + op.PlanMode = plans.DestroyMode op.PlanRefresh = true op.PlanOutPath = planPath cfg := cty.ObjectVal(map[string]cty.Value{ @@ -598,7 +598,7 @@ func TestLocal_planDestroy_withDataSources(t *testing.T) { op, configCleanup, done := testOperationPlan(t, "./testdata/destroy-with-ds") defer configCleanup() - op.Destroy = true + op.PlanMode = plans.DestroyMode op.PlanRefresh = true op.PlanOutPath = planPath cfg := cty.ObjectVal(map[string]cty.Value{ diff --git a/backend/remote/backend_apply.go b/backend/remote/backend_apply.go index f03323d24..23e81625a 100644 --- a/backend/remote/backend_apply.go +++ b/backend/remote/backend_apply.go @@ -10,6 +10,7 @@ import ( tfe "github.com/hashicorp/go-tfe" version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" ) @@ -84,7 +85,7 @@ func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operati )) } - if !op.HasConfig() && !op.Destroy { + if !op.HasConfig() && op.PlanMode != plans.DestroyMode { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "No configuration files found", @@ -152,10 +153,12 @@ func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operati if r.Actions.IsDiscardable { err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{}) if err != nil { - if op.Destroy { + switch op.PlanMode { + case plans.DestroyMode: return r, generalError("Failed to discard destroy", err) + default: + return r, generalError("Failed to discard apply", err) } - return r, generalError("Failed to discard apply", err) } } diags = diags.Append(tfdiags.Sourceless( @@ -176,7 +179,7 @@ func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operati if mustConfirm { opts := &terraform.InputOpts{Id: "approve"} - if op.Destroy { + if op.PlanMode == plans.DestroyMode { opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" + "There is no undo. Only 'yes' will be accepted to confirm." diff --git a/backend/remote/backend_apply_test.go b/backend/remote/backend_apply_test.go index 355f1c506..5576fecd4 100644 --- a/backend/remote/backend_apply_test.go +++ b/backend/remote/backend_apply_test.go @@ -19,6 +19,7 @@ import ( "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans/planfile" "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" @@ -968,7 +969,7 @@ func TestRemote_applyDestroy(t *testing.T) { "approve": "yes", }) - op.Destroy = true + op.PlanMode = plans.DestroyMode op.UIIn = input op.UIOut = b.CLI op.Workspace = backend.DefaultStateName @@ -1014,7 +1015,7 @@ func TestRemote_applyDestroyNoConfig(t *testing.T) { defer configCleanup() defer done(t) - op.Destroy = true + op.PlanMode = plans.DestroyMode op.UIIn = input op.UIOut = b.CLI op.Workspace = backend.DefaultStateName diff --git a/backend/remote/backend_common.go b/backend/remote/backend_common.go index 7346324ce..eb8ed2c8a 100644 --- a/backend/remote/backend_common.go +++ b/backend/remote/backend_common.go @@ -13,6 +13,7 @@ import ( tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/terraform" ) @@ -508,7 +509,7 @@ func (b *Remote) confirm(stopCtx context.Context, op *backend.Operation, opts *t if err == errRunDiscarded { err = errApplyDiscarded - if op.Destroy { + if op.PlanMode == plans.DestroyMode { err = errDestroyDiscarded } } @@ -551,7 +552,7 @@ func (b *Remote) confirm(stopCtx context.Context, op *backend.Operation, opts *t if r.Actions.IsDiscardable { err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{}) if err != nil { - if op.Destroy { + if op.PlanMode == plans.DestroyMode { return generalError("Failed to discard destroy", err) } return generalError("Failed to discard apply", err) @@ -560,7 +561,7 @@ func (b *Remote) confirm(stopCtx context.Context, op *backend.Operation, opts *t // Even if the run was discarded successfully, we still // return an error as the apply command was canceled. - if op.Destroy { + if op.PlanMode == plans.DestroyMode { return errDestroyDiscarded } return errApplyDiscarded diff --git a/backend/remote/backend_context.go b/backend/remote/backend_context.go index eb9c92bba..09bace58d 100644 --- a/backend/remote/backend_context.go +++ b/backend/remote/backend_context.go @@ -12,7 +12,6 @@ import ( "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/configs" - "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" @@ -62,12 +61,7 @@ func (b *Remote) Context(op *backend.Operation) (*terraform.Context, statemgr.Fu } // Copy set options from the operation - switch { - case op.Destroy: - opts.PlanMode = plans.DestroyMode - default: - opts.PlanMode = plans.NormalMode - } + opts.PlanMode = op.PlanMode opts.Targets = op.Targets opts.UIInput = op.UIIn diff --git a/backend/remote/backend_plan.go b/backend/remote/backend_plan.go index 6e016b7fc..401e7415b 100644 --- a/backend/remote/backend_plan.go +++ b/backend/remote/backend_plan.go @@ -17,6 +17,7 @@ import ( tfe "github.com/hashicorp/go-tfe" version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/tfdiags" ) @@ -89,7 +90,7 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio )) } - if !op.HasConfig() && !op.Destroy { + if !op.HasConfig() && op.PlanMode != plans.DestroyMode { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "No configuration files found", @@ -238,12 +239,26 @@ in order to capture the filesystem context the remote workspace expects: } runOptions := tfe.RunCreateOptions{ - IsDestroy: tfe.Bool(op.Destroy), Message: tfe.String(queueMessage), ConfigurationVersion: cv, Workspace: w, } + switch op.PlanMode { + case plans.NormalMode: + // okay, but we don't need to do anything special for this + case plans.DestroyMode: + runOptions.IsDestroy = tfe.Bool(true) + default: + // Shouldn't get here because we should update this for each new + // plan mode we add, mapping it to the corresponding RunCreateOptions + // field. + return nil, generalError( + "Invalid plan mode", + fmt.Errorf("remote backend doesn't support %s", op.PlanMode), + ) + } + if len(op.Targets) != 0 { runOptions.TargetAddrs = make([]string, 0, len(op.Targets)) for _, addr := range op.Targets { diff --git a/backend/remote/backend_plan_test.go b/backend/remote/backend_plan_test.go index ac33ce76e..16e482e39 100644 --- a/backend/remote/backend_plan_test.go +++ b/backend/remote/backend_plan_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans/planfile" "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" @@ -709,7 +710,7 @@ func TestRemote_planDestroy(t *testing.T) { defer configCleanup() defer done(t) - op.Destroy = true + op.PlanMode = plans.DestroyMode op.Workspace = backend.DefaultStateName run, err := b.Operation(context.Background(), op) @@ -734,7 +735,7 @@ func TestRemote_planDestroyNoConfig(t *testing.T) { defer configCleanup() defer done(t) - op.Destroy = true + op.PlanMode = plans.DestroyMode op.Workspace = backend.DefaultStateName run, err := b.Operation(context.Background(), op) diff --git a/command/apply.go b/command/apply.go index 178cfbebe..e154ce236 100644 --- a/command/apply.go +++ b/command/apply.go @@ -23,6 +23,8 @@ type ApplyCommand struct { } func (c *ApplyCommand) Run(rawArgs []string) int { + var diags tfdiags.Diagnostics + // Parse and apply global view arguments common, rawArgs := arguments.ParseView(rawArgs) c.View.Configure(common) @@ -33,7 +35,13 @@ func (c *ApplyCommand) Run(rawArgs []string) int { c.Meta.Color = c.Meta.color // Parse and validate flags - args, diags := arguments.ParseApply(rawArgs) + var args *arguments.Apply + switch { + case c.Destroy: + args, diags = arguments.ParseApplyDestroy(rawArgs) + default: + args, diags = arguments.ParseApply(rawArgs) + } // Instantiate the view, even if there are flag errors, so that we render // diagnostics according to the desired view @@ -253,7 +261,7 @@ func (c *ApplyCommand) OperationRequest( opReq := c.Operation(be) opReq.AutoApprove = autoApprove opReq.ConfigDir = "." - opReq.Destroy = c.Destroy + opReq.PlanMode = args.PlanMode opReq.Hooks = view.Hooks() opReq.PlanFile = planFile opReq.PlanRefresh = args.Refresh @@ -345,9 +353,6 @@ Options: -parallelism=n Limit the number of parallel resource operations. Defaults to 10. - -refresh=true Update state prior to checking for differences. This - has no effect if a plan file is given to apply. - -state=path Path to read and save state (unless state-out is specified). Defaults to "terraform.tfstate". @@ -355,18 +360,10 @@ Options: "-state". This can be used to preserve the old state. - -target=resource Resource to target. Operation will be limited to this - resource and its dependencies. This flag can be used - multiple times. - - -var 'foo=bar' Set a variable in the Terraform configuration. This - flag can be set multiple times. - - -var-file=foo Set variables in the Terraform configuration from - a file. If "terraform.tfvars" or any ".auto.tfvars" - files are present, they will be automatically loaded. - - + If you don't provide a saved plan file then this command will also accept + all of the plan-customization options accepted by the terraform plan command. + For more information on those options, run: + terraform plan -help ` return strings.TrimSpace(helpText) } @@ -377,35 +374,12 @@ Usage: terraform [global options] destroy [options] Destroy Terraform-managed infrastructure. -Options: + This command is a convenience alias for: + terraform apply -destroy - -auto-approve Skip interactive approval before destroying. - - -lock=true Lock the state file when locking is supported. - - -lock-timeout=0s Duration to retry a state lock. - - -no-color If specified, output won't contain any color. - - -parallelism=n Limit the number of concurrent operations. - Defaults to 10. - - -refresh=true Update state prior to checking for differences. This - has no effect if a plan file is given to apply. - - -target=resource Resource to target. Operation will be limited to this - resource and its dependencies. This flag can be used - multiple times. - - -var 'foo=bar' Set a variable in the Terraform configuration. This - flag can be set multiple times. - - -var-file=foo Set variables in the Terraform configuration from - a file. If "terraform.tfvars" or any ".auto.tfvars" - files are present, they will be automatically loaded. - - -state, state-out, and -backup are legacy options supported for the local - backend only. For more information, see the local backend's documentation. + This command also accepts many of the plan-customization options accepted by + the terraform plan command. For more information on those options, run: + terraform plan -help ` return strings.TrimSpace(helpText) } diff --git a/command/arguments/apply.go b/command/arguments/apply.go index 0db576b18..8b7b0d674 100644 --- a/command/arguments/apply.go +++ b/command/arguments/apply.go @@ -1,6 +1,9 @@ package arguments import ( + "fmt" + + "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/tfdiags" ) @@ -71,3 +74,53 @@ func ParseApply(args []string) (*Apply, tfdiags.Diagnostics) { return apply, diags } + +// ParseApplyDestroy is a special case of ParseApply that deals with the +// "terraform destroy" command, which is effectively an alias for +// "terraform apply -destroy". +func ParseApplyDestroy(args []string) (*Apply, tfdiags.Diagnostics) { + apply, diags := ParseApply(args) + + // So far ParseApply was using the command line options like -destroy + // and -refresh-only to determine the plan mode. For "terraform destroy" + // we expect neither of those arguments to be set, and so the plan mode + // should currently be set to NormalMode, which we'll replace with + // DestroyMode here. If it's already set to something else then that + // suggests incorrect usage. + switch apply.Operation.PlanMode { + case plans.NormalMode: + // This indicates that the user didn't specify any mode options at + // all, which is correct, although we know from the command that + // they actually intended to use DestroyMode here. + apply.Operation.PlanMode = plans.DestroyMode + case plans.DestroyMode: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid mode option", + "The -destroy option is not valid for \"terraform destroy\", because this command always runs in destroy mode.", + )) + case plans.RefreshOnlyMode: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid mode option", + "The -refresh-only option is not valid for \"terraform destroy\".", + )) + default: + // This is a non-ideal error message for if we forget to handle a + // newly-handled plan mode in Operation.Parse. Ideally they should all + // have cases above so we can produce better error messages. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid mode option", + fmt.Sprintf("The \"terraform destroy\" command doesn't support %s.", apply.Operation.PlanMode), + )) + } + + // NOTE: It's also invalid to have apply.PlanPath set in this codepath, + // but we don't check that in here because we'll return a different error + // message depending on whether the given path seems to refer to a saved + // plan file or to a configuration directory. The apply command + // implementation itself therefore handles this situation. + + return apply, diags +} diff --git a/command/arguments/apply_test.go b/command/arguments/apply_test.go index 65dfae472..c22521cba 100644 --- a/command/arguments/apply_test.go +++ b/command/arguments/apply_test.go @@ -5,7 +5,9 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/plans" ) func TestParseApply_basicValid(t *testing.T) { @@ -20,6 +22,13 @@ func TestParseApply_basicValid(t *testing.T) { InputEnabled: true, PlanPath: "", ViewType: ViewHuman, + State: &State{Lock: true}, + Vars: &Vars{}, + Operation: &Operation{ + PlanMode: plans.NormalMode, + Parallelism: 10, + Refresh: true, + }, }, }, "auto-approve, disabled input, and plan path": { @@ -29,22 +38,43 @@ func TestParseApply_basicValid(t *testing.T) { InputEnabled: false, PlanPath: "saved.tfplan", ViewType: ViewHuman, + State: &State{Lock: true}, + Vars: &Vars{}, + Operation: &Operation{ + PlanMode: plans.NormalMode, + Parallelism: 10, + Refresh: true, + }, + }, + }, + "destroy mode": { + []string{"-destroy"}, + &Apply{ + AutoApprove: false, + InputEnabled: true, + PlanPath: "", + ViewType: ViewHuman, + State: &State{Lock: true}, + Vars: &Vars{}, + Operation: &Operation{ + PlanMode: plans.DestroyMode, + Parallelism: 10, + Refresh: true, + }, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Operation{}, Vars{}, State{}) + 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) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Errorf("unexpected result\n%s", diff) } }) } @@ -175,3 +205,70 @@ func TestParseApply_vars(t *testing.T) { }) } } + +func TestParseApplyDestroy_basicValid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Apply + }{ + "defaults": { + nil, + &Apply{ + AutoApprove: false, + InputEnabled: true, + ViewType: ViewHuman, + State: &State{Lock: true}, + Vars: &Vars{}, + Operation: &Operation{ + PlanMode: plans.DestroyMode, + Parallelism: 10, + Refresh: true, + }, + }, + }, + "auto-approve and disabled input": { + []string{"-auto-approve", "-input=false"}, + &Apply{ + AutoApprove: true, + InputEnabled: false, + ViewType: ViewHuman, + State: &State{Lock: true}, + Vars: &Vars{}, + Operation: &Operation{ + PlanMode: plans.DestroyMode, + Parallelism: 10, + Refresh: true, + }, + }, + }, + } + + cmpOpts := cmpopts.IgnoreUnexported(Operation{}, Vars{}, State{}) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseApplyDestroy(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Errorf("unexpected result\n%s", diff) + } + }) + } +} + +func TestParseApplyDestroy_invalid(t *testing.T) { + t.Run("explicit destroy mode", func(t *testing.T) { + got, diags := ParseApplyDestroy([]string{"-destroy"}) + if len(diags) == 0 { + t.Fatal("expected diags but got none") + } + if got, want := diags.Err().Error(), "Invalid mode option:"; !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) + } + }) +} diff --git a/command/arguments/extended.go b/command/arguments/extended.go index 48b83cab5..000704a38 100644 --- a/command/arguments/extended.go +++ b/command/arguments/extended.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/tfdiags" ) @@ -45,6 +46,11 @@ type State struct { // Operation describes arguments which are used to configure how a Terraform // operation such as a plan or apply executes. type Operation struct { + // PlanMode selects one of the mutually-exclusive planning modes that + // decides the overall goal of a plan operation. This field is relevant + // only for an operation that produces a plan. + PlanMode plans.Mode + // Parallelism is the limit Terraform places on total parallel operations // as it walks the dependency graph. Parallelism int @@ -57,7 +63,11 @@ type Operation struct { // their dependencies. Targets []addrs.Targetable + // These private fields are used only temporarily during decoding. Use + // method Parse to populate the exported fields from these, validating + // the raw values in the process. targetsRaw []string + destroyRaw bool } // Parse must be called on Operation after initial flag parse. This processes @@ -92,6 +102,15 @@ func (o *Operation) Parse() tfdiags.Diagnostics { o.Targets = append(o.Targets, target.Subject) } + // If you add a new possible value for o.PlanMode here, consider also + // adding a specialized error message for it in ParseApplyDestroy. + switch { + case o.destroyRaw: + o.PlanMode = plans.DestroyMode + default: + o.PlanMode = plans.NormalMode + } + return diags } @@ -140,6 +159,7 @@ func extendedFlagSet(name string, state *State, operation *Operation, vars *Vars if operation != nil { f.IntVar(&operation.Parallelism, "parallelism", DefaultParallelism, "parallelism") f.BoolVar(&operation.Refresh, "refresh", true, "refresh") + f.BoolVar(&operation.destroyRaw, "destroy", false, "destroy") f.Var((*flagStringSlice)(&operation.targetsRaw), "target", "target") } diff --git a/command/arguments/plan.go b/command/arguments/plan.go index 93de15c29..1c7be20fc 100644 --- a/command/arguments/plan.go +++ b/command/arguments/plan.go @@ -11,9 +11,6 @@ type Plan struct { Operation *Operation Vars *Vars - // Destroy can be set to generate a plan to destroy all infrastructure. - Destroy bool - // DetailedExitCode enables different exit codes for error, success with // changes, and success with no changes. DetailedExitCode bool @@ -41,7 +38,6 @@ func ParsePlan(args []string) (*Plan, tfdiags.Diagnostics) { } cmdFlags := extendedFlagSet("plan", plan.State, plan.Operation, plan.Vars) - cmdFlags.BoolVar(&plan.Destroy, "destroy", false, "destroy") cmdFlags.BoolVar(&plan.DetailedExitCode, "detailed-exitcode", false, "detailed-exitcode") cmdFlags.BoolVar(&plan.InputEnabled, "input", true, "input") cmdFlags.StringVar(&plan.OutPath, "out", "", "out") diff --git a/command/arguments/plan_test.go b/command/arguments/plan_test.go index aa0af925e..bf336b00d 100644 --- a/command/arguments/plan_test.go +++ b/command/arguments/plan_test.go @@ -5,7 +5,9 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/plans" ) func TestParsePlan_basicValid(t *testing.T) { @@ -16,37 +18,47 @@ func TestParsePlan_basicValid(t *testing.T) { "defaults": { nil, &Plan{ - Destroy: false, DetailedExitCode: false, InputEnabled: true, OutPath: "", ViewType: ViewHuman, + State: &State{Lock: true}, + Vars: &Vars{}, + Operation: &Operation{ + PlanMode: plans.NormalMode, + Parallelism: 10, + Refresh: true, + }, }, }, "setting all options": { []string{"-destroy", "-detailed-exitcode", "-input=false", "-out=saved.tfplan"}, &Plan{ - Destroy: true, DetailedExitCode: true, InputEnabled: false, OutPath: "saved.tfplan", ViewType: ViewHuman, + State: &State{Lock: true}, + Vars: &Vars{}, + Operation: &Operation{ + PlanMode: plans.DestroyMode, + Parallelism: 10, + Refresh: true, + }, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Operation{}, Vars{}, State{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParsePlan(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) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Errorf("unexpected result\n%s", diff) } }) } diff --git a/command/plan.go b/command/plan.go index 4d408fcbb..7172846ee 100644 --- a/command/plan.go +++ b/command/plan.go @@ -71,7 +71,7 @@ func (c *PlanCommand) Run(rawArgs []string) int { } // Build the operation request - opReq, opDiags := c.OperationRequest(be, view, args.Operation, args.Destroy, args.OutPath) + opReq, opDiags := c.OperationRequest(be, view, args.Operation, args.OutPath) diags = diags.Append(opDiags) if diags.HasErrors() { view.Diagnostics(diags) @@ -137,7 +137,6 @@ func (c *PlanCommand) OperationRequest( be backend.Enhanced, view views.Plan, args *arguments.Operation, - destroy bool, planOutPath string, ) (*backend.Operation, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics @@ -145,7 +144,7 @@ func (c *PlanCommand) OperationRequest( // Build the operation opReq := c.Operation(be) opReq.ConfigDir = "." - opReq.Destroy = destroy + opReq.PlanMode = args.PlanMode opReq.Hooks = view.Hooks() opReq.PlanRefresh = args.Refresh opReq.PlanOutPath = planOutPath @@ -196,15 +195,34 @@ Usage: terraform [global options] plan [options] You can optionally save the plan to a file, which you can then pass to the "apply" command to perform exactly the actions described in the plan. -Options: +Plan Customization Options: + + The following options customize how Terraform will produce its plan. You + can also use these options when you run "terraform apply" without passing + it a saved plan, in order to plan and apply in a single command. + + -destroy If set, a plan will be generated to destroy all resources + managed by the given configuration and state. + + -refresh=true Update state prior to checking for differences. + + -target=resource Resource to target. Operation will be limited to this + resource and its dependencies. This flag can be used + multiple times. + + -var 'foo=bar' Set a variable in the Terraform configuration. This + flag can be set multiple times. + + -var-file=foo Set variables in the Terraform configuration from + a file. If "terraform.tfvars" or any ".auto.tfvars" + files are present, they will be automatically loaded. + +Other Options: -compact-warnings If Terraform produces any warnings that are not accompanied by errors, show them in a more compact form that includes only the summary messages. - -destroy If set, a plan will be generated to destroy all resources - managed by the given configuration and state. - -detailed-exitcode Return detailed exit codes when the command exits. This will change the meaning of exit codes to: 0 - Succeeded, diff is empty (no changes) @@ -224,21 +242,8 @@ Options: -parallelism=n Limit the number of concurrent operations. Defaults to 10. - -refresh=true Update state prior to checking for differences. - -state=statefile A legacy option used for the local backend only. See the local backend's documentation for more information. - - -target=resource Resource to target. Operation will be limited to this - resource and its dependencies. This flag can be used - multiple times. - - -var 'foo=bar' Set a variable in the Terraform configuration. This - flag can be set multiple times. - - -var-file=foo Set variables in the Terraform configuration from - a file. If "terraform.tfvars" or any ".auto.tfvars" - files are present, they will be automatically loaded. ` return strings.TrimSpace(helpText) } diff --git a/command/views/operation.go b/command/views/operation.go index 509def766..4252561bc 100644 --- a/command/views/operation.go +++ b/command/views/operation.go @@ -18,7 +18,7 @@ type Operation interface { Interrupted() FatalInterrupt() Stopping() - Cancelled(destroy bool) + Cancelled(planMode plans.Mode) EmergencyDumpState(stateFile *statefile.File) error @@ -75,10 +75,11 @@ func (v *OperationHuman) Stopping() { v.view.streams.Println("Stopping operation...") } -func (v *OperationHuman) Cancelled(destroy bool) { - if destroy { +func (v *OperationHuman) Cancelled(planMode plans.Mode) { + switch planMode { + case plans.DestroyMode: v.view.streams.Println("Destroy cancelled.") - } else { + default: v.view.streams.Println("Apply cancelled.") } } diff --git a/command/views/operation_test.go b/command/views/operation_test.go index c792f67aa..20979bfdf 100644 --- a/command/views/operation_test.go +++ b/command/views/operation_test.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states/statefile" ) @@ -24,16 +25,16 @@ func TestOperation_stopping(t *testing.T) { func TestOperation_cancelled(t *testing.T) { testCases := map[string]struct { - destroy bool - want string + planMode plans.Mode + want string }{ "apply": { - destroy: false, - want: "Apply cancelled.\n", + planMode: plans.NormalMode, + want: "Apply cancelled.\n", }, "destroy": { - destroy: true, - want: "Destroy cancelled.\n", + planMode: plans.DestroyMode, + want: "Destroy cancelled.\n", }, } for name, tc := range testCases { @@ -41,7 +42,7 @@ func TestOperation_cancelled(t *testing.T) { streams, done := terminal.StreamsForTesting(t) v := NewOperation(arguments.ViewHuman, false, NewView(streams)) - v.Cancelled(tc.destroy) + v.Cancelled(tc.planMode) if got, want := done(t).Stdout(), tc.want; got != want { t.Errorf("wrong result\ngot: %q\nwant: %q", got, want)