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.
This commit is contained in:
Martin Atkins 2021-04-30 14:46:22 -07:00
parent 7f39f19ec7
commit 1d3e34e35e
13 changed files with 410 additions and 6 deletions

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)))
}

View File

@ -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.

View File

@ -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".

View File

@ -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)

View File

@ -0,0 +1,2 @@
resource "test_instance" "a" {
}

2
command/testdata/plan-replace/main.tf vendored Normal file
View File

@ -0,0 +1,2 @@
resource "test_instance" "a" {
}

View File

@ -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.

View File

@ -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`

View File

@ -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`