From 1d3e34e35e78a70fc9c89baf2c86daa24c2b2b7b Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 30 Apr 2021 14:46:22 -0700 Subject: [PATCH] command: New -replace=... planning option This allows a similar effect to pre-tainting an object but does the action within the context of a normal plan and apply, avoiding the need for an intermediate state where the old object still exists but is marked as tainted. The core functionality for this was already present, so this commit is just the UI-level changes to make that option available for use and to explain how it contributed to the resulting plan in Terraform's output. --- command/apply.go | 1 + command/apply_test.go | 87 +++++++++++++++++++++++ command/arguments/apply_test.go | 59 +++++++++++++++ command/arguments/extended.go | 51 ++++++++++++- command/format/diff.go | 2 + command/plan.go | 19 +++-- command/plan_test.go | 72 +++++++++++++++++++ command/show_test.go | 72 +++++++++++++++++++ command/testdata/apply-replace/main.tf | 2 + command/testdata/plan-replace/main.tf | 2 + website/docs/cli/commands/plan.html.md | 17 +++++ website/docs/cli/commands/taint.html.md | 24 +++++++ website/docs/cli/commands/untaint.html.md | 8 +++ 13 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 command/testdata/apply-replace/main.tf create mode 100644 command/testdata/plan-replace/main.tf diff --git a/command/apply.go b/command/apply.go index e154ce236..3942f4ad9 100644 --- a/command/apply.go +++ b/command/apply.go @@ -266,6 +266,7 @@ func (c *ApplyCommand) OperationRequest( opReq.PlanFile = planFile opReq.PlanRefresh = args.Refresh opReq.Targets = args.Targets + opReq.ForceReplace = args.ForceReplace opReq.Type = backend.OperationTypeApply opReq.View = view.Operation() diff --git a/command/apply_test.go b/command/apply_test.go index 63be339f3..f69aae5dc 100644 --- a/command/apply_test.go +++ b/command/apply_test.go @@ -1862,6 +1862,93 @@ func TestApply_targetFlagsDiags(t *testing.T) { } } +func TestApply_replace(t *testing.T) { + td := tempDir(t) + testCopyDir(t, testFixturePath("apply-replace"), td) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + originalState := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "a", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"hello"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + }) + statePath := testStateFile(t, originalState) + + p := testProvider() + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + }, + }, + }, + }, + } + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + } + } + createCount := 0 + deleteCount := 0 + p.ApplyResourceChangeFn = func(req providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse { + if req.PriorState.IsNull() { + createCount++ + } + if req.PlannedState.IsNull() { + deleteCount++ + } + return providers.ApplyResourceChangeResponse{ + NewState: req.PlannedState, + } + } + + view, done := testView(t) + c := &ApplyCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + }, + } + + args := []string{ + "-auto-approve", + "-state", statePath, + "-replace", "test_instance.a", + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("wrong exit code %d\n\n%s", code, output.Stderr()) + } + + if got, want := output.Stdout(), "1 added, 0 changed, 1 destroyed"; !strings.Contains(got, want) { + t.Errorf("wrong change summary\ngot output:\n%s\n\nwant substring: %s", got, want) + } + + if got, want := createCount, 1; got != want { + t.Errorf("wrong create count %d; want %d", got, want) + } + if got, want := deleteCount, 1; got != want { + t.Errorf("wrong create count %d; want %d", got, want) + } +} + func TestApply_pluginPath(t *testing.T) { // Create a temporary working directory that is empty td := tempDir(t) diff --git a/command/arguments/apply_test.go b/command/arguments/apply_test.go index b0134c469..eb23919c6 100644 --- a/command/arguments/apply_test.go +++ b/command/arguments/apply_test.go @@ -211,6 +211,65 @@ func TestParseApply_targets(t *testing.T) { } } +func TestParseApply_replace(t *testing.T) { + foobarbaz, _ := addrs.ParseAbsResourceInstanceStr("foo_bar.baz") + foobarbeep, _ := addrs.ParseAbsResourceInstanceStr("foo_bar.beep") + testCases := map[string]struct { + args []string + want []addrs.AbsResourceInstance + wantErr string + }{ + "no addresses by default": { + args: nil, + want: nil, + }, + "one address": { + args: []string{"-replace=foo_bar.baz"}, + want: []addrs.AbsResourceInstance{foobarbaz}, + }, + "two addresses": { + args: []string{"-replace=foo_bar.baz", "-replace", "foo_bar.beep"}, + want: []addrs.AbsResourceInstance{foobarbaz, foobarbeep}, + }, + "non-resource-instance address": { + args: []string{"-replace=module.boop"}, + want: nil, + wantErr: "A resource instance address is required here.", + }, + "data resource address": { + args: []string{"-replace=data.foo.bar"}, + want: nil, + wantErr: "Only managed resources can be used", + }, + "invalid traversal": { + args: []string{"-replace=foo."}, + want: nil, + wantErr: "Dot must be followed by attribute name", + }, + "invalid address": { + args: []string{"-replace=data[0].foo"}, + want: nil, + wantErr: "A data source name is required", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseApply(tc.args) + if len(diags) > 0 { + if tc.wantErr == "" { + t.Fatalf("unexpected diags: %v", diags) + } else if got := diags.Err().Error(); !strings.Contains(got, tc.wantErr) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, tc.wantErr) + } + } + if !cmp.Equal(got.Operation.ForceReplace, tc.want) { + t.Fatalf("unexpected result\n%s", cmp.Diff(got.Operation.Targets, tc.want)) + } + }) + } +} + func TestParseApply_vars(t *testing.T) { testCases := map[string]struct { args []string diff --git a/command/arguments/extended.go b/command/arguments/extended.go index 000704a38..af0b9dcb6 100644 --- a/command/arguments/extended.go +++ b/command/arguments/extended.go @@ -63,11 +63,24 @@ type Operation struct { // their dependencies. Targets []addrs.Targetable + // ForceReplace addresses cause Terraform to force a particular set of + // resource instances to generate "replace" actions in any plan where they + // would normally have generated "no-op" or "update" actions. + // + // This is currently limited to specific instances because typical uses + // of replace are associated with only specific remote objects that the + // user has somehow learned to be malfunctioning, in which case it + // would be unusual and potentially dangerous to replace everything under + // a module all at once. We could potentially loosen this later if we + // learn a use-case for broader matching. + ForceReplace []addrs.AbsResourceInstance + // 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 + targetsRaw []string + forceReplaceRaw []string + destroyRaw bool } // Parse must be called on Operation after initial flag parse. This processes @@ -102,6 +115,39 @@ func (o *Operation) Parse() tfdiags.Diagnostics { o.Targets = append(o.Targets, target.Subject) } + for _, raw := range o.forceReplaceRaw { + traversal, syntaxDiags := hclsyntax.ParseTraversalAbs([]byte(raw), "", hcl.Pos{Line: 1, Column: 1}) + if syntaxDiags.HasErrors() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Invalid force-replace address %q", raw), + syntaxDiags[0].Detail, + )) + continue + } + + addr, addrDiags := addrs.ParseAbsResourceInstance(traversal) + if addrDiags.HasErrors() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Invalid force-replace address %q", raw), + addrDiags[0].Description().Detail, + )) + continue + } + + if addr.Resource.Resource.Mode != addrs.ManagedResourceMode { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Invalid force-replace address %q", raw), + "Only managed resources can be used with the -replace=... option.", + )) + continue + } + + o.ForceReplace = append(o.ForceReplace, addr) + } + // If you add a new possible value for o.PlanMode here, consider also // adding a specialized error message for it in ParseApplyDestroy. switch { @@ -161,6 +207,7 @@ func extendedFlagSet(name string, state *State, operation *Operation, vars *Vars f.BoolVar(&operation.Refresh, "refresh", true, "refresh") f.BoolVar(&operation.destroyRaw, "destroy", false, "destroy") f.Var((*flagStringSlice)(&operation.targetsRaw), "target", "target") + f.Var((*flagStringSlice)(&operation.forceReplaceRaw), "replace", "replace") } // Gather all -var and -var-file arguments into one heterogenous structure diff --git a/command/format/diff.go b/command/format/diff.go index 1cc717ab8..d2b84571d 100644 --- a/command/format/diff.go +++ b/command/format/diff.go @@ -60,6 +60,8 @@ func ResourceChange( switch change.ActionReason { case plans.ResourceInstanceReplaceBecauseTainted: buf.WriteString(color.Color(fmt.Sprintf("[bold] # %s[reset] is tainted, so must be [bold][red]replaced", dispAddr))) + case plans.ResourceInstanceReplaceByRequest: + buf.WriteString(color.Color(fmt.Sprintf("[bold] # %s[reset] will be [bold][red]replaced[reset], as requested", dispAddr))) default: buf.WriteString(color.Color(fmt.Sprintf("[bold] # %s[reset] must be [bold][red]replaced", dispAddr))) } diff --git a/command/plan.go b/command/plan.go index 7172846ee..f4c81053f 100644 --- a/command/plan.go +++ b/command/plan.go @@ -149,6 +149,7 @@ func (c *PlanCommand) OperationRequest( opReq.PlanRefresh = args.Refresh opReq.PlanOutPath = planOutPath opReq.Targets = args.Targets + opReq.ForceReplace = args.ForceReplace opReq.Type = backend.OperationTypePlan opReq.View = view.Operation() @@ -204,11 +205,21 @@ Plan Customization Options: -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. + -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. - -target=resource Resource to target. Operation will be limited to this - resource and its dependencies. This flag can be used - multiple times. + -replace=resource Force replacement of a particular resource instance using + its resource address. If the plan would've normally + produced an update or no-op action for this instance, + Terraform will plan to replace it instead. + + -target=resource Limit the planning operation to only the given module, + resource, or resource instance and all of its + dependencies. You can use this option multiple times to + 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. diff --git a/command/plan_test.go b/command/plan_test.go index c102e60bd..2a865a9ca 100644 --- a/command/plan_test.go +++ b/command/plan_test.go @@ -1033,6 +1033,78 @@ func TestPlan_targetFlagsDiags(t *testing.T) { } } +func TestPlan_replace(t *testing.T) { + td := tempDir(t) + testCopyDir(t, testFixturePath("plan-replace"), td) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + originalState := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "a", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"hello"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + }) + statePath := testStateFile(t, originalState) + + p := testProvider() + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + }, + }, + }, + }, + } + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + } + } + + view, done := testView(t) + c := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + }, + } + + args := []string{ + "-state", statePath, + "-no-color", + "-replace", "test_instance.a", + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("wrong exit code %d\n\n%s", code, output.Stderr()) + } + + stdout := output.Stdout() + if got, want := stdout, "1 to add, 0 to change, 1 to destroy"; !strings.Contains(got, want) { + t.Errorf("wrong plan summary\ngot output:\n%s\n\nwant substring: %s", got, want) + } + if got, want := stdout, "test_instance.a will be replaced, as requested"; !strings.Contains(got, want) { + t.Errorf("missing replace explanation\ngot output:\n%s\n\nwant substring: %s", got, want) + } + +} + // planFixtureSchema returns a schema suitable for processing the // configuration in testdata/plan . This schema should be // assigned to a mock provider named "test". diff --git a/command/show_test.go b/command/show_test.go index de535ca5b..f1f0fa0a8 100644 --- a/command/show_test.go +++ b/command/show_test.go @@ -198,6 +198,78 @@ func TestShow_planWithChanges(t *testing.T) { } } +func TestShow_planWithForceReplaceChange(t *testing.T) { + // The main goal of this test is to see that the "replace by request" + // resource instance action reason can round-trip through a plan file and + // be reflected correctly in the "terraform show" output, the same way + // as it would appear in "terraform plan" output. + + _, snap := testModuleWithSnapshot(t, "show") + plannedVal := cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "ami": cty.StringVal("bar"), + }) + priorValRaw, err := plans.NewDynamicValue(cty.NullVal(plannedVal.Type()), plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + plannedValRaw, err := plans.NewDynamicValue(plannedVal, plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + plan := testPlan(t) + plan.Changes.SyncWrapper().AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{ + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.CreateThenDelete, + Before: priorValRaw, + After: plannedValRaw, + }, + ActionReason: plans.ResourceInstanceReplaceByRequest, + }) + planFilePath := testPlanFile( + t, + snap, + states.NewState(), + plan, + ) + + ui := cli.NewMockUi() + view, done := testView(t) + c := &ShowCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(showFixtureProvider()), + Ui: ui, + View: view, + }, + } + + args := []string{ + planFilePath, + } + + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } + + got := done(t).Stdout() + if want := `test_instance.foo will be replaced, as requested`; !strings.Contains(got, want) { + t.Errorf("wrong output\ngot:\n%s\n\nwant substring: %s", got, want) + } + if want := `Plan: 1 to add, 0 to change, 1 to destroy.`; !strings.Contains(got, want) { + t.Errorf("wrong output\ngot:\n%s\n\nwant substring: %s", got, want) + } + +} + func TestShow_plan_json(t *testing.T) { planPath := showFixturePlanFile(t, plans.Create) diff --git a/command/testdata/apply-replace/main.tf b/command/testdata/apply-replace/main.tf new file mode 100644 index 000000000..efc6729f9 --- /dev/null +++ b/command/testdata/apply-replace/main.tf @@ -0,0 +1,2 @@ +resource "test_instance" "a" { +} diff --git a/command/testdata/plan-replace/main.tf b/command/testdata/plan-replace/main.tf new file mode 100644 index 000000000..efc6729f9 --- /dev/null +++ b/command/testdata/plan-replace/main.tf @@ -0,0 +1,2 @@ +resource "test_instance" "a" { +} diff --git a/website/docs/cli/commands/plan.html.md b/website/docs/cli/commands/plan.html.md index 36fb5b361..5a87d728f 100644 --- a/website/docs/cli/commands/plan.html.md +++ b/website/docs/cli/commands/plan.html.md @@ -135,6 +135,23 @@ the previous section, are also available with the same meanings on it would effectively disable the entirety of the planning operation in that case. +* `-replace=ADDRESS` - Instructs Terraform to plan to replace the single + resource instance with the given address. If the given instance would + normally have caused only an "update" action, or no action at all, then + Terraform will choose a "replace" action instead. + + You can use this option if you have learned that a particular remote object + has become degraded in some way. If you are using immutable infrastructure + patterns then you may wish to respond to that by replacing the + malfunctioning object with a new object that has the same configuration. + + This option is allowed only in the normal planning mode, so this option + is incompatible with the `-destroy` option. + + The `-replace=...` option is available only from Terraform v1.0 onwards. + For earlier versions, you can achieve a similar effect (with some caveats) + using [`terraform taint`](./taint.html). + * `-target=ADDRESS` - Instructs Terraform to focus its planning efforts only on resource instances which match the given address and on any objects that those instances depend on. diff --git a/website/docs/cli/commands/taint.html.md b/website/docs/cli/commands/taint.html.md index b3b3ccaa7..84c2cbdbd 100644 --- a/website/docs/cli/commands/taint.html.md +++ b/website/docs/cli/commands/taint.html.md @@ -14,6 +14,30 @@ become degraded or damaged. Terraform represents this by marking the object as "tainted" in the Terraform state, in which case Terraform will propose to replace it in the next plan you create. +~> *Warning:* This command is deprecated, because there are better alternatives +available in Terraform v1.0 and later. See below for more details. + +If your intent is to force replacement of a particular object even though +there are no configuration changes that would require it, we recommend instead +to use the `-replace` option with [`terraform apply`](./apply.html). +For example: + +``` +terraform apply -replace="aws_instance.example[0]" +``` + +Creating a plan with the "replace" option is superior to using `terraform taint` +because it will allow you to see the full effect of that change before you take +any externally-visible action. When you use `terraform taint` to get a similar +effect, you risk someone else on your team creating a new plan against your +tainted object before you've had a chance to review the consequences of that +change yourself. + +The `-replace=...` option to `terraform apply` is only available from +Terraform v1.0 onwards, so if you are using an earlier version you will need to +use `terraform taint` to force object replacement, while considering the +caveats described above. + ## Usage Usage: `terraform taint [options] address` diff --git a/website/docs/cli/commands/untaint.html.md b/website/docs/cli/commands/untaint.html.md index 835ed6223..b505e7eda 100644 --- a/website/docs/cli/commands/untaint.html.md +++ b/website/docs/cli/commands/untaint.html.md @@ -28,6 +28,14 @@ you can use `terraform untaint` to remove the taint marker from that object. This command _will not_ modify any real remote objects, but will modify the state in order to remove the tainted status. +If you remove the taint marker from an object but then later discover that it +was degraded after all, you can create and apply a plan to replace it without +first re-tainting the object, by using a command like the following: + +``` +terraform apply -replace="aws_instance.example[0]" +``` + ## Usage Usage: `terraform untaint [options] address`