Skip to content

Commit

Permalink
command: New -replace=... planning option
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
apparentlymart committed May 3, 2021
1 parent 7f39f19 commit 1d3e34e
Show file tree
Hide file tree
Showing 13 changed files with 411 additions and 7 deletions.
1 change: 1 addition & 0 deletions command/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
87 changes: 87 additions & 0 deletions command/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions command/arguments/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 49 additions & 2 deletions command/arguments/extended.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions command/format/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
Expand Down
21 changes: 16 additions & 5 deletions command/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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.
-target=resource Resource to target. Operation will be limited to this
resource and its dependencies. This flag can be used
multiple times.
-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.
-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.
Expand Down
72 changes: 72 additions & 0 deletions command/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
Loading

0 comments on commit 1d3e34e

Please sign in to comment.