From 5e09c690ed0ed2cbbc9f87ff557911925b23e87c Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Thu, 18 Sep 2025 12:46:22 +0300 Subject: [PATCH] Allow requiresreplace plan modifier to run for write-only attributes Signed-off-by: Andrei Ciobanu --- .../fwserver/attribute_plan_modification.go | 11 + .../attribute_plan_modification_test.go | 1151 ++++++++++++++++- .../boolplanmodifier/requires_replace_if.go | 8 +- .../requires_replace_if_test.go | 34 + .../requires_replace_if.go | 8 +- .../requires_replace_if_test.go | 34 + .../requires_replace_if.go | 8 +- .../requires_replace_if_test.go | 34 + .../requires_replace_if.go | 8 +- .../requires_replace_if_test.go | 34 + .../int32planmodifier/requires_replace_if.go | 8 +- .../requires_replace_if_test.go | 34 + .../int64planmodifier/requires_replace_if.go | 8 +- .../requires_replace_if_test.go | 34 + .../listplanmodifier/requires_replace_if.go | 8 +- .../requires_replace_if_test.go | 34 + .../mapplanmodifier/requires_replace_if.go | 8 +- .../requires_replace_if_test.go | 34 + .../numberplanmodifier/requires_replace_if.go | 8 +- .../requires_replace_if_test.go | 34 + .../objectplanmodifier/requires_replace_if.go | 8 +- .../requires_replace_if_test.go | 34 + resource/schema/planmodifier/bool.go | 3 + resource/schema/planmodifier/dynamic.go | 3 + resource/schema/planmodifier/float32.go | 3 + resource/schema/planmodifier/float64.go | 3 + resource/schema/planmodifier/int32.go | 3 + resource/schema/planmodifier/int64.go | 3 + resource/schema/planmodifier/list.go | 3 + resource/schema/planmodifier/map.go | 3 + resource/schema/planmodifier/number.go | 3 + resource/schema/planmodifier/object.go | 3 + resource/schema/planmodifier/set.go | 2 + resource/schema/planmodifier/string.go | 2 + .../stringplanmodifier/requires_replace_if.go | 8 +- .../requires_replace_if_test.go | 34 + 36 files changed, 1588 insertions(+), 70 deletions(-) diff --git a/internal/fwserver/attribute_plan_modification.go b/internal/fwserver/attribute_plan_modification.go index c82139c64..aab7342db 100644 --- a/internal/fwserver/attribute_plan_modification.go +++ b/internal/fwserver/attribute_plan_modification.go @@ -786,6 +786,7 @@ func AttributePlanModifyBool(ctx context.Context, attribute fwxschema.AttributeW Private: req.Private, State: req.State, StateValue: stateValue, + WriteOnly: attribute.IsWriteOnly(), } for _, planModifier := range attribute.BoolPlanModifiers() { @@ -946,6 +947,7 @@ func AttributePlanModifyFloat32(ctx context.Context, attribute fwxschema.Attribu Private: req.Private, State: req.State, StateValue: stateValue, + WriteOnly: attribute.IsWriteOnly(), } for _, planModifier := range attribute.Float32PlanModifiers() { @@ -1106,6 +1108,7 @@ func AttributePlanModifyFloat64(ctx context.Context, attribute fwxschema.Attribu Private: req.Private, State: req.State, StateValue: stateValue, + WriteOnly: attribute.IsWriteOnly(), } for _, planModifier := range attribute.Float64PlanModifiers() { @@ -1266,6 +1269,7 @@ func AttributePlanModifyInt32(ctx context.Context, attribute fwxschema.Attribute Private: req.Private, State: req.State, StateValue: stateValue, + WriteOnly: attribute.IsWriteOnly(), } for _, planModifier := range attribute.Int32PlanModifiers() { @@ -1426,6 +1430,7 @@ func AttributePlanModifyInt64(ctx context.Context, attribute fwxschema.Attribute Private: req.Private, State: req.State, StateValue: stateValue, + WriteOnly: attribute.IsWriteOnly(), } for _, planModifier := range attribute.Int64PlanModifiers() { @@ -1586,6 +1591,7 @@ func AttributePlanModifyList(ctx context.Context, attribute fwxschema.AttributeW Private: req.Private, State: req.State, StateValue: stateValue, + WriteOnly: attribute.IsWriteOnly(), } for _, planModifier := range attribute.ListPlanModifiers() { @@ -1746,6 +1752,7 @@ func AttributePlanModifyMap(ctx context.Context, attribute fwxschema.AttributeWi Private: req.Private, State: req.State, StateValue: stateValue, + WriteOnly: attribute.IsWriteOnly(), } for _, planModifier := range attribute.MapPlanModifiers() { @@ -1906,6 +1913,7 @@ func AttributePlanModifyNumber(ctx context.Context, attribute fwxschema.Attribut Private: req.Private, State: req.State, StateValue: stateValue, + WriteOnly: attribute.IsWriteOnly(), } for _, planModifier := range attribute.NumberPlanModifiers() { @@ -2066,6 +2074,7 @@ func AttributePlanModifyObject(ctx context.Context, attribute fwxschema.Attribut Private: req.Private, State: req.State, StateValue: stateValue, + WriteOnly: attribute.IsWriteOnly(), } for _, planModifier := range attribute.ObjectPlanModifiers() { @@ -2386,6 +2395,7 @@ func AttributePlanModifyString(ctx context.Context, attribute fwxschema.Attribut Private: req.Private, State: req.State, StateValue: stateValue, + WriteOnly: attribute.IsWriteOnly(), } for _, planModifier := range attribute.StringPlanModifiers() { @@ -2546,6 +2556,7 @@ func AttributePlanModifyDynamic(ctx context.Context, attribute fwxschema.Attribu Private: req.Private, State: req.State, StateValue: stateValue, + WriteOnly: attribute.IsWriteOnly(), } for _, planModifier := range attribute.DynamicPlanModifiers() { diff --git a/internal/fwserver/attribute_plan_modification_test.go b/internal/fwserver/attribute_plan_modification_test.go index c0b635b4c..61157a812 100644 --- a/internal/fwserver/attribute_plan_modification_test.go +++ b/internal/fwserver/attribute_plan_modification_test.go @@ -10,6 +10,13 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/float32planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/float64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/numberplanmodifier" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -4419,6 +4426,97 @@ func TestAttributePlanModifyBool(t *testing.T) { }, }, }, + "response-requiresreplace-write-only-with-no-config": { + attribute: testschema.AttributeWithBoolPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.BoolNull(), + AttributePlan: types.BoolNull(), + AttributeState: types.BoolNull(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.BoolNull(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.BoolNull(), + }, + }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithBoolPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.BoolValue(true), + AttributePlan: types.BoolNull(), + AttributeState: types.BoolNull(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.BoolNull(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.BoolNull(), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, } for name, testCase := range testCases { @@ -5053,6 +5151,97 @@ func TestAttributePlanModifyFloat32(t *testing.T) { }, }, }, + "response-requiresreplace-write-only-with-no-config": { + attribute: testschema.AttributeWithFloat32PlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Float32{ + float32planmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.Float32Null(), + AttributePlan: types.Float32Null(), + AttributeState: types.Float32Null(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.Float32Null(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.Float32Null(), + }, + }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithFloat32PlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Float32{ + float32planmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.Float32Value(1.3), + AttributePlan: types.Float32Null(), + AttributeState: types.Float32Null(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.Float32Null(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.Float32Null(), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, } for name, testCase := range testCases { @@ -5687,6 +5876,97 @@ func TestAttributePlanModifyFloat64(t *testing.T) { }, }, }, + "response-requiresreplace-write-only-with-no-config": { + attribute: testschema.AttributeWithFloat64PlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Float64{ + float64planmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.Float64Null(), + AttributePlan: types.Float64Null(), + AttributeState: types.Float64Null(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.Float64Null(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.Float64Null(), + }, + }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithFloat64PlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Float64{ + float64planmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.Float64Value(1.3), + AttributePlan: types.Float64Null(), + AttributeState: types.Float64Null(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.Float64Null(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.Float64Null(), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, } for name, testCase := range testCases { @@ -6321,6 +6601,97 @@ func TestAttributePlanModifyInt32(t *testing.T) { }, }, }, + "response-requiresreplace-write-only-with-no-config": { + attribute: testschema.AttributeWithInt32PlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Int32{ + int32planmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.Int32Null(), + AttributePlan: types.Int32Null(), + AttributeState: types.Int32Null(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.Int32Null(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.Int32Null(), + }, + }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithInt32PlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Int32{ + int32planmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.Int32Value(2), + AttributePlan: types.Int32Null(), + AttributeState: types.Int32Null(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.Int32Null(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.Int32Null(), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, } for name, testCase := range testCases { @@ -6955,6 +7326,97 @@ func TestAttributePlanModifyInt64(t *testing.T) { }, }, }, + "response-requiresreplace-write-only-with-no-config": { + attribute: testschema.AttributeWithInt64PlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.Int64Null(), + AttributePlan: types.Int64Null(), + AttributeState: types.Int64Null(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.Int64Null(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.Int64Null(), + }, + }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithInt64PlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.Int64Value(2), + AttributePlan: types.Int64Null(), + AttributeState: types.Int64Null(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.Int64Null(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.Int64Null(), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, } for name, testCase := range testCases { @@ -7607,6 +8069,97 @@ func TestAttributePlanModifyList(t *testing.T) { }, }, }, + "response-requiresreplace-write-only-with-no-config": { + attribute: testschema.AttributeWithListPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.ListNull(types.StringType), + AttributePlan: types.ListNull(types.StringType), + AttributeState: types.ListNull(types.StringType), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.ListNull(types.StringType), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.ListNull(types.StringType), + }, + }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithListPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("value from config")}), + AttributePlan: types.ListNull(types.StringType), + AttributeState: types.ListNull(types.StringType), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.ListNull(types.StringType), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.ListNull(types.StringType), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, } for name, testCase := range testCases { @@ -8586,6 +9139,97 @@ func TestAttributePlanModifyMap(t *testing.T) { }, }, }, + "response-requiresreplace-write-only-with-no-config": { + attribute: testschema.AttributeWithMapPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Map{ + mapplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.MapNull(types.StringType), + AttributePlan: types.MapNull(types.StringType), + AttributeState: types.MapNull(types.StringType), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.MapNull(types.StringType), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.MapNull(types.StringType), + }, + }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithMapPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Map{ + mapplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.MapValueMust(types.StringType, map[string]attr.Value{"testattr": types.StringValue("value from config")}), + AttributePlan: types.MapNull(types.StringType), + AttributeState: types.MapNull(types.StringType), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.MapNull(types.StringType), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.MapNull(types.StringType), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, } for name, testCase := range testCases { @@ -9220,6 +9864,97 @@ func TestAttributePlanModifyNumber(t *testing.T) { }, }, }, + "response-requiresreplace-write-only-with-no-config": { + attribute: testschema.AttributeWithNumberPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Number{ + numberplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.NumberNull(), + AttributePlan: types.NumberNull(), + AttributeState: types.NumberNull(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.NumberNull(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.NumberNull(), + }, + }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithNumberPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Number{ + numberplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.NumberValue(big.NewFloat(4.2)), + AttributePlan: types.NumberNull(), + AttributeState: types.NumberNull(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.NumberNull(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.NumberNull(), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, } for name, testCase := range testCases { @@ -10360,60 +11095,151 @@ func TestAttributePlanModifyObject(t *testing.T) { PlanModifyObjectMethod: func(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { resp.RequiresReplace = true }, - }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.ObjectValueMust( + map[string]attr.Type{ + "testattr": types.StringType, + }, + map[string]attr.Value{ + "testattr": types.StringValue("testvalue"), + }, + ), + AttributePlan: types.ObjectValueMust( + map[string]attr.Type{ + "testattr": types.StringType, + }, + map[string]attr.Value{ + "testattr": types.StringValue("testvalue"), + }, + ), + AttributeState: types.ObjectValueMust( + map[string]attr.Type{ + "testattr": types.StringType, + }, + map[string]attr.Value{ + "testattr": types.StringValue("oldtestvalue"), + }, + ), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.ObjectValueMust( + map[string]attr.Type{ + "testattr": types.StringType, + }, + map[string]attr.Value{ + "testattr": types.StringValue("testvalue"), + }, + ), + RequiresReplace: path.Paths{ + path.Root("test"), // Set by prior plan modifier + }, + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.ObjectValueMust( + map[string]attr.Type{ + "testattr": types.StringType, + }, + map[string]attr.Value{ + "testattr": types.StringValue("testvalue"), + }, + ), + RequiresReplace: path.Paths{ + path.Root("test"), // Remains deduplicated + }, + }, + }, + "response-requiresreplace-write-only-with-no-config": { + attribute: testschema.AttributeWithObjectPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + AttributePlan: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + AttributeState: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithObjectPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), }, - }, - request: ModifyAttributePlanRequest{ - AttributePath: path.Root("test"), - AttributeConfig: types.ObjectValueMust( - map[string]attr.Type{ - "testattr": types.StringType, - }, - map[string]attr.Value{ - "testattr": types.StringValue("testvalue"), - }, - ), - AttributePlan: types.ObjectValueMust( - map[string]attr.Type{ - "testattr": types.StringType, - }, - map[string]attr.Value{ - "testattr": types.StringValue("testvalue"), - }, - ), - AttributeState: types.ObjectValueMust( - map[string]attr.Type{ - "testattr": types.StringType, - }, - map[string]attr.Value{ - "testattr": types.StringValue("oldtestvalue"), - }, - ), + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("value from config")}), + AttributePlan: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + AttributeState: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), }, response: &ModifyAttributePlanResponse{ - AttributePlan: types.ObjectValueMust( - map[string]attr.Type{ - "testattr": types.StringType, - }, - map[string]attr.Value{ - "testattr": types.StringValue("testvalue"), - }, - ), - RequiresReplace: path.Paths{ - path.Root("test"), // Set by prior plan modifier - }, + AttributePlan: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), }, expected: &ModifyAttributePlanResponse{ - AttributePlan: types.ObjectValueMust( - map[string]attr.Type{ - "testattr": types.StringType, - }, - map[string]attr.Value{ - "testattr": types.StringValue("testvalue"), - }, - ), + AttributePlan: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), RequiresReplace: path.Paths{ - path.Root("test"), // Remains deduplicated + path.Root("test"), }, }, }, @@ -11069,6 +11895,53 @@ func TestAttributePlanModifySet(t *testing.T) { }, }, }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithSetPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("value from config")}), + AttributePlan: types.SetNull(types.StringType), + AttributeState: types.SetNull(types.StringType), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.SetNull(types.StringType), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.SetNull(types.StringType), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, } for name, testCase := range testCases { @@ -11703,6 +12576,97 @@ func TestAttributePlanModifyString(t *testing.T) { }, }, }, + "response-requiresreplace-write-only-with-no-config": { + attribute: testschema.AttributeWithStringPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.StringNull(), + AttributePlan: types.StringNull(), + AttributeState: types.StringNull(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.StringNull(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.StringNull(), + }, + }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithStringPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.StringValue("value from config"), + AttributePlan: types.StringNull(), + AttributeState: types.StringNull(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.StringNull(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.StringNull(), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, } for name, testCase := range testCases { @@ -12337,6 +13301,97 @@ func TestAttributePlanModifyDynamic(t *testing.T) { }, }, }, + "response-requiresreplace-write-only-with-no-config": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Dynamic{ + dynamicplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.DynamicNull(), + AttributePlan: types.DynamicNull(), + AttributeState: types.DynamicNull(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicNull(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicNull(), + }, + }, + "response-requiresreplace-write-only-with-config": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + WriteOnly: true, + PlanModifiers: []planmodifier.Dynamic{ + dynamicplanmodifier.RequiresReplace(), + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test2": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "test2": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + AttributeConfig: types.DynamicValue(types.StringValue("value from config")), + AttributePlan: types.DynamicNull(), + AttributeState: types.DynamicNull(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicNull(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicNull(), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/boolplanmodifier/requires_replace_if.go b/resource/schema/boolplanmodifier/requires_replace_if.go index 389ddb937..8348db84b 100644 --- a/resource/schema/boolplanmodifier/requires_replace_if.go +++ b/resource/schema/boolplanmodifier/requires_replace_if.go @@ -59,8 +59,12 @@ func (m requiresReplaceIfModifier) PlanModifyBool(ctx context.Context, req planm return } - // Do not replace if the plan and state values are equal. - if req.PlanValue.Equal(req.StateValue) { + // Do not replace if the plan and state values are equal when the attribute is not write-only. + if !req.WriteOnly && req.PlanValue.Equal(req.StateValue) { + return + } + // Even if it's write-only, do not run the modifier if the config value is not specified. + if req.WriteOnly && req.ConfigValue.IsNull() { return } diff --git a/resource/schema/boolplanmodifier/requires_replace_if_test.go b/resource/schema/boolplanmodifier/requires_replace_if_test.go index 4d88e36ef..744cc470a 100644 --- a/resource/schema/boolplanmodifier/requires_replace_if_test.go +++ b/resource/schema/boolplanmodifier/requires_replace_if_test.go @@ -159,6 +159,40 @@ func TestRequiresReplaceIfModifierPlanModifyBool(t *testing.T) { RequiresReplace: false, }, }, + "write-only-with-null-config-value": { + request: planmodifier.BoolRequest{ + Plan: testPlan(types.BoolValue(true)), + PlanValue: types.BoolNull(), + State: testState(types.BoolValue(true)), + StateValue: types.BoolNull(), + ConfigValue: types.BoolNull(), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolNull(), + RequiresReplace: false, + }, + }, + "write-only-with-actual-config-value": { + request: planmodifier.BoolRequest{ + Plan: testPlan(types.BoolValue(true)), + PlanValue: types.BoolNull(), + State: testState(types.BoolValue(true)), + StateValue: types.BoolNull(), + ConfigValue: types.BoolValue(true), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolNull(), + RequiresReplace: true, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/dynamicplanmodifier/requires_replace_if.go b/resource/schema/dynamicplanmodifier/requires_replace_if.go index 75eb39080..49545c919 100644 --- a/resource/schema/dynamicplanmodifier/requires_replace_if.go +++ b/resource/schema/dynamicplanmodifier/requires_replace_if.go @@ -59,8 +59,12 @@ func (m requiresReplaceIfModifier) PlanModifyDynamic(ctx context.Context, req pl return } - // Do not replace if the plan and state values are equal. - if req.PlanValue.Equal(req.StateValue) { + // Do not replace if the plan and state values are equal when the attribute is not write-only. + if !req.WriteOnly && req.PlanValue.Equal(req.StateValue) { + return + } + // Even if it's write-only, do not run the modifier if the config value is not specified. + if req.WriteOnly && req.ConfigValue.IsNull() { return } diff --git a/resource/schema/dynamicplanmodifier/requires_replace_if_test.go b/resource/schema/dynamicplanmodifier/requires_replace_if_test.go index a878405f2..cd0fe0d95 100644 --- a/resource/schema/dynamicplanmodifier/requires_replace_if_test.go +++ b/resource/schema/dynamicplanmodifier/requires_replace_if_test.go @@ -159,6 +159,40 @@ func TestRequiresReplaceIfModifierPlanModifyDynamic(t *testing.T) { RequiresReplace: false, }, }, + "write-only-with-null-config-value": { + request: planmodifier.DynamicRequest{ + Plan: testPlan(types.DynamicValue(types.StringValue("test"))), + PlanValue: types.DynamicNull(), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicNull(), + ConfigValue: types.DynamicNull(), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicNull(), + RequiresReplace: false, + }, + }, + "write-only-with-actual-config-value": { + request: planmodifier.DynamicRequest{ + Plan: testPlan(types.DynamicValue(types.StringValue("test"))), + PlanValue: types.DynamicNull(), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicNull(), + ConfigValue: types.DynamicValue(types.StringValue("test value from config")), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicNull(), + RequiresReplace: true, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/float32planmodifier/requires_replace_if.go b/resource/schema/float32planmodifier/requires_replace_if.go index 4699b6425..963180dfe 100644 --- a/resource/schema/float32planmodifier/requires_replace_if.go +++ b/resource/schema/float32planmodifier/requires_replace_if.go @@ -59,8 +59,12 @@ func (m requiresReplaceIfModifier) PlanModifyFloat32(ctx context.Context, req pl return } - // Do not replace if the plan and state values are equal. - if req.PlanValue.Equal(req.StateValue) { + // Do not replace if the plan and state values are equal when the attribute is not write-only. + if !req.WriteOnly && req.PlanValue.Equal(req.StateValue) { + return + } + // Even if it's write-only, do not run the modifier if the config value is not specified. + if req.WriteOnly && req.ConfigValue.IsNull() { return } diff --git a/resource/schema/float32planmodifier/requires_replace_if_test.go b/resource/schema/float32planmodifier/requires_replace_if_test.go index cc8c07e04..26dd39490 100644 --- a/resource/schema/float32planmodifier/requires_replace_if_test.go +++ b/resource/schema/float32planmodifier/requires_replace_if_test.go @@ -159,6 +159,40 @@ func TestRequiresReplaceIfModifierPlanModifyFloat32(t *testing.T) { RequiresReplace: false, }, }, + "write-only-with-null-config-value": { + request: planmodifier.Float32Request{ + Plan: testPlan(types.Float32Value(1.2)), + PlanValue: types.Float32Null(), + State: testState(types.Float32Value(1.2)), + StateValue: types.Float32Null(), + ConfigValue: types.Float32Null(), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.Float32Request, resp *float32planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Null(), + RequiresReplace: false, + }, + }, + "write-only-with-actual-config-value": { + request: planmodifier.Float32Request{ + Plan: testPlan(types.Float32Value(1.2)), + PlanValue: types.Float32Null(), + State: testState(types.Float32Value(1.2)), + StateValue: types.Float32Null(), + ConfigValue: types.Float32Value(1.1), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.Float32Request, resp *float32planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Null(), + RequiresReplace: true, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/float64planmodifier/requires_replace_if.go b/resource/schema/float64planmodifier/requires_replace_if.go index c0e9af906..3c221cc01 100644 --- a/resource/schema/float64planmodifier/requires_replace_if.go +++ b/resource/schema/float64planmodifier/requires_replace_if.go @@ -59,8 +59,12 @@ func (m requiresReplaceIfModifier) PlanModifyFloat64(ctx context.Context, req pl return } - // Do not replace if the plan and state values are equal. - if req.PlanValue.Equal(req.StateValue) { + // Do not replace if the plan and state values are equal when the attribute is not write-only. + if !req.WriteOnly && req.PlanValue.Equal(req.StateValue) { + return + } + // Even if it's write-only, do not run the modifier if the config value is not specified. + if req.WriteOnly && req.ConfigValue.IsNull() { return } diff --git a/resource/schema/float64planmodifier/requires_replace_if_test.go b/resource/schema/float64planmodifier/requires_replace_if_test.go index 15fec9b42..526124eda 100644 --- a/resource/schema/float64planmodifier/requires_replace_if_test.go +++ b/resource/schema/float64planmodifier/requires_replace_if_test.go @@ -159,6 +159,40 @@ func TestRequiresReplaceIfModifierPlanModifyFloat64(t *testing.T) { RequiresReplace: false, }, }, + "write-only-with-null-config-value": { + request: planmodifier.Float64Request{ + Plan: testPlan(types.Float64Value(1.2)), + PlanValue: types.Float64Null(), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Null(), + ConfigValue: types.Float64Null(), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Null(), + RequiresReplace: false, + }, + }, + "write-only-with-actual-config-value": { + request: planmodifier.Float64Request{ + Plan: testPlan(types.Float64Value(1.2)), + PlanValue: types.Float64Null(), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Null(), + ConfigValue: types.Float64Value(1.1), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Null(), + RequiresReplace: true, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/int32planmodifier/requires_replace_if.go b/resource/schema/int32planmodifier/requires_replace_if.go index 4b5a1a9ef..c9446b25a 100644 --- a/resource/schema/int32planmodifier/requires_replace_if.go +++ b/resource/schema/int32planmodifier/requires_replace_if.go @@ -59,8 +59,12 @@ func (m requiresReplaceIfModifier) PlanModifyInt32(ctx context.Context, req plan return } - // Do not replace if the plan and state values are equal. - if req.PlanValue.Equal(req.StateValue) { + // Do not replace if the plan and state values are equal when the attribute is not write-only. + if !req.WriteOnly && req.PlanValue.Equal(req.StateValue) { + return + } + // Even if it's write-only, do not run the modifier if the config value is not specified. + if req.WriteOnly && req.ConfigValue.IsNull() { return } diff --git a/resource/schema/int32planmodifier/requires_replace_if_test.go b/resource/schema/int32planmodifier/requires_replace_if_test.go index 6bc3cc165..ec7df878f 100644 --- a/resource/schema/int32planmodifier/requires_replace_if_test.go +++ b/resource/schema/int32planmodifier/requires_replace_if_test.go @@ -160,6 +160,40 @@ func TestRequiresReplaceIfModifierPlanModifyInt32(t *testing.T) { RequiresReplace: false, }, }, + "write-only-with-null-config-value": { + request: planmodifier.Int32Request{ + Plan: testPlan(types.Int32Value(1)), + PlanValue: types.Int32Null(), + State: testState(types.Int32Value(1)), + StateValue: types.Int32Null(), + ConfigValue: types.Int32Null(), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.Int32Request, resp *int32planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Null(), + RequiresReplace: false, + }, + }, + "write-only-with-actual-config-value": { + request: planmodifier.Int32Request{ + Plan: testPlan(types.Int32Value(1)), + PlanValue: types.Int32Null(), + State: testState(types.Int32Value(1)), + StateValue: types.Int32Null(), + ConfigValue: types.Int32Value(1), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.Int32Request, resp *int32planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Null(), + RequiresReplace: true, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/int64planmodifier/requires_replace_if.go b/resource/schema/int64planmodifier/requires_replace_if.go index 1d51bf156..6654ec8be 100644 --- a/resource/schema/int64planmodifier/requires_replace_if.go +++ b/resource/schema/int64planmodifier/requires_replace_if.go @@ -59,8 +59,12 @@ func (m requiresReplaceIfModifier) PlanModifyInt64(ctx context.Context, req plan return } - // Do not replace if the plan and state values are equal. - if req.PlanValue.Equal(req.StateValue) { + // Do not replace if the plan and state values are equal when the attribute is not write-only. + if !req.WriteOnly && req.PlanValue.Equal(req.StateValue) { + return + } + // Even if it's write-only, do not run the modifier if the config value is not specified. + if req.WriteOnly && req.ConfigValue.IsNull() { return } diff --git a/resource/schema/int64planmodifier/requires_replace_if_test.go b/resource/schema/int64planmodifier/requires_replace_if_test.go index f19b794b0..1a2e99e63 100644 --- a/resource/schema/int64planmodifier/requires_replace_if_test.go +++ b/resource/schema/int64planmodifier/requires_replace_if_test.go @@ -159,6 +159,40 @@ func TestRequiresReplaceIfModifierPlanModifyInt64(t *testing.T) { RequiresReplace: false, }, }, + "write-only-with-null-config-value": { + request: planmodifier.Int64Request{ + Plan: testPlan(types.Int64Value(1)), + PlanValue: types.Int64Null(), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Null(), + ConfigValue: types.Int64Null(), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Null(), + RequiresReplace: false, + }, + }, + "write-only-with-actual-config-value": { + request: planmodifier.Int64Request{ + Plan: testPlan(types.Int64Value(1)), + PlanValue: types.Int64Null(), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Null(), + ConfigValue: types.Int64Value(2), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Null(), + RequiresReplace: true, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/listplanmodifier/requires_replace_if.go b/resource/schema/listplanmodifier/requires_replace_if.go index 840c5223b..b012a5645 100644 --- a/resource/schema/listplanmodifier/requires_replace_if.go +++ b/resource/schema/listplanmodifier/requires_replace_if.go @@ -59,8 +59,12 @@ func (m requiresReplaceIfModifier) PlanModifyList(ctx context.Context, req planm return } - // Do not replace if the plan and state values are equal. - if req.PlanValue.Equal(req.StateValue) { + // Do not replace if the plan and state values are equal when the attribute is not write-only. + if !req.WriteOnly && req.PlanValue.Equal(req.StateValue) { + return + } + // Even if it's write-only, do not run the modifier if the config value is not specified. + if req.WriteOnly && req.ConfigValue.IsNull() { return } diff --git a/resource/schema/listplanmodifier/requires_replace_if_test.go b/resource/schema/listplanmodifier/requires_replace_if_test.go index 2f6aa2be9..95db0d7dc 100644 --- a/resource/schema/listplanmodifier/requires_replace_if_test.go +++ b/resource/schema/listplanmodifier/requires_replace_if_test.go @@ -162,6 +162,40 @@ func TestRequiresReplaceIfModifierPlanModifyList(t *testing.T) { RequiresReplace: false, }, }, + "write-only-with-null-config-value": { + request: planmodifier.ListRequest{ + Plan: testPlan(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + PlanValue: types.ListNull(types.StringType), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListNull(types.StringType), + ConfigValue: types.ListNull(types.StringType), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListNull(types.StringType), + RequiresReplace: false, + }, + }, + "write-only-with-actual-config-value": { + request: planmodifier.ListRequest{ + Plan: testPlan(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + PlanValue: types.ListNull(types.StringType), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListNull(types.StringType), + ConfigValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("value from config")}), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListNull(types.StringType), + RequiresReplace: true, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/mapplanmodifier/requires_replace_if.go b/resource/schema/mapplanmodifier/requires_replace_if.go index d7e0c8d50..03f3757e7 100644 --- a/resource/schema/mapplanmodifier/requires_replace_if.go +++ b/resource/schema/mapplanmodifier/requires_replace_if.go @@ -59,8 +59,12 @@ func (m requiresReplaceIfModifier) PlanModifyMap(ctx context.Context, req planmo return } - // Do not replace if the plan and state values are equal. - if req.PlanValue.Equal(req.StateValue) { + // Do not replace if the plan and state values are equal when the attribute is not write-only. + if !req.WriteOnly && req.PlanValue.Equal(req.StateValue) { + return + } + // Even if it's write-only, do not run the modifier if the config value is not specified. + if req.WriteOnly && req.ConfigValue.IsNull() { return } diff --git a/resource/schema/mapplanmodifier/requires_replace_if_test.go b/resource/schema/mapplanmodifier/requires_replace_if_test.go index 5edd5ceac..8e02c4833 100644 --- a/resource/schema/mapplanmodifier/requires_replace_if_test.go +++ b/resource/schema/mapplanmodifier/requires_replace_if_test.go @@ -162,6 +162,40 @@ func TestRequiresReplaceIfModifierPlanModifyMap(t *testing.T) { RequiresReplace: false, }, }, + "write-only-with-null-config-value": { + request: planmodifier.MapRequest{ + Plan: testPlan(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + PlanValue: types.MapNull(types.StringType), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapNull(types.StringType), + ConfigValue: types.MapNull(types.StringType), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapNull(types.StringType), + RequiresReplace: false, + }, + }, + "write-only-with-actual-config-value": { + request: planmodifier.MapRequest{ + Plan: testPlan(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + PlanValue: types.MapNull(types.StringType), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapNull(types.StringType), + ConfigValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test value in config")}), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapNull(types.StringType), + RequiresReplace: true, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/numberplanmodifier/requires_replace_if.go b/resource/schema/numberplanmodifier/requires_replace_if.go index b82a51708..f4fafdf85 100644 --- a/resource/schema/numberplanmodifier/requires_replace_if.go +++ b/resource/schema/numberplanmodifier/requires_replace_if.go @@ -59,8 +59,12 @@ func (m requiresReplaceIfModifier) PlanModifyNumber(ctx context.Context, req pla return } - // Do not replace if the plan and state values are equal. - if req.PlanValue.Equal(req.StateValue) { + // Do not replace if the plan and state values are equal when the attribute is not write-only. + if !req.WriteOnly && req.PlanValue.Equal(req.StateValue) { + return + } + // Even if it's write-only, do not run the modifier if the config value is not specified. + if req.WriteOnly && req.ConfigValue.IsNull() { return } diff --git a/resource/schema/numberplanmodifier/requires_replace_if_test.go b/resource/schema/numberplanmodifier/requires_replace_if_test.go index 67272731e..29ce886ba 100644 --- a/resource/schema/numberplanmodifier/requires_replace_if_test.go +++ b/resource/schema/numberplanmodifier/requires_replace_if_test.go @@ -160,6 +160,40 @@ func TestRequiresReplaceIfModifierPlanModifyNumber(t *testing.T) { RequiresReplace: false, }, }, + "write-only-with-null-config-value": { + request: planmodifier.NumberRequest{ + Plan: testPlan(types.NumberValue(big.NewFloat(1.1))), + PlanValue: types.NumberNull(), + State: testState(types.NumberValue(big.NewFloat(1.1))), + StateValue: types.NumberNull(), + ConfigValue: types.NumberNull(), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberNull(), + RequiresReplace: false, + }, + }, + "write-only-with-actual-config-value": { + request: planmodifier.NumberRequest{ + Plan: testPlan(types.NumberValue(big.NewFloat(1.1))), + PlanValue: types.NumberNull(), + State: testState(types.NumberValue(big.NewFloat(1.1))), + StateValue: types.NumberNull(), + ConfigValue: types.NumberValue(big.NewFloat(1.2)), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberNull(), + RequiresReplace: true, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/objectplanmodifier/requires_replace_if.go b/resource/schema/objectplanmodifier/requires_replace_if.go index ea82beb5f..506114356 100644 --- a/resource/schema/objectplanmodifier/requires_replace_if.go +++ b/resource/schema/objectplanmodifier/requires_replace_if.go @@ -59,8 +59,12 @@ func (m requiresReplaceIfModifier) PlanModifyObject(ctx context.Context, req pla return } - // Do not replace if the plan and state values are equal. - if req.PlanValue.Equal(req.StateValue) { + // Do not replace if the plan and state values are equal when the attribute is not write-only. + if !req.WriteOnly && req.PlanValue.Equal(req.StateValue) { + return + } + // Even if it's write-only, do not run the modifier if the config value is not specified. + if req.WriteOnly && req.ConfigValue.IsNull() { return } diff --git a/resource/schema/objectplanmodifier/requires_replace_if_test.go b/resource/schema/objectplanmodifier/requires_replace_if_test.go index 15417ca49..122372ed3 100644 --- a/resource/schema/objectplanmodifier/requires_replace_if_test.go +++ b/resource/schema/objectplanmodifier/requires_replace_if_test.go @@ -162,6 +162,40 @@ func TestRequiresReplaceIfModifierPlanModifyObject(t *testing.T) { RequiresReplace: false, }, }, + "write-only-with-null-config-value": { + request: planmodifier.ObjectRequest{ + Plan: testPlan(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + PlanValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + State: testState(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + StateValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + ConfigValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + RequiresReplace: false, + }, + }, + "write-only-with-actual-config-value": { + request: planmodifier.ObjectRequest{ + Plan: testPlan(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + PlanValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + State: testState(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + StateValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + ConfigValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test value in config")}), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + RequiresReplace: true, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/planmodifier/bool.go b/resource/schema/planmodifier/bool.go index 9b60038b9..87295274b 100644 --- a/resource/schema/planmodifier/bool.go +++ b/resource/schema/planmodifier/bool.go @@ -49,6 +49,9 @@ type BoolRequest struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.Bool + // WriteOnly indicates if the attribute for which the plan modifier is executed is write-only or not. + WriteOnly bool + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/planmodifier/dynamic.go b/resource/schema/planmodifier/dynamic.go index cbf9a7758..1de1f6199 100644 --- a/resource/schema/planmodifier/dynamic.go +++ b/resource/schema/planmodifier/dynamic.go @@ -49,6 +49,9 @@ type DynamicRequest struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.Dynamic + // WriteOnly indicates if the attribute for which the plan modifier is executed is write-only or not. + WriteOnly bool + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/planmodifier/float32.go b/resource/schema/planmodifier/float32.go index 6cb6c4827..8cf1fd684 100644 --- a/resource/schema/planmodifier/float32.go +++ b/resource/schema/planmodifier/float32.go @@ -49,6 +49,9 @@ type Float32Request struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.Float32 + // WriteOnly indicates if the attribute for which the plan modifier is executed is write-only or not. + WriteOnly bool + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/planmodifier/float64.go b/resource/schema/planmodifier/float64.go index 971586b36..08605adfd 100644 --- a/resource/schema/planmodifier/float64.go +++ b/resource/schema/planmodifier/float64.go @@ -49,6 +49,9 @@ type Float64Request struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.Float64 + // WriteOnly indicates if the attribute for which the plan modifier is executed is write-only or not. + WriteOnly bool + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/planmodifier/int32.go b/resource/schema/planmodifier/int32.go index 88136157f..b58a6b1cb 100644 --- a/resource/schema/planmodifier/int32.go +++ b/resource/schema/planmodifier/int32.go @@ -49,6 +49,9 @@ type Int32Request struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.Int32 + // WriteOnly indicates if the attribute for which the plan modifier is executed is write-only or not. + WriteOnly bool + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/planmodifier/int64.go b/resource/schema/planmodifier/int64.go index f65d63b5a..7f7f1efc7 100644 --- a/resource/schema/planmodifier/int64.go +++ b/resource/schema/planmodifier/int64.go @@ -49,6 +49,9 @@ type Int64Request struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.Int64 + // WriteOnly indicates if the attribute for which the plan modifier is executed is write-only or not. + WriteOnly bool + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/planmodifier/list.go b/resource/schema/planmodifier/list.go index dfbff6a81..1fca52661 100644 --- a/resource/schema/planmodifier/list.go +++ b/resource/schema/planmodifier/list.go @@ -49,6 +49,9 @@ type ListRequest struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.List + // WriteOnly indicates if the attribute for which the plan modifier is executed is write-only or not. + WriteOnly bool + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/planmodifier/map.go b/resource/schema/planmodifier/map.go index 5969d5ded..7084261d6 100644 --- a/resource/schema/planmodifier/map.go +++ b/resource/schema/planmodifier/map.go @@ -49,6 +49,9 @@ type MapRequest struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.Map + // WriteOnly indicates if the attribute for which the plan modifier is executed is write-only or not. + WriteOnly bool + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/planmodifier/number.go b/resource/schema/planmodifier/number.go index 44978912a..1b1fa9dc5 100644 --- a/resource/schema/planmodifier/number.go +++ b/resource/schema/planmodifier/number.go @@ -49,6 +49,9 @@ type NumberRequest struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.Number + // WriteOnly indicates if the attribute for which the plan modifier is executed is write-only or not. + WriteOnly bool + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/planmodifier/object.go b/resource/schema/planmodifier/object.go index 1f426d735..76a4060b1 100644 --- a/resource/schema/planmodifier/object.go +++ b/resource/schema/planmodifier/object.go @@ -49,6 +49,9 @@ type ObjectRequest struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.Object + // WriteOnly indicates if the attribute for which the plan modifier is executed is write-only or not. + WriteOnly bool + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/planmodifier/set.go b/resource/schema/planmodifier/set.go index 21e157a78..1e7e173e3 100644 --- a/resource/schema/planmodifier/set.go +++ b/resource/schema/planmodifier/set.go @@ -49,6 +49,8 @@ type SetRequest struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.Set + // Set attributes cannot be configured as write-only so the plan modifiers does not affect these. + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/planmodifier/string.go b/resource/schema/planmodifier/string.go index b8c938c81..2c9a9a16f 100644 --- a/resource/schema/planmodifier/string.go +++ b/resource/schema/planmodifier/string.go @@ -49,6 +49,8 @@ type StringRequest struct { // StateValue contains the value of the attribute for modification from the prior state. StateValue types.String + WriteOnly bool + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to diff --git a/resource/schema/stringplanmodifier/requires_replace_if.go b/resource/schema/stringplanmodifier/requires_replace_if.go index 0afe6cebf..f47740634 100644 --- a/resource/schema/stringplanmodifier/requires_replace_if.go +++ b/resource/schema/stringplanmodifier/requires_replace_if.go @@ -59,8 +59,12 @@ func (m requiresReplaceIfModifier) PlanModifyString(ctx context.Context, req pla return } - // Do not replace if the plan and state values are equal. - if req.PlanValue.Equal(req.StateValue) { + // Do not replace if the plan and state values are equal when the attribute is not write-only. + if !req.WriteOnly && req.PlanValue.Equal(req.StateValue) { + return + } + // Even if it's write-only, do not run the modifier if the config value is not specified. + if req.WriteOnly && req.ConfigValue.IsNull() { return } diff --git a/resource/schema/stringplanmodifier/requires_replace_if_test.go b/resource/schema/stringplanmodifier/requires_replace_if_test.go index 5473f6d75..406c02def 100644 --- a/resource/schema/stringplanmodifier/requires_replace_if_test.go +++ b/resource/schema/stringplanmodifier/requires_replace_if_test.go @@ -159,6 +159,40 @@ func TestRequiresReplaceIfModifierPlanModifyString(t *testing.T) { RequiresReplace: false, }, }, + "write-only-with-null-config-value": { + request: planmodifier.StringRequest{ + Plan: testPlan(types.StringValue("test")), + PlanValue: types.StringNull(), + State: testState(types.StringValue("test")), + StateValue: types.StringNull(), + ConfigValue: types.StringNull(), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringNull(), + RequiresReplace: false, + }, + }, + "write-only-with-actual-config-value": { + request: planmodifier.StringRequest{ + Plan: testPlan(types.StringValue("test")), + PlanValue: types.StringNull(), + State: testState(types.StringValue("test")), + StateValue: types.StringNull(), + ConfigValue: types.StringValue("test config value"), + WriteOnly: true, + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringNull(), + RequiresReplace: true, + }, + }, } for name, testCase := range testCases {