Skip to content

Commit 73edff4

Browse files
authored
fix(planmodifier): requires replace external values should not replace if state value is null (#866)
* fix(planmodifier): requires replace external values should not replace if wither plan or state is null fixes importing resource with replace_triggers_external_values recreates the resource #858 * test(planmodifier): add comments explaining why dummy plan and state are used * refactor(planmodifier): simplify RequiredReplaceIfNotNull to avoid duplicate equality checks * docs(planmodifier): clarify code comment
1 parent b16bfdf commit 73edff4

File tree

2 files changed

+104
-3
lines changed

2 files changed

+104
-3
lines changed

internal/services/myplanmodifier/planmodifierdynamic/dynamic_requires_replace.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,22 @@ func RequiresReplace() planmodifier.Dynamic {
2121
)
2222
}
2323

24+
// RequiresReplaceIfNotNull is a plan modifier that sets RequiresReplace
25+
// on the attribute if the planned value is different from the state value.
26+
// It will not trigger replacement when either the planned or state value is null.
27+
// When this function is called, the equality of the planned and state values
28+
// has already been checked by the PlanModifyDynamic method, so we can assume they are different values.
2429
func RequiresReplaceIfNotNull() planmodifier.Dynamic {
2530
return RequiresReplaceIf(
2631
func(_ context.Context, req planmodifier.DynamicRequest, resp *RequiresReplaceIfFuncResponse) {
27-
if req.PlanValue.IsNull() {
32+
if req.PlanValue.IsNull() || req.StateValue.IsNull() {
2833
resp.RequiresReplace = false
2934
return
3035
}
3136
resp.RequiresReplace = true
3237
},
33-
"If the value of this attribute changes and is not null, Terraform will destroy and recreate the resource.",
34-
"If the value of this attribute changes and is not null, Terraform will destroy and recreate the resource.",
38+
"If the planned value is different from the state value, Terraform will destroy and recreate the resource unless either the planned or state value is null.",
39+
"If the planned value is different from the state value, Terraform will destroy and recreate the resource unless either the planned or state value is null.",
3540
)
3641
}
3742

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package planmodifierdynamic
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
8+
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
9+
"github.com/hashicorp/terraform-plugin-framework/types"
10+
"github.com/hashicorp/terraform-plugin-go/tftypes"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func Test_RequiresReplaceIfNotNull(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
planValue types.Dynamic
19+
stateValue types.Dynamic
20+
expectedResult bool
21+
}{
22+
{
23+
name: "both values null",
24+
planValue: types.DynamicNull(),
25+
stateValue: types.DynamicNull(),
26+
expectedResult: false,
27+
},
28+
{
29+
name: "plan value null",
30+
planValue: types.DynamicNull(),
31+
stateValue: types.DynamicValue(types.StringValue("state")),
32+
expectedResult: false,
33+
},
34+
{
35+
name: "state value null",
36+
planValue: types.DynamicValue(types.StringValue("plan")),
37+
stateValue: types.DynamicNull(),
38+
expectedResult: false,
39+
},
40+
{
41+
name: "values equal",
42+
planValue: types.DynamicValue(types.StringValue("same")),
43+
stateValue: types.DynamicValue(types.StringValue("same")),
44+
expectedResult: false,
45+
},
46+
{
47+
name: "values different",
48+
planValue: types.DynamicValue(types.StringValue("plan")),
49+
stateValue: types.DynamicValue(types.StringValue("state")),
50+
expectedResult: true,
51+
},
52+
}
53+
54+
// We need to create a dummy state and plan because the PlanModifierDynamic
55+
// method checks for a null state and plan, returning early if they are null.
56+
state := tftypes.NewValue(tftypes.Object{
57+
AttributeTypes: map[string]tftypes.Type{
58+
"test": tftypes.String,
59+
},
60+
}, map[string]tftypes.Value{
61+
"test": tftypes.NewValue(tftypes.String, "state"),
62+
})
63+
64+
plan := tftypes.NewValue(tftypes.Object{
65+
AttributeTypes: map[string]tftypes.Type{
66+
"test": tftypes.String,
67+
},
68+
}, map[string]tftypes.Value{
69+
"test": tftypes.NewValue(tftypes.String, "plan"),
70+
})
71+
72+
// Set up the plan and state values for the test cases
73+
for _, tt := range tests {
74+
t.Run(tt.name, func(t *testing.T) {
75+
modifier := RequiresReplaceIfNotNull()
76+
77+
req := planmodifier.DynamicRequest{
78+
State: tfsdk.State{
79+
Raw: state,
80+
},
81+
Plan: tfsdk.Plan{
82+
Raw: plan,
83+
},
84+
PlanValue: tt.planValue,
85+
StateValue: tt.stateValue,
86+
}
87+
88+
resp := &planmodifier.DynamicResponse{}
89+
90+
modifier.PlanModifyDynamic(context.Background(), req, resp)
91+
92+
require.Empty(t, resp.Diagnostics)
93+
assert.Equal(t, tt.expectedResult, resp.RequiresReplace)
94+
})
95+
}
96+
}

0 commit comments

Comments
 (0)