From 3c8a4e6e05afefb7639bf30efa852cf59f14ade6 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 6 May 2021 15:22:48 -0700 Subject: [PATCH] command+backend/local: -refresh-only and drift detection This is a light revamp of our plan output to make use of Terraform core's new ability to report both the previous run state and the refreshed state, allowing us to explicitly report changes made outside of Terraform. Because whether a plan has "changes" or not is no longer such a straightforward matter, this now merges views.Operation.Plan with views.Operation.PlanNoChanges to produce a single function that knows how to report all of the various permutations. This was also an opportunity to fill some holes in our previous logic which caused it to produce some confusing messages, including a new tailored message for when "terraform destroy" detects that nothing needs to be destroyed. This also allows users to request the refresh-only planning mode using a new -refresh-only command line option. In that case, Terraform _only_ performs drift detection, and so applying a refresh-only plan only involves writing a new state snapshot, without changing any real infrastructure objects. --- backend/local/backend_apply.go | 27 +-- backend/local/backend_plan.go | 15 +- backend/local/backend_plan_test.go | 13 +- backend/remote/backend_plan.go | 16 ++ command/apply_test.go | 34 +-- command/arguments/extended.go | 17 ++ command/e2etest/primary_test.go | 4 +- command/format/diff.go | 14 +- command/jsonplan/plan.go | 141 ++++++++++++- command/plan.go | 32 +-- command/show_test.go | 4 +- command/testdata/apply/output.jsonlog | 1 + command/views/json/message_types.go | 1 + command/views/json_view.go | 8 + command/views/operation.go | 28 +-- command/views/operation_test.go | 134 +++++++++++- command/views/plan.go | 273 +++++++++++++++++-------- plans/plan.go | 94 ++++++--- website/docs/cli/commands/plan.html.md | 11 +- 19 files changed, 654 insertions(+), 213 deletions(-) diff --git a/backend/local/backend_apply.go b/backend/local/backend_apply.go index 86104ef54..db7d76829 100644 --- a/backend/local/backend_apply.go +++ b/backend/local/backend_apply.go @@ -72,12 +72,15 @@ func (b *Local) opApply( return } - trivialPlan := plan.Changes.Empty() + trivialPlan := !plan.CanApply() hasUI := op.UIOut != nil && op.UIIn != nil mustConfirm := hasUI && !op.AutoApprove && !trivialPlan + op.View.Plan(plan, tfCtx.Schemas()) + if mustConfirm { var desc, query string - if op.PlanMode == plans.DestroyMode { + switch op.PlanMode { + case plans.DestroyMode: if op.Workspace != "default" { query = "Do you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" } else { @@ -85,7 +88,15 @@ func (b *Local) opApply( } desc = "Terraform will destroy all your managed infrastructure, as shown above.\n" + "There is no undo. Only 'yes' will be accepted to confirm." - } else { + case plans.RefreshOnlyMode: + if op.Workspace != "default" { + query = "Would you like to update the Terraform state for \"" + op.Workspace + "\" to reflect these detected changes?" + } else { + query = "Would you like to update the Terraform state to reflect these detected changes?" + } + desc = "Terraform will write these changes to the state without modifying any real infrastructure.\n" + + "There is no undo. Only 'yes' will be accepted to confirm." + default: if op.Workspace != "default" { query = "Do you want to perform these actions in workspace \"" + op.Workspace + "\"?" } else { @@ -95,10 +106,6 @@ func (b *Local) opApply( "Only 'yes' will be accepted to approve." } - if !trivialPlan { - op.View.Plan(plan, tfCtx.Schemas()) - } - // We'll show any accumulated warnings before we display the prompt, // so the user can consider them when deciding how to answer. if len(diags) > 0 { @@ -121,12 +128,6 @@ func (b *Local) opApply( runningOp.Result = backend.OperationFailure return } - } else { - for _, change := range plan.Changes.Resources { - if change.Action != plans.NoOp { - op.View.PlannedChange(change) - } - } } } else { plan, err := op.PlanFile.ReadPlan() diff --git a/backend/local/backend_plan.go b/backend/local/backend_plan.go index e3d56a613..5bdd0d040 100644 --- a/backend/local/backend_plan.go +++ b/backend/local/backend_plan.go @@ -98,7 +98,7 @@ func (b *Local) opPlan( } // Record whether this plan includes any side-effects that could be applied. - runningOp.PlanEmpty = plan.Changes.Empty() + runningOp.PlanEmpty = !plan.CanApply() // Save the plan to disk if path := op.PlanOutPath; path != "" { @@ -143,15 +143,6 @@ func (b *Local) opPlan( } } - // Perform some output tasks - if runningOp.PlanEmpty { - op.View.PlanNoChanges() - - // Even if there are no changes, there still could be some warnings - op.View.Diagnostics(diags) - return - } - // Render the plan op.View.Plan(plan, tfCtx.Schemas()) @@ -160,5 +151,7 @@ func (b *Local) opPlan( // errors then we would've returned early at some other point above. op.View.Diagnostics(diags) - op.View.PlanNextStep(op.PlanOutPath) + if !runningOp.PlanEmpty { + op.View.PlanNextStep(op.PlanOutPath) + } } diff --git a/backend/local/backend_plan_test.go b/backend/local/backend_plan_test.go index 02ed4d5b9..ab1c135f3 100644 --- a/backend/local/backend_plan_test.go +++ b/backend/local/backend_plan_test.go @@ -202,22 +202,23 @@ func TestLocal_planOutputsChanged(t *testing.T) { t.Fatalf("plan operation failed") } if run.PlanEmpty { - t.Fatal("plan should not be empty") + t.Error("plan should not be empty") } expectedOutput := strings.TrimSpace(` -Plan: 0 to add, 0 to change, 0 to destroy. - Changes to Outputs: + added = "after" ~ changed = "before" -> "after" - removed = "before" -> null ~ sensitive_after = (sensitive value) ~ sensitive_before = (sensitive value) + +You can apply this plan to save these new output values to the Terraform +state, without changing any real infrastructure. `) if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) { - t.Fatalf("Unexpected output:\n%s\n\nwant output containing:\n%s", output, expectedOutput) + t.Errorf("Unexpected output:\n%s\n\nwant output containing:\n%s", output, expectedOutput) } } @@ -262,7 +263,7 @@ func TestLocal_planModuleOutputsChanged(t *testing.T) { } expectedOutput := strings.TrimSpace(` -No changes. Infrastructure is up-to-date. +No changes. Your infrastructure matches the configuration. `) if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) { t.Fatalf("Unexpected output:\n%s\n\nwant output containing:\n%s", output, expectedOutput) @@ -323,7 +324,7 @@ Terraform will perform the following actions: Plan: 1 to add, 0 to change, 1 to destroy.` if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) { - t.Fatalf("Unexpected output:\n%s", output) + t.Fatalf("Unexpected output\ngot\n%s\n\nwant:\n%s", output, expectedOutput) } } diff --git a/backend/remote/backend_plan.go b/backend/remote/backend_plan.go index 401e7415b..5011bf829 100644 --- a/backend/remote/backend_plan.go +++ b/backend/remote/backend_plan.go @@ -74,6 +74,14 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio )) } + if op.PlanMode == plans.RefreshOnlyMode { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Refresh-only mode is currently not supported", + `The "remote" backend does not currently support the refresh-only planning mode.`, + )) + } + if b.hasExplicitVariableValues(op) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, @@ -102,6 +110,14 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio )) } + if len(op.ForceReplace) != 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Forced replacement is currently not supported", + `The "remote" backend does not currently support the -replace=... planning option.`, + )) + } + if len(op.Targets) != 0 { // For API versions prior to 2.3, RemoteAPIVersion will return an empty string, // so if there's an error when parsing the RemoteAPIVersion, it's handled as diff --git a/command/apply_test.go b/command/apply_test.go index ab6d2c6a9..464b4f402 100644 --- a/command/apply_test.go +++ b/command/apply_test.go @@ -2103,7 +2103,7 @@ func TestApply_jsonGoldenReference(t *testing.T) { wantLines := strings.Split(want, "\n") if len(gotLines) != len(wantLines) { - t.Fatalf("unexpected number of log lines: got %d, want %d", len(gotLines), len(wantLines)) + t.Errorf("unexpected number of log lines: got %d, want %d", len(gotLines), len(wantLines)) } // Verify that the log starts with a version message @@ -2130,26 +2130,30 @@ func TestApply_jsonGoldenReference(t *testing.T) { } // Compare the rest of the lines against the golden reference - for i := range gotLines[1:] { + var gotLineMaps []map[string]interface{} + for i, line := range gotLines[1:] { index := i + 1 - var gotMap, wantMap map[string]interface{} - if err := json.Unmarshal([]byte(gotLines[index]), &gotMap); err != nil { - t.Errorf("failed to unmarshal got line %d: %s\n%s", index, err, gotLines[i]) + var gotMap map[string]interface{} + if err := json.Unmarshal([]byte(line), &gotMap); err != nil { + t.Errorf("failed to unmarshal got line %d: %s\n%s", index, err, gotLines[index]) } - if err := json.Unmarshal([]byte(wantLines[index]), &wantMap); err != nil { - t.Errorf("failed to unmarshal want line %d: %s\n%s", index, err, wantLines[i]) - } - - // The timestamp field is the only one that should change, so we drop - // it from the comparison if _, ok := gotMap["@timestamp"]; !ok { - t.Errorf("missing @timestamp field in log: %s", gotLines[i]) + t.Errorf("missing @timestamp field in log: %s", gotLines[index]) } delete(gotMap, "@timestamp") - - if !cmp.Equal(wantMap, gotMap) { - t.Errorf("unexpected log:\n%s", cmp.Diff(wantMap, gotMap)) + gotLineMaps = append(gotLineMaps, gotMap) + } + var wantLineMaps []map[string]interface{} + for i, line := range wantLines[1:] { + index := i + 1 + var wantMap map[string]interface{} + if err := json.Unmarshal([]byte(line), &wantMap); err != nil { + t.Errorf("failed to unmarshal want line %d: %s\n%s", index, err, gotLines[index]) } + wantLineMaps = append(wantLineMaps, wantMap) + } + if diff := cmp.Diff(wantLineMaps, gotLineMaps); diff != "" { + t.Errorf("wrong output lines\n%s", diff) } } diff --git a/command/arguments/extended.go b/command/arguments/extended.go index af0b9dcb6..c4b98336e 100644 --- a/command/arguments/extended.go +++ b/command/arguments/extended.go @@ -81,6 +81,7 @@ type Operation struct { targetsRaw []string forceReplaceRaw []string destroyRaw bool + refreshOnlyRaw bool } // Parse must be called on Operation after initial flag parse. This processes @@ -151,8 +152,23 @@ func (o *Operation) Parse() tfdiags.Diagnostics { // 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.refreshOnlyRaw: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Incompatible plan mode options", + "The -destroy and -refresh-only options are mutually-exclusive.", + )) case o.destroyRaw: o.PlanMode = plans.DestroyMode + case o.refreshOnlyRaw: + o.PlanMode = plans.RefreshOnlyMode + if !o.Refresh { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Incompatible refresh options", + "It doesn't make sense to use -refresh-only at the same time as -refresh=false, because Terraform would have nothing to do.", + )) + } default: o.PlanMode = plans.NormalMode } @@ -206,6 +222,7 @@ func extendedFlagSet(name string, state *State, operation *Operation, vars *Vars f.IntVar(&operation.Parallelism, "parallelism", DefaultParallelism, "parallelism") f.BoolVar(&operation.Refresh, "refresh", true, "refresh") f.BoolVar(&operation.destroyRaw, "destroy", false, "destroy") + f.BoolVar(&operation.refreshOnlyRaw, "refresh-only", false, "refresh-only") f.Var((*flagStringSlice)(&operation.targetsRaw), "target", "target") f.Var((*flagStringSlice)(&operation.forceReplaceRaw), "replace", "replace") } diff --git a/command/e2etest/primary_test.go b/command/e2etest/primary_test.go index 304ddedc9..ded1ba78a 100644 --- a/command/e2etest/primary_test.go +++ b/command/e2etest/primary_test.go @@ -165,8 +165,8 @@ func TestPrimaryChdirOption(t *testing.T) { t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr) } - if !strings.Contains(stdout, "0 to add, 0 to change, 0 to destroy") { - t.Errorf("incorrect plan tally; want 0 to add:\n%s", stdout) + if want := "You can apply this plan to save these new output values"; !strings.Contains(stdout, want) { + t.Errorf("missing expected message for an outputs-only plan\ngot:\n%s\n\nwant substring: %s", stdout, want) } if !strings.Contains(stdout, "Saved the plan to: tfplan") { diff --git a/command/format/diff.go b/command/format/diff.go index cfa63041e..92e271d0e 100644 --- a/command/format/diff.go +++ b/command/format/diff.go @@ -235,12 +235,14 @@ func ResourceInstanceDrift( } if after != nil && after.Current != nil { newObj, err = after.Current.Decode(ty) - // We shouldn't encounter errors here because Terraform Core should've - // made sure that the prior state object conforms to the current - // schema by having the provider upgrade it, even if we skipped - // refreshing on this run, but we'll be robust here in case there are - // some edges we didn't find yet. - return fmt.Sprintf(" # %s refreshed state doesn't conform to current schema; this is a Terraform bug\n # %s\n", addr, err) + if err != nil { + // We shouldn't encounter errors here because Terraform Core should've + // made sure that the prior state object conforms to the current + // schema by having the provider upgrade it, even if we skipped + // refreshing on this run, but we'll be robust here in case there are + // some edges we didn't find yet. + return fmt.Sprintf(" # %s refreshed state doesn't conform to current schema; this is a Terraform bug\n # %s\n", addr, err) + } } oldVal := oldObj.Value diff --git a/command/jsonplan/plan.go b/command/jsonplan/plan.go index 1db2067d4..362acc6b4 100644 --- a/command/jsonplan/plan.go +++ b/command/jsonplan/plan.go @@ -31,8 +31,9 @@ type plan struct { TerraformVersion string `json:"terraform_version,omitempty"` Variables variables `json:"variables,omitempty"` PlannedValues stateValues `json:"planned_values,omitempty"` - // ResourceChanges are sorted in a user-friendly order that is undefined at - // this time, but consistent. + // ResourceDrift and ResourceChanges are sorted in a user-friendly order + // that is undefined at this time, but consistent. + ResourceDrift []resourceChange `json:"resource_drift,omitempty"` ResourceChanges []resourceChange `json:"resource_changes,omitempty"` OutputChanges map[string]change `json:"output_changes,omitempty"` PriorState json.RawMessage `json:"prior_state,omitempty"` @@ -128,6 +129,12 @@ func Marshal( return nil, fmt.Errorf("error in marshalPlannedValues: %s", err) } + // output.ResourceDrift + err = output.marshalResourceDrift(p.PrevRunState, p.PriorState, schemas) + if err != nil { + return nil, fmt.Errorf("error in marshalResourceDrift: %s", err) + } + // output.ResourceChanges err = output.marshalResourceChanges(p.Changes, schemas) if err != nil { @@ -181,6 +188,136 @@ func (p *plan) marshalPlanVariables(vars map[string]plans.DynamicValue, schemas return nil } +func (p *plan) marshalResourceDrift(oldState, newState *states.State, schemas *terraform.Schemas) error { + // Our goal here is to build a data structure of the same shape as we use + // to describe planned resource changes, but in this case we'll be + // taking the old and new values from different state snapshots rather + // than from a real "Changes" object. + // + // In doing this we make an assumption that drift detection can only + // ever show objects as updated or removed, and will never show anything + // as created because we only refresh objects we were already tracking + // after the previous run. This means we can use oldState as our baseline + // for what resource instances we might include, and check for each item + // whether it's present in newState. If we ever have some mechanism to + // detect "additive drift" later then we'll need to take a different + // approach here, but we have no plans for that at the time of writing. + // + // We also assume that both states have had all managed resource objects + // upgraded to match the current schemas given in schemas, so we shouldn't + // need to contend with oldState having old-shaped objects even if the + // user changed provider versions since the last run. + + if newState.ManagedResourcesEqual(oldState) { + // Nothing to do, because we only detect and report drift for managed + // resource instances. + return nil + } + for _, ms := range oldState.Modules { + for _, rs := range ms.Resources { + if rs.Addr.Resource.Mode != addrs.ManagedResourceMode { + // Drift reporting is only for managed resources + continue + } + + provider := rs.ProviderConfig.Provider + for key, oldIS := range rs.Instances { + if oldIS.Current == nil { + // Not interested in instances that only have deposed objects + continue + } + addr := rs.Addr.Instance(key) + newIS := newState.ResourceInstance(addr) + + schema, _ := schemas.ResourceTypeConfig( + provider, + addr.Resource.Resource.Mode, + addr.Resource.Resource.Type, + ) + if schema == nil { + return fmt.Errorf("no schema found for %s (in provider %s)", addr, provider) + } + ty := schema.ImpliedType() + + oldObj, err := oldIS.Current.Decode(ty) + if err != nil { + return fmt.Errorf("failed to decode previous run data for %s: %s", addr, err) + } + + var newObj *states.ResourceInstanceObject + if newIS != nil && newIS.Current != nil { + newObj, err = newIS.Current.Decode(ty) + if err != nil { + return fmt.Errorf("failed to decode refreshed data for %s: %s", addr, err) + } + } + + var oldVal, newVal cty.Value + oldVal = oldObj.Value + if newObj != nil { + newVal = newObj.Value + } else { + newVal = cty.NullVal(ty) + } + oldSensitive := sensitiveAsBool(oldVal) + newSensitive := sensitiveAsBool(newVal) + oldVal, _ = oldVal.UnmarkDeep() + newVal, _ = newVal.UnmarkDeep() + + var before, after []byte + var beforeSensitive, afterSensitive []byte + before, err = ctyjson.Marshal(oldVal, oldVal.Type()) + if err != nil { + return fmt.Errorf("failed to encode previous run data for %s as JSON: %s", addr, err) + } + after, err = ctyjson.Marshal(newVal, oldVal.Type()) + if err != nil { + return fmt.Errorf("failed to encode refreshed data for %s as JSON: %s", addr, err) + } + beforeSensitive, err = ctyjson.Marshal(oldSensitive, oldSensitive.Type()) + if err != nil { + return fmt.Errorf("failed to encode previous run data sensitivity for %s as JSON: %s", addr, err) + } + afterSensitive, err = ctyjson.Marshal(newSensitive, newSensitive.Type()) + if err != nil { + return fmt.Errorf("failed to encode refreshed data sensitivity for %s as JSON: %s", addr, err) + } + + // We can only detect updates and deletes as drift. + action := plans.Update + if newVal.IsNull() { + action = plans.Delete + } + + change := resourceChange{ + ModuleAddress: addr.Module.String(), + Mode: "managed", // drift reporting is only for managed resources + Name: addr.Resource.Resource.Name, + Type: addr.Resource.Resource.Type, + ProviderName: provider.String(), + + Change: change{ + Actions: actionString(action.String()), + Before: json.RawMessage(before), + BeforeSensitive: json.RawMessage(beforeSensitive), + After: json.RawMessage(after), + AfterSensitive: json.RawMessage(afterSensitive), + // AfterUnknown is never populated here because + // values in a state are always fully known. + }, + } + p.ResourceDrift = append(p.ResourceDrift, change) + } + } + } + + sort.Slice(p.ResourceChanges, func(i, j int) bool { + return p.ResourceChanges[i].Address < p.ResourceChanges[j].Address + }) + + return nil +} + func (p *plan) marshalResourceChanges(changes *plans.Changes, schemas *terraform.Schemas) error { if changes == nil { // Nothing to do! diff --git a/command/plan.go b/command/plan.go index e33473104..d73041af5 100644 --- a/command/plan.go +++ b/command/plan.go @@ -202,13 +202,19 @@ Plan Customization Options: 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. + -destroy Select the "destroy" planning mode, which creates a plan + to destroy all objects currently managed by this + Terraform configuration instead of the usual behavior. - -refresh=false Skip checking for changes to remote objects while - creating the plan. This can potentially make planning - faster, but at the expense of possibly planning against - a stale record of the remote system state. + -refresh-only Select the "refresh only" planning mode, which checks + whether remote objects still match the outcome of the + most recent Terraform apply but does not propose any + actions to undo any changes made outside of Terraform. + + -refresh=false Skip checking for external changes to remote objects + while creating the plan. This can potentially make + planning faster, but at the expense of possibly planning + against a stale record of the remote system state. -replace=resource Force replacement of a particular resource instance using its resource address. If the plan would've normally @@ -221,17 +227,19 @@ Plan Customization Options: include more than one object. This is for exceptional use only. - -var 'foo=bar' Set a variable in the Terraform configuration. This - flag can be set multiple times. + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. - -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. + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. Other Options: -compact-warnings If Terraform produces any warnings that are not - accompanied by errors, show them in a more compact form + accompanied by errors, shows them in a more compact form that includes only the summary messages. -detailed-exitcode Return detailed exit codes when the command exits. This diff --git a/command/show_test.go b/command/show_test.go index a520e4f8e..68abc13fe 100644 --- a/command/show_test.go +++ b/command/show_test.go @@ -140,7 +140,7 @@ func TestShow_noArgsNoState(t *testing.T) { } } -func TestShow_plan(t *testing.T) { +func TestShow_planNoop(t *testing.T) { planPath := testPlanFileNoop(t) ui := cli.NewMockUi() @@ -160,7 +160,7 @@ func TestShow_plan(t *testing.T) { t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) } - want := `Terraform will perform the following actions` + want := `No changes. Your infrastructure matches the configuration.` got := done(t).Stdout() if !strings.Contains(got, want) { t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got) diff --git a/command/testdata/apply/output.jsonlog b/command/testdata/apply/output.jsonlog index a70603491..806a091fd 100644 --- a/command/testdata/apply/output.jsonlog +++ b/command/testdata/apply/output.jsonlog @@ -1,5 +1,6 @@ {"@level":"info","@message":"Terraform 0.15.0-dev","@module":"terraform.ui","terraform":"0.15.0-dev","type":"version","ui":"0.1.0"} {"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"} +{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"} {"@level":"info","@message":"test_instance.foo: Creating...","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"apply_start"} {"@level":"info","@message":"test_instance.foo: Creation complete after 0s","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create","elapsed_seconds":0},"type":"apply_complete"} {"@level":"info","@message":"Apply complete! Resources: 1 added, 0 changed, 0 destroyed.","@module":"terraform.ui","changes":{"add":1,"change":0,"remove":0,"operation":"apply"},"type":"change_summary"} diff --git a/command/views/json/message_types.go b/command/views/json/message_types.go index eb10c7b1e..fd974d2cd 100644 --- a/command/views/json/message_types.go +++ b/command/views/json/message_types.go @@ -11,6 +11,7 @@ const ( // Operation results MessagePlannedChange MessageType = "planned_change" MessageChangeSummary MessageType = "change_summary" + MessageDriftSummary MessageType = "drift_summary" MessageOutputs MessageType = "outputs" // Hook-driven messages diff --git a/command/views/json_view.go b/command/views/json_view.go index fdb851c3f..f18886e80 100644 --- a/command/views/json_view.go +++ b/command/views/json_view.go @@ -103,6 +103,14 @@ func (v *JSONView) ChangeSummary(cs *json.ChangeSummary) { ) } +func (v *JSONView) DriftSummary(cs *json.ChangeSummary) { + v.log.Info( + cs.String(), + "type", json.MessageDriftSummary, + "changes", cs, + ) +} + func (v *JSONView) Hook(h json.Hook) { v.log.Info( h.String(), diff --git a/command/views/operation.go b/command/views/operation.go index 2f3307c80..3f29b0888 100644 --- a/command/views/operation.go +++ b/command/views/operation.go @@ -24,7 +24,6 @@ type Operation interface { EmergencyDumpState(stateFile *statefile.File) error PlannedChange(change *plans.ResourceInstanceChangeSrc) - PlanNoChanges() Plan(plan *plans.Plan, schemas *terraform.Schemas) PlanNextStep(planPath string) @@ -86,16 +85,15 @@ func (v *OperationHuman) EmergencyDumpState(stateFile *statefile.File) error { return nil } -func (v *OperationHuman) PlanNoChanges() { - v.view.streams.Println("\n" + v.view.colorize.Color(strings.TrimSpace(planNoChanges))) - v.view.streams.Println("\n" + strings.TrimSpace(format.WordWrap(planNoChangesDetail, v.view.outputColumns()))) -} - func (v *OperationHuman) Plan(plan *plans.Plan, schemas *terraform.Schemas) { renderPlan(plan, schemas, v.view) } func (v *OperationHuman) PlannedChange(change *plans.ResourceInstanceChangeSrc) { + // PlannedChange is primarily for machine-readable output in order to + // get a per-resource-instance change description. We don't use it + // with OperationHuman because the output of Plan already includes the + // change details for all resource instances. } // PlanNextStep gives the user some next-steps, unless we're running in an @@ -159,16 +157,6 @@ func (v *OperationJSON) EmergencyDumpState(stateFile *statefile.File) error { return nil } -// Log an empty change summary. -func (v *OperationJSON) PlanNoChanges() { - v.view.ChangeSummary(&json.ChangeSummary{ - Add: 0, - Change: 0, - Remove: 0, - Operation: json.OperationPlanned, - }) -} - // Log a change summary and a series of "planned" messages for the changes in // the plan. func (v *OperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) { @@ -227,14 +215,6 @@ Please wait for Terraform to exit or data loss may occur. Gracefully shutting down... ` -const planNoChanges = ` -[reset][bold][green]No changes. Infrastructure is up-to-date.[reset][green] -` - -const planNoChangesDetail = ` -This means that Terraform did not detect any differences between your configuration and the remote system(s). As a result, there are no actions to take. -` - const planHeaderNoOutput = ` Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now. ` diff --git a/command/views/operation_test.go b/command/views/operation_test.go index d2e74deee..4e3c1e159 100644 --- a/command/views/operation_test.go +++ b/command/views/operation_test.go @@ -10,7 +10,9 @@ 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" + "github.com/hashicorp/terraform/terraform" ) func TestOperation_stopping(t *testing.T) { @@ -72,13 +74,130 @@ func TestOperation_emergencyDumpState(t *testing.T) { } func TestOperation_planNoChanges(t *testing.T) { - streams, done := terminal.StreamsForTesting(t) - v := NewOperation(arguments.ViewHuman, false, NewView(streams)) - v.PlanNoChanges() + tests := map[string]struct { + plan func(schemas *terraform.Schemas) *plans.Plan + wantText string + }{ + "nothing at all in normal mode": { + func(schemas *terraform.Schemas) *plans.Plan { + return &plans.Plan{ + UIMode: plans.NormalMode, + Changes: plans.NewChanges(), + PrevRunState: states.NewState(), + PriorState: states.NewState(), + } + }, + "no differences, so no changes are needed.", + }, + "nothing at all in refresh-only mode": { + func(schemas *terraform.Schemas) *plans.Plan { + return &plans.Plan{ + UIMode: plans.RefreshOnlyMode, + Changes: plans.NewChanges(), + PrevRunState: states.NewState(), + PriorState: states.NewState(), + } + }, + "Terraform has checked that the real remote objects still match", + }, + "nothing at all in destroy mode": { + func(schemas *terraform.Schemas) *plans.Plan { + return &plans.Plan{ + UIMode: plans.DestroyMode, + Changes: plans.NewChanges(), + PrevRunState: states.NewState(), + PriorState: states.NewState(), + } + }, + "No objects need to be destroyed.", + }, + "drift detected in normal mode": { + func(schemas *terraform.Schemas) *plans.Plan { + return &plans.Plan{ + UIMode: plans.NormalMode, + Changes: plans.NewChanges(), + PrevRunState: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "something", + Name: "somewhere", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{}`), + }, + addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewBuiltInProvider("test")), + ) + }), + PriorState: states.NewState(), + } + }, + "to update the Terraform state to match, create and apply a refresh-only plan", + }, + "drift detected in refresh-only mode": { + func(schemas *terraform.Schemas) *plans.Plan { + return &plans.Plan{ + UIMode: plans.RefreshOnlyMode, + Changes: plans.NewChanges(), + PrevRunState: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "something", + Name: "somewhere", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{}`), + }, + addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewBuiltInProvider("test")), + ) + }), + PriorState: states.NewState(), + } + }, + "If you were expecting these changes then you can apply this plan", + }, + "drift detected in destroy mode": { + func(schemas *terraform.Schemas) *plans.Plan { + return &plans.Plan{ + UIMode: plans.DestroyMode, + Changes: plans.NewChanges(), + PrevRunState: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "something", + Name: "somewhere", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{}`), + }, + addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewBuiltInProvider("test")), + ) + }), + PriorState: states.NewState(), + } + }, + "No objects need to be destroyed.", + }, + } - if got, want := done(t).Stdout(), "No changes. Infrastructure is up-to-date."; !strings.Contains(got, want) { - t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + schemas := testSchemas() + for name, test := range tests { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOperation(arguments.ViewHuman, false, NewView(streams)) + plan := test.plan(schemas) + v.Plan(plan, schemas) + got := done(t).Stdout() + if want := test.wantText; want != "" && !strings.Contains(got, want) { + t.Errorf("missing expected message\ngot:\n%s\n\nwant substring: %s", got, want) + } + }) } } @@ -242,7 +361,10 @@ func TestOperationJSON_planNoChanges(t *testing.T) { streams, done := terminal.StreamsForTesting(t) v := &OperationJSON{view: NewJSONView(NewView(streams))} - v.PlanNoChanges() + plan := &plans.Plan{ + Changes: plans.NewChanges(), + } + v.Plan(plan, nil) want := []map[string]interface{}{ { diff --git a/command/views/plan.go b/command/views/plan.go index 5097f71df..fd39ccf4a 100644 --- a/command/views/plan.go +++ b/command/views/plan.go @@ -111,13 +111,14 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) { view.outputColumns(), )) } - view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns())) - view.streams.Println("") } counts := map[plans.Action]int{} var rChanges []*plans.ResourceInstanceChangeSrc for _, change := range plan.Changes.Resources { + if change.Action == plans.NoOp { + continue // We don't show anything for no-op changes + } if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode { // Avoid rendering data sources on deletion continue @@ -126,90 +127,6 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) { rChanges = append(rChanges, change) counts[change.Action]++ } - - headerBuf := &bytes.Buffer{} - fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, view.outputColumns()))) - if counts[plans.Create] > 0 { - fmt.Fprintf(headerBuf, "%s create\n", format.DiffActionSymbol(plans.Create)) - } - if counts[plans.Update] > 0 { - fmt.Fprintf(headerBuf, "%s update in-place\n", format.DiffActionSymbol(plans.Update)) - } - if counts[plans.Delete] > 0 { - fmt.Fprintf(headerBuf, "%s destroy\n", format.DiffActionSymbol(plans.Delete)) - } - if counts[plans.DeleteThenCreate] > 0 { - fmt.Fprintf(headerBuf, "%s destroy and then create replacement\n", format.DiffActionSymbol(plans.DeleteThenCreate)) - } - if counts[plans.CreateThenDelete] > 0 { - fmt.Fprintf(headerBuf, "%s create replacement and then destroy\n", format.DiffActionSymbol(plans.CreateThenDelete)) - } - if counts[plans.Read] > 0 { - fmt.Fprintf(headerBuf, "%s read (data resources)\n", format.DiffActionSymbol(plans.Read)) - } - - view.streams.Println(view.colorize.Color(headerBuf.String())) - - view.streams.Printf("Terraform will perform the following actions:\n\n") - - // Note: we're modifying the backing slice of this plan object in-place - // here. The ordering of resource changes in a plan is not significant, - // but we can only do this safely here because we can assume that nobody - // is concurrently modifying our changes while we're trying to print it. - sort.Slice(rChanges, func(i, j int) bool { - iA := rChanges[i].Addr - jA := rChanges[j].Addr - if iA.String() == jA.String() { - return rChanges[i].DeposedKey < rChanges[j].DeposedKey - } - return iA.Less(jA) - }) - - for _, rcs := range rChanges { - if rcs.Action == plans.NoOp { - continue - } - - providerSchema := schemas.ProviderSchema(rcs.ProviderAddr.Provider) - if providerSchema == nil { - // Should never happen - view.streams.Printf("(schema missing for %s)\n\n", rcs.ProviderAddr) - continue - } - rSchema, _ := providerSchema.SchemaForResourceAddr(rcs.Addr.Resource.Resource) - if rSchema == nil { - // Should never happen - view.streams.Printf("(schema missing for %s)\n\n", rcs.Addr) - continue - } - - view.streams.Println(format.ResourceChange( - rcs, - rSchema, - view.colorize, - )) - } - - // stats is similar to counts above, but: - // - it considers only resource changes - // - it simplifies "replace" into both a create and a delete - stats := map[plans.Action]int{} - for _, change := range rChanges { - switch change.Action { - case plans.CreateThenDelete, plans.DeleteThenCreate: - stats[plans.Create]++ - stats[plans.Delete]++ - default: - stats[change.Action]++ - } - } - view.streams.Printf( - view.colorize.Color("[reset][bold]Plan:[reset] %d to add, %d to change, %d to destroy.\n"), - stats[plans.Create], stats[plans.Update], stats[plans.Delete], - ) - - // If there is at least one planned change to the root module outputs - // then we'll render a summary of those too. var changedRootModuleOutputs []*plans.OutputChangeSrc for _, output := range plan.Changes.Outputs { if !output.Addr.Module.IsRoot() { @@ -220,11 +137,195 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) { } changedRootModuleOutputs = append(changedRootModuleOutputs, output) } + + if len(counts) == 0 && len(changedRootModuleOutputs) == 0 { + // If we didn't find any changes to report at all then this is a + // "No changes" plan. How we'll present this depends on whether + // the plan is "applyable" and, if so, whether it had refresh changes + // that we already would've presented above. + + switch plan.UIMode { + case plans.RefreshOnlyMode: + if haveRefreshChanges { + // We already generated a sufficient prompt about what will + // happen if applying this change above, so we don't need to + // say anything more. + return + } + + view.streams.Print( + view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] Your infrastructure still matches the configuration.[reset]\n\n"), + ) + view.streams.Println(format.WordWrap( + "Terraform has checked that the real remote objects still match the result of your most recent changes, and found no differences.", + view.outputColumns(), + )) + + case plans.DestroyMode: + if haveRefreshChanges { + view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns())) + view.streams.Println("") + } + view.streams.Print( + view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] No objects need to be destroyed.[reset]\n\n"), + ) + view.streams.Println(format.WordWrap( + "Either you have not created any objects yet or the existing objects were already deleted outside of Terraform.", + view.outputColumns(), + )) + + default: + if haveRefreshChanges { + view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns())) + view.streams.Println("") + } + view.streams.Print( + view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] Your infrastructure matches the configuration.[reset]\n\n"), + ) + + if haveRefreshChanges && !plan.CanApply() { + if plan.CanApply() { + // In this case, applying this plan will not change any + // remote objects but _will_ update the state to match what + // we detected during refresh, so we'll reassure the user + // about that. + view.streams.Println(format.WordWrap( + "Your configuration already matches the changes detected above, so applying this plan will only update the state to include the changes detected above and won't change any real infrastructure.", + view.outputColumns(), + )) + } else { + // In this case we detected changes during refresh but this isn't + // a planning mode where we consider those to be applyable. The + // user must re-run in refresh-only mode in order to update the + // state to match the upstream changes. + suggestion := "." + if !view.runningInAutomation { + // The normal message includes a specific command line to run. + suggestion = ":\n terraform apply -refresh-only" + } + view.streams.Println(format.WordWrap( + "Your configuration already matches the changes detected above. If you'd like to update the Terraform state to match, create and apply a refresh-only plan"+suggestion, + view.outputColumns(), + )) + } + return + } + + // If we get down here then we're just in the simple situation where + // the plan isn't applyable at all. + view.streams.Println(format.WordWrap( + "Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.", + view.outputColumns(), + )) + } + return + } + if haveRefreshChanges { + view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns())) + view.streams.Println("") + } + + if len(counts) != 0 { + headerBuf := &bytes.Buffer{} + fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, view.outputColumns()))) + if counts[plans.Create] > 0 { + fmt.Fprintf(headerBuf, "%s create\n", format.DiffActionSymbol(plans.Create)) + } + if counts[plans.Update] > 0 { + fmt.Fprintf(headerBuf, "%s update in-place\n", format.DiffActionSymbol(plans.Update)) + } + if counts[plans.Delete] > 0 { + fmt.Fprintf(headerBuf, "%s destroy\n", format.DiffActionSymbol(plans.Delete)) + } + if counts[plans.DeleteThenCreate] > 0 { + fmt.Fprintf(headerBuf, "%s destroy and then create replacement\n", format.DiffActionSymbol(plans.DeleteThenCreate)) + } + if counts[plans.CreateThenDelete] > 0 { + fmt.Fprintf(headerBuf, "%s create replacement and then destroy\n", format.DiffActionSymbol(plans.CreateThenDelete)) + } + if counts[plans.Read] > 0 { + fmt.Fprintf(headerBuf, "%s read (data resources)\n", format.DiffActionSymbol(plans.Read)) + } + + view.streams.Println(view.colorize.Color(headerBuf.String())) + + view.streams.Printf("Terraform will perform the following actions:\n\n") + + // Note: we're modifying the backing slice of this plan object in-place + // here. The ordering of resource changes in a plan is not significant, + // but we can only do this safely here because we can assume that nobody + // is concurrently modifying our changes while we're trying to print it. + sort.Slice(rChanges, func(i, j int) bool { + iA := rChanges[i].Addr + jA := rChanges[j].Addr + if iA.String() == jA.String() { + return rChanges[i].DeposedKey < rChanges[j].DeposedKey + } + return iA.Less(jA) + }) + + for _, rcs := range rChanges { + if rcs.Action == plans.NoOp { + continue + } + + providerSchema := schemas.ProviderSchema(rcs.ProviderAddr.Provider) + if providerSchema == nil { + // Should never happen + view.streams.Printf("(schema missing for %s)\n\n", rcs.ProviderAddr) + continue + } + rSchema, _ := providerSchema.SchemaForResourceAddr(rcs.Addr.Resource.Resource) + if rSchema == nil { + // Should never happen + view.streams.Printf("(schema missing for %s)\n\n", rcs.Addr) + continue + } + + view.streams.Println(format.ResourceChange( + rcs, + rSchema, + view.colorize, + )) + } + + // stats is similar to counts above, but: + // - it considers only resource changes + // - it simplifies "replace" into both a create and a delete + stats := map[plans.Action]int{} + for _, change := range rChanges { + switch change.Action { + case plans.CreateThenDelete, plans.DeleteThenCreate: + stats[plans.Create]++ + stats[plans.Delete]++ + default: + stats[change.Action]++ + } + } + view.streams.Printf( + view.colorize.Color("[reset][bold]Plan:[reset] %d to add, %d to change, %d to destroy.\n"), + stats[plans.Create], stats[plans.Update], stats[plans.Delete], + ) + } + + // If there is at least one planned change to the root module outputs + // then we'll render a summary of those too. if len(changedRootModuleOutputs) > 0 { view.streams.Println( view.colorize.Color("[reset]\n[bold]Changes to Outputs:[reset]") + format.OutputChanges(changedRootModuleOutputs, view.colorize), ) + + if len(counts) == 0 { + // If we have output changes but not resource changes then we + // won't have output any indication about the changes at all yet, + // so we need some extra context about what it would mean to + // apply a change that _only_ includes output changes. + view.streams.Println(format.WordWrap( + "\nYou can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.", + view.outputColumns(), + )) + } } } diff --git a/plans/plan.go b/plans/plan.go index 3ba7fa975..a3ba4f291 100644 --- a/plans/plan.go +++ b/plans/plan.go @@ -55,36 +55,44 @@ type Plan struct { PriorState *states.State } -// Backend represents the backend-related configuration and other data as it -// existed when a plan was created. -type Backend struct { - // Type is the type of backend that the plan will apply against. - Type string +// CanApply returns true if and only if the recieving plan includes content +// that would make sense to apply. If it returns false, the plan operation +// should indicate that there's nothing to do and Terraform should exit +// without prompting the user to confirm the changes. +// +// This function represents our main business logic for making the decision +// about whether a given plan represents meaningful "changes", and so its +// exact definition may change over time; the intent is just to centralize the +// rules for that rather than duplicating different versions of it at various +// locations in the UI code. +func (p *Plan) CanApply() bool { + switch { + case !p.Changes.Empty(): + // "Empty" means that everything in the changes is a "NoOp", so if + // not empty then there's at least one non-NoOp change. + return true - // Config is the configuration of the backend, whose schema is decided by - // the backend Type. - Config DynamicValue + case !p.PriorState.ManagedResourcesEqual(p.PrevRunState): + // If there are no changes planned but we detected some + // outside-Terraform changes while refreshing then we consider + // that applyable in isolation only if this was a refresh-only + // plan where we expect updating the state to include these + // changes was the intended goal. + // + // (We don't treat a "refresh only" plan as applyable in normal + // planning mode because historically the refresh result wasn't + // considered part of a plan at all, and so it would be + // a disruptive breaking change if refreshing alone suddenly + // became applyable in the normal case and an existing configuration + // was relying on ignore_changes in order to be convergent in spite + // of intentional out-of-band operations.) + return p.UIMode == RefreshOnlyMode - // Workspace is the name of the workspace that was active when the plan - // was created. It is illegal to apply a plan created for one workspace - // to the state of another workspace. - // (This constraint is already enforced by the statefile lineage mechanism, - // but storing this explicitly allows us to return a better error message - // in the situation where the user has the wrong workspace selected.) - Workspace string -} - -func NewBackend(typeName string, config cty.Value, configSchema *configschema.Block, workspaceName string) (*Backend, error) { - dv, err := NewDynamicValue(config, configSchema.ImpliedType()) - if err != nil { - return nil, err + default: + // Otherwise, there are either no changes to apply or they are changes + // our cases above don't consider as worthy of applying in isolation. + return false } - - return &Backend{ - Type: typeName, - Config: dv, - Workspace: workspaceName, - }, nil } // ProviderAddrs returns a list of all of the provider configuration addresses @@ -118,3 +126,35 @@ func (p *Plan) ProviderAddrs() []addrs.AbsProviderConfig { return ret } + +// Backend represents the backend-related configuration and other data as it +// existed when a plan was created. +type Backend struct { + // Type is the type of backend that the plan will apply against. + Type string + + // Config is the configuration of the backend, whose schema is decided by + // the backend Type. + Config DynamicValue + + // Workspace is the name of the workspace that was active when the plan + // was created. It is illegal to apply a plan created for one workspace + // to the state of another workspace. + // (This constraint is already enforced by the statefile lineage mechanism, + // but storing this explicitly allows us to return a better error message + // in the situation where the user has the wrong workspace selected.) + Workspace string +} + +func NewBackend(typeName string, config cty.Value, configSchema *configschema.Block, workspaceName string) (*Backend, error) { + dv, err := NewDynamicValue(config, configSchema.ImpliedType()) + if err != nil { + return nil, err + } + + return &Backend{ + Type: typeName, + Config: dv, + Workspace: workspaceName, + }, nil +} diff --git a/website/docs/cli/commands/plan.html.md b/website/docs/cli/commands/plan.html.md index fd68a98f6..5b102b188 100644 --- a/website/docs/cli/commands/plan.html.md +++ b/website/docs/cli/commands/plan.html.md @@ -86,7 +86,7 @@ The section above described Terraform's default planning behavior, which is intended for changing the remote system to match with changes you've made to your configuration. -Terraform has one alternative planning mode, which creates a plan with +Terraform has two alternative planning modes, each of which creates a plan with a different intended outcome: * **Destroy mode:** creates a plan whose goal is to destroy all remote objects @@ -96,6 +96,15 @@ a different intended outcome: Activate destroy mode using the `-destroy` command line option. +* **Refresh-only mode:** creates a plan whose goal is only to update the + Terraform state and any root module output values to match changes made to + remote objects outside of Terraform. This can be useful if you've + intentionally changed one or more remote objects outside of the usual + workflow (e.g. while responding to an incident) and you now need to reconcile + Terraform's records with those changes. + + Activate refresh-only mode using the `-refresh-only` command line option. + In situations where we need to discuss the default planning mode that Terraform uses when none of the alternative modes are selected, we refer to it as "Normal mode". Because these alternative modes are for specialized situations