From 89f986ded6fb07e7d5f27aaf340f69c353860c12 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 5 Apr 2021 16:28:59 -0700 Subject: [PATCH] command+backend: generalized "plan mode" So far we've only had "normal mode" and "destroy mode", where the latter is activated either by "terraform plan -destroy" or "terraform destroy". In preparation for introducing a third mode "refresh only" this generalizes how we handle modes so we can potentially deal with an arbitrary number of modes, although for now we only intend to have three. Mostly this is just a different implementation of the same old behavior, but there is one small user-visible difference here: the "terraform apply" command now accepts a -destroy option, mirroring the option of the same name on "terraform plan", which in turn makes "terraform destroy" effectively a shorthand for "terraform apply -destroy". This is intended to make us consistent that "terraform apply" without a plan file argument accepts all of the same plan-customization options that "terraform plan" does, which will in turn avoid us having to add a new alias of "terraform plan" for each new plan mode we might add. The -help output is changed in that vein here, although we'll wait for subsequent commit to make a similar change to the website documentation just so we can deal with the "refresh only mode" docs at the same time. --- backend/backend.go | 2 +- backend/local/backend_apply.go | 7 +- backend/local/backend_apply_test.go | 3 +- backend/local/backend_local.go | 8 +- backend/local/backend_plan.go | 2 +- backend/local/backend_plan_test.go | 4 +- backend/remote/backend_apply.go | 11 ++- backend/remote/backend_apply_test.go | 5 +- backend/remote/backend_common.go | 7 +- backend/remote/backend_context.go | 8 +- backend/remote/backend_plan.go | 19 ++++- backend/remote/backend_plan_test.go | 5 +- command/apply.go | 64 +++++----------- command/arguments/apply.go | 53 +++++++++++++ command/arguments/apply_test.go | 109 +++++++++++++++++++++++++-- command/arguments/extended.go | 20 +++++ command/arguments/plan.go | 4 - command/arguments/plan_test.go | 28 +++++-- command/plan.go | 45 ++++++----- command/views/operation.go | 9 ++- command/views/operation_test.go | 15 ++-- 21 files changed, 299 insertions(+), 129 deletions(-) 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)