diff --git a/.changelog/565.txt b/.changelog/565.txt new file mode 100644 index 000000000..b5d84fb8f --- /dev/null +++ b/.changelog/565.txt @@ -0,0 +1,15 @@ +```release-note:feature +resource/schema: New packages, such as `stringplanmodifier` which contain type-specific schema plan modifier implementations +``` + +```release-note:note +resource: The `RequiresReplace()` plan modifier has been deprecated. Use a type-specific plan modifier instead, such as `resource/schema/stringplanmodifier.RequiresReplace()` or `resource/schema/stringplanmodifier.RequiresReplaceIfConfigured()` +``` + +```release-note:note +resource: The `RequiresReplaceIf()` plan modifier has been deprecated. Use a type-specific plan modifier instead, such as `resource/schema/stringplanmodifier.RequiresReplaceIf()` +``` + +```release-note:note +resource: The `UseStateForUnknown()` plan modifier has been deprecated. Use a type-specific plan modifier instead, such as `resource/schema/stringplanmodifier.UseStateForUnknown()` +``` diff --git a/resource/schema/boolplanmodifier/doc.go b/resource/schema/boolplanmodifier/doc.go new file mode 100644 index 000000000..0bfd4c4df --- /dev/null +++ b/resource/schema/boolplanmodifier/doc.go @@ -0,0 +1,2 @@ +// Package boolplanmodifier provides plan modifiers for types.Bool attributes. +package boolplanmodifier diff --git a/resource/schema/boolplanmodifier/requires_replace.go b/resource/schema/boolplanmodifier/requires_replace.go new file mode 100644 index 000000000..57d1aae45 --- /dev/null +++ b/resource/schema/boolplanmodifier/requires_replace.go @@ -0,0 +1,27 @@ +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplace returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// +// Use RequiresReplaceIfConfigured if the resource replacement should +// only occur if there is a configuration value (ignore unconfigured drift +// detection changes). Use RequiresReplaceIf if the resource replacement +// should check provider-defined conditional logic. +func RequiresReplace() planmodifier.Bool { + return RequiresReplaceIf( + func(_ context.Context, _ planmodifier.BoolRequest, resp *RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/boolplanmodifier/requires_replace_if.go b/resource/schema/boolplanmodifier/requires_replace_if.go new file mode 100644 index 000000000..6ce1ed623 --- /dev/null +++ b/resource/schema/boolplanmodifier/requires_replace_if.go @@ -0,0 +1,70 @@ +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIf returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The given function returns true. Returning false will not unset any +// prior resource replacement. +// +// Use RequiresReplace if the resource replacement should always occur on value +// changes. Use RequiresReplaceIfConfigured if the resource replacement should +// occur on value changes, but only if there is a configuration value (ignore +// unconfigured drift detection changes). +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) planmodifier.Bool { + return requiresReplaceIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// requiresReplaceIfModifier is an plan modifier that sets RequiresReplace +// on the attribute if a given function is true. +type requiresReplaceIfModifier struct { + ifFunc RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m requiresReplaceIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m requiresReplaceIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyBool implements the plan modification logic. +func (m requiresReplaceIfModifier) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return + } + + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + // Do not replace if the plan and state values are equal. + if req.PlanValue.Equal(req.StateValue) { + return + } + + ifFuncResp := &RequiresReplaceIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + resp.RequiresReplace = ifFuncResp.RequiresReplace +} diff --git a/resource/schema/boolplanmodifier/requires_replace_if_configured.go b/resource/schema/boolplanmodifier/requires_replace_if_configured.go new file mode 100644 index 000000000..412d60caf --- /dev/null +++ b/resource/schema/boolplanmodifier/requires_replace_if_configured.go @@ -0,0 +1,31 @@ +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfConfigured returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The configuration value is not null. +// +// Use RequiresReplace if the resource replacement should occur regardless of +// the presence of a configuration value. Use RequiresReplaceIf if the resource +// replacement should check provider-defined conditional logic. +func RequiresReplaceIfConfigured() planmodifier.Bool { + return RequiresReplaceIf( + func(_ context.Context, req planmodifier.BoolRequest, resp *RequiresReplaceIfFuncResponse) { + if req.ConfigValue.IsNull() { + return + } + + resp.RequiresReplace = true + }, + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/boolplanmodifier/requires_replace_if_configured_test.go b/resource/schema/boolplanmodifier/requires_replace_if_configured_test.go new file mode 100644 index 000000000..52b36e3a3 --- /dev/null +++ b/resource/schema/boolplanmodifier/requires_replace_if_configured_test.go @@ -0,0 +1,167 @@ +package boolplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfConfiguredModifierPlanModifyBool(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.BoolAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Bool) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Bool) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.BoolRequest + expected *planmodifier.BoolResponse + }{ + "state-null": { + // resource creation + request: planmodifier.BoolRequest{ + ConfigValue: types.BoolValue(true), + Plan: testPlan(types.BoolValue(true)), + PlanValue: types.BoolValue(true), + State: nullState, + StateValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(true), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.BoolRequest{ + ConfigValue: types.BoolNull(), + Plan: nullPlan, + PlanValue: types.BoolNull(), + State: testState(types.BoolValue(true)), + StateValue: types.BoolValue(true), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolNull(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-configured": { + request: planmodifier.BoolRequest{ + ConfigValue: types.BoolValue(false), + Plan: testPlan(types.BoolValue(false)), + PlanValue: types.BoolValue(false), + State: testState(types.BoolValue(true)), + StateValue: types.BoolValue(true), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(false), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-different-unconfigured": { + request: planmodifier.BoolRequest{ + ConfigValue: types.BoolNull(), + Plan: testPlan(types.BoolValue(false)), + PlanValue: types.BoolValue(false), + State: testState(types.BoolValue(true)), + StateValue: types.BoolValue(true), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(false), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.BoolRequest{ + ConfigValue: types.BoolValue(true), + Plan: testPlan(types.BoolValue(true)), + PlanValue: types.BoolValue(true), + State: testState(types.BoolValue(true)), + StateValue: types.BoolValue(true), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(true), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.BoolResponse{ + PlanValue: testCase.request.PlanValue, + } + + boolplanmodifier.RequiresReplaceIfConfigured().PlanModifyBool(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/boolplanmodifier/requires_replace_if_func.go b/resource/schema/boolplanmodifier/requires_replace_if_func.go new file mode 100644 index 000000000..480d4e0ba --- /dev/null +++ b/resource/schema/boolplanmodifier/requires_replace_if_func.go @@ -0,0 +1,22 @@ +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(context.Context, planmodifier.BoolRequest, *RequiresReplaceIfFuncResponse) + +// RequiresReplaceIfFuncResponse is the response type for a RequiresReplaceIfFunc. +type RequiresReplaceIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // RequiresReplace should be enabled if the resource should be replaced. + RequiresReplace bool +} diff --git a/resource/schema/boolplanmodifier/requires_replace_if_test.go b/resource/schema/boolplanmodifier/requires_replace_if_test.go new file mode 100644 index 000000000..0580edf8b --- /dev/null +++ b/resource/schema/boolplanmodifier/requires_replace_if_test.go @@ -0,0 +1,178 @@ +package boolplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfModifierPlanModifyBool(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.BoolAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Bool) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Bool) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.BoolRequest + ifFunc boolplanmodifier.RequiresReplaceIfFunc + expected *planmodifier.BoolResponse + }{ + "state-null": { + // resource creation + request: planmodifier.BoolRequest{ + Plan: testPlan(types.BoolUnknown()), + PlanValue: types.BoolUnknown(), + State: nullState, + StateValue: types.BoolNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.BoolRequest{ + Plan: nullPlan, + PlanValue: types.BoolNull(), + State: testState(types.BoolValue(true)), + StateValue: types.BoolValue(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, + }, + }, + "planvalue-statevalue-different-if-false": { + request: planmodifier.BoolRequest{ + Plan: testPlan(types.BoolValue(false)), + PlanValue: types.BoolValue(false), + State: testState(types.BoolValue(true)), + StateValue: types.BoolValue(true), + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = false // no change + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(false), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-true": { + request: planmodifier.BoolRequest{ + Plan: testPlan(types.BoolValue(false)), + PlanValue: types.BoolValue(false), + State: testState(types.BoolValue(true)), + StateValue: types.BoolValue(true), + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should reach here + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(false), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.BoolRequest{ + Plan: testPlan(types.BoolValue(true)), + PlanValue: types.BoolValue(true), + State: testState(types.BoolValue(true)), + StateValue: types.BoolValue(true), + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(true), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.BoolResponse{ + PlanValue: testCase.request.PlanValue, + } + + boolplanmodifier.RequiresReplaceIf(testCase.ifFunc, "test", "test").PlanModifyBool(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/boolplanmodifier/requires_replace_test.go b/resource/schema/boolplanmodifier/requires_replace_test.go new file mode 100644 index 000000000..0bcbcd29f --- /dev/null +++ b/resource/schema/boolplanmodifier/requires_replace_test.go @@ -0,0 +1,150 @@ +package boolplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceModifierPlanModifyBool(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.BoolAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Bool) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Bool) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.BoolRequest + expected *planmodifier.BoolResponse + }{ + "state-null": { + // resource creation + request: planmodifier.BoolRequest{ + Plan: testPlan(types.BoolUnknown()), + PlanValue: types.BoolUnknown(), + State: nullState, + StateValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.BoolRequest{ + Plan: nullPlan, + PlanValue: types.BoolNull(), + State: testState(types.BoolValue(true)), + StateValue: types.BoolValue(true), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolNull(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different": { + request: planmodifier.BoolRequest{ + Plan: testPlan(types.BoolValue(false)), + PlanValue: types.BoolValue(false), + State: testState(types.BoolValue(true)), + StateValue: types.BoolValue(true), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(false), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.BoolRequest{ + Plan: testPlan(types.BoolValue(true)), + PlanValue: types.BoolValue(true), + State: testState(types.BoolValue(true)), + StateValue: types.BoolValue(true), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(true), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.BoolResponse{ + PlanValue: testCase.request.PlanValue, + } + + boolplanmodifier.RequiresReplace().PlanModifyBool(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/boolplanmodifier/use_state_for_unknown.go b/resource/schema/boolplanmodifier/use_state_for_unknown.go new file mode 100644 index 000000000..4edf09555 --- /dev/null +++ b/resource/schema/boolplanmodifier/use_state_for_unknown.go @@ -0,0 +1,52 @@ +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknown returns a plan modifier that copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value. +func UseStateForUnknown() planmodifier.Bool { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the plan modifier. +type useStateForUnknownModifier struct{} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// PlanModifyBool implements the plan modification logic. +func (m useStateForUnknownModifier) PlanModifyBool(_ context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/boolplanmodifier/use_state_for_unknown_test.go b/resource/schema/boolplanmodifier/use_state_for_unknown_test.go new file mode 100644 index 000000000..818b5027c --- /dev/null +++ b/resource/schema/boolplanmodifier/use_state_for_unknown_test.go @@ -0,0 +1,95 @@ +package boolplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUseStateForUnknownModifierPlanModifyBool(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.BoolRequest + expected *planmodifier.BoolResponse + }{ + "null-state": { + // when we first create the resource, use the unknown + // value + request: planmodifier.BoolRequest{ + StateValue: types.BoolNull(), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.BoolRequest{ + StateValue: types.BoolValue(false), + PlanValue: types.BoolValue(true), + ConfigValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(true), + }, + }, + "non-null-state-unknown-plan": { + // this is the situation we want to preserve the state + // in + request: planmodifier.BoolRequest{ + StateValue: types.BoolValue(true), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(true), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.BoolRequest{ + StateValue: types.BoolValue(true), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolUnknown(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown(), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.BoolResponse{ + PlanValue: testCase.request.PlanValue, + } + + boolplanmodifier.UseStateForUnknown().PlanModifyBool(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/float64planmodifier/doc.go b/resource/schema/float64planmodifier/doc.go new file mode 100644 index 000000000..d1c4f9021 --- /dev/null +++ b/resource/schema/float64planmodifier/doc.go @@ -0,0 +1,2 @@ +// Package float64planmodifier provides plan modifiers for types.Float64 attributes. +package float64planmodifier diff --git a/resource/schema/float64planmodifier/requires_replace.go b/resource/schema/float64planmodifier/requires_replace.go new file mode 100644 index 000000000..d990df517 --- /dev/null +++ b/resource/schema/float64planmodifier/requires_replace.go @@ -0,0 +1,27 @@ +package float64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplace returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// +// Use RequiresReplaceIfConfigured if the resource replacement should +// only occur if there is a configuration value (ignore unconfigured drift +// detection changes). Use RequiresReplaceIf if the resource replacement +// should check provider-defined conditional logic. +func RequiresReplace() planmodifier.Float64 { + return RequiresReplaceIf( + func(_ context.Context, _ planmodifier.Float64Request, resp *RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/float64planmodifier/requires_replace_if.go b/resource/schema/float64planmodifier/requires_replace_if.go new file mode 100644 index 000000000..cc682f577 --- /dev/null +++ b/resource/schema/float64planmodifier/requires_replace_if.go @@ -0,0 +1,70 @@ +package float64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIf returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The given function returns true. Returning false will not unset any +// prior resource replacement. +// +// Use RequiresReplace if the resource replacement should always occur on value +// changes. Use RequiresReplaceIfConfigured if the resource replacement should +// occur on value changes, but only if there is a configuration value (ignore +// unconfigured drift detection changes). +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) planmodifier.Float64 { + return requiresReplaceIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// requiresReplaceIfModifier is an plan modifier that sets RequiresReplace +// on the attribute if a given function is true. +type requiresReplaceIfModifier struct { + ifFunc RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m requiresReplaceIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m requiresReplaceIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyFloat64 implements the plan modification logic. +func (m requiresReplaceIfModifier) PlanModifyFloat64(ctx context.Context, req planmodifier.Float64Request, resp *planmodifier.Float64Response) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return + } + + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + // Do not replace if the plan and state values are equal. + if req.PlanValue.Equal(req.StateValue) { + return + } + + ifFuncResp := &RequiresReplaceIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + resp.RequiresReplace = ifFuncResp.RequiresReplace +} diff --git a/resource/schema/float64planmodifier/requires_replace_if_configured.go b/resource/schema/float64planmodifier/requires_replace_if_configured.go new file mode 100644 index 000000000..704164233 --- /dev/null +++ b/resource/schema/float64planmodifier/requires_replace_if_configured.go @@ -0,0 +1,31 @@ +package float64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfConfigured returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The configuration value is not null. +// +// Use RequiresReplace if the resource replacement should occur regardless of +// the presence of a configuration value. Use RequiresReplaceIf if the resource +// replacement should check provider-defined conditional logic. +func RequiresReplaceIfConfigured() planmodifier.Float64 { + return RequiresReplaceIf( + func(_ context.Context, req planmodifier.Float64Request, resp *RequiresReplaceIfFuncResponse) { + if req.ConfigValue.IsNull() { + return + } + + resp.RequiresReplace = true + }, + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/float64planmodifier/requires_replace_if_configured_test.go b/resource/schema/float64planmodifier/requires_replace_if_configured_test.go new file mode 100644 index 000000000..e0219c11c --- /dev/null +++ b/resource/schema/float64planmodifier/requires_replace_if_configured_test.go @@ -0,0 +1,167 @@ +package float64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/float64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfConfiguredModifierPlanModifyFloat64(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.Float64Attribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Float64) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Float64) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.Float64Request + expected *planmodifier.Float64Response + }{ + "state-null": { + // resource creation + request: planmodifier.Float64Request{ + ConfigValue: types.Float64Value(1.2), + Plan: testPlan(types.Float64Value(1.2)), + PlanValue: types.Float64Value(1.2), + State: nullState, + StateValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(1.2), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.Float64Request{ + ConfigValue: types.Float64Null(), + Plan: nullPlan, + PlanValue: types.Float64Null(), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Value(1.2), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Null(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-configured": { + request: planmodifier.Float64Request{ + ConfigValue: types.Float64Value(2.4), + Plan: testPlan(types.Float64Value(2.4)), + PlanValue: types.Float64Value(2.4), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Value(1.2), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(2.4), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-different-unconfigured": { + request: planmodifier.Float64Request{ + ConfigValue: types.Float64Null(), + Plan: testPlan(types.Float64Value(2.4)), + PlanValue: types.Float64Value(2.4), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Value(1.2), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(2.4), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.Float64Request{ + ConfigValue: types.Float64Value(1.2), + Plan: testPlan(types.Float64Value(1.2)), + PlanValue: types.Float64Value(1.2), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Value(1.2), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(1.2), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float64Response{ + PlanValue: testCase.request.PlanValue, + } + + float64planmodifier.RequiresReplaceIfConfigured().PlanModifyFloat64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/float64planmodifier/requires_replace_if_func.go b/resource/schema/float64planmodifier/requires_replace_if_func.go new file mode 100644 index 000000000..8a6b6125c --- /dev/null +++ b/resource/schema/float64planmodifier/requires_replace_if_func.go @@ -0,0 +1,22 @@ +package float64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(context.Context, planmodifier.Float64Request, *RequiresReplaceIfFuncResponse) + +// RequiresReplaceIfFuncResponse is the response type for a RequiresReplaceIfFunc. +type RequiresReplaceIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // RequiresReplace should be enabled if the resource should be replaced. + RequiresReplace bool +} diff --git a/resource/schema/float64planmodifier/requires_replace_if_test.go b/resource/schema/float64planmodifier/requires_replace_if_test.go new file mode 100644 index 000000000..ac8d29807 --- /dev/null +++ b/resource/schema/float64planmodifier/requires_replace_if_test.go @@ -0,0 +1,178 @@ +package float64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/float64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfModifierPlanModifyFloat64(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.Float64Attribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Float64) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Float64) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.Float64Request + ifFunc float64planmodifier.RequiresReplaceIfFunc + expected *planmodifier.Float64Response + }{ + "state-null": { + // resource creation + request: planmodifier.Float64Request{ + Plan: testPlan(types.Float64Unknown()), + PlanValue: types.Float64Unknown(), + State: nullState, + StateValue: types.Float64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.Float64Request{ + Plan: nullPlan, + PlanValue: types.Float64Null(), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Value(1.2), + }, + 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, + }, + }, + "planvalue-statevalue-different-if-false": { + request: planmodifier.Float64Request{ + Plan: testPlan(types.Float64Value(2.4)), + PlanValue: types.Float64Value(2.4), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Value(1.2), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = false // no change + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(2.4), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-true": { + request: planmodifier.Float64Request{ + Plan: testPlan(types.Float64Value(2.4)), + PlanValue: types.Float64Value(2.4), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Value(1.2), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should reach here + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(2.4), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.Float64Request{ + Plan: testPlan(types.Float64Value(1.2)), + PlanValue: types.Float64Value(1.2), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Value(1.2), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(1.2), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float64Response{ + PlanValue: testCase.request.PlanValue, + } + + float64planmodifier.RequiresReplaceIf(testCase.ifFunc, "test", "test").PlanModifyFloat64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/float64planmodifier/requires_replace_test.go b/resource/schema/float64planmodifier/requires_replace_test.go new file mode 100644 index 000000000..34cc74af7 --- /dev/null +++ b/resource/schema/float64planmodifier/requires_replace_test.go @@ -0,0 +1,150 @@ +package float64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/float64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceModifierPlanModifyFloat64(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.Float64Attribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Float64) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Float64) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.Float64Request + expected *planmodifier.Float64Response + }{ + "state-null": { + // resource creation + request: planmodifier.Float64Request{ + Plan: testPlan(types.Float64Unknown()), + PlanValue: types.Float64Unknown(), + State: nullState, + StateValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.Float64Request{ + Plan: nullPlan, + PlanValue: types.Float64Null(), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Value(1.2), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Null(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different": { + request: planmodifier.Float64Request{ + Plan: testPlan(types.Float64Value(2.4)), + PlanValue: types.Float64Value(2.4), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Value(1.2), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(2.4), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.Float64Request{ + Plan: testPlan(types.Float64Value(1.2)), + PlanValue: types.Float64Value(1.2), + State: testState(types.Float64Value(1.2)), + StateValue: types.Float64Value(1.2), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(1.2), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float64Response{ + PlanValue: testCase.request.PlanValue, + } + + float64planmodifier.RequiresReplace().PlanModifyFloat64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/float64planmodifier/use_state_for_unknown.go b/resource/schema/float64planmodifier/use_state_for_unknown.go new file mode 100644 index 000000000..6d39ac81b --- /dev/null +++ b/resource/schema/float64planmodifier/use_state_for_unknown.go @@ -0,0 +1,52 @@ +package float64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknown returns a plan modifier that copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value. +func UseStateForUnknown() planmodifier.Float64 { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the plan modifier. +type useStateForUnknownModifier struct{} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// PlanModifyFloat64 implements the plan modification logic. +func (m useStateForUnknownModifier) PlanModifyFloat64(_ context.Context, req planmodifier.Float64Request, resp *planmodifier.Float64Response) { + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/float64planmodifier/use_state_for_unknown_test.go b/resource/schema/float64planmodifier/use_state_for_unknown_test.go new file mode 100644 index 000000000..e5551600f --- /dev/null +++ b/resource/schema/float64planmodifier/use_state_for_unknown_test.go @@ -0,0 +1,95 @@ +package float64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/float64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUseStateForUnknownModifierPlanModifyFloat64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Float64Request + expected *planmodifier.Float64Response + }{ + "null-state": { + // when we first create the resource, use the unknown + // value + request: planmodifier.Float64Request{ + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(2.4), + PlanValue: types.Float64Value(1.2), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(1.2), + }, + }, + "non-null-state-unknown-plan": { + // this is the situation we want to preserve the state + // in + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(1.2), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(1.2), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(1.2), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Unknown(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float64Response{ + PlanValue: testCase.request.PlanValue, + } + + float64planmodifier.UseStateForUnknown().PlanModifyFloat64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/int64planmodifier/doc.go b/resource/schema/int64planmodifier/doc.go new file mode 100644 index 000000000..29494967a --- /dev/null +++ b/resource/schema/int64planmodifier/doc.go @@ -0,0 +1,2 @@ +// Package int64planmodifier provides plan modifiers for types.Int64 attributes. +package int64planmodifier diff --git a/resource/schema/int64planmodifier/requires_replace.go b/resource/schema/int64planmodifier/requires_replace.go new file mode 100644 index 000000000..6de2db2a1 --- /dev/null +++ b/resource/schema/int64planmodifier/requires_replace.go @@ -0,0 +1,27 @@ +package int64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplace returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// +// Use RequiresReplaceIfConfigured if the resource replacement should +// only occur if there is a configuration value (ignore unconfigured drift +// detection changes). Use RequiresReplaceIf if the resource replacement +// should check provider-defined conditional logic. +func RequiresReplace() planmodifier.Int64 { + return RequiresReplaceIf( + func(_ context.Context, _ planmodifier.Int64Request, resp *RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/int64planmodifier/requires_replace_if.go b/resource/schema/int64planmodifier/requires_replace_if.go new file mode 100644 index 000000000..709c4b487 --- /dev/null +++ b/resource/schema/int64planmodifier/requires_replace_if.go @@ -0,0 +1,70 @@ +package int64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIf returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The given function returns true. Returning false will not unset any +// prior resource replacement. +// +// Use RequiresReplace if the resource replacement should always occur on value +// changes. Use RequiresReplaceIfConfigured if the resource replacement should +// occur on value changes, but only if there is a configuration value (ignore +// unconfigured drift detection changes). +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) planmodifier.Int64 { + return requiresReplaceIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// requiresReplaceIfModifier is an plan modifier that sets RequiresReplace +// on the attribute if a given function is true. +type requiresReplaceIfModifier struct { + ifFunc RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m requiresReplaceIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m requiresReplaceIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyInt64 implements the plan modification logic. +func (m requiresReplaceIfModifier) PlanModifyInt64(ctx context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return + } + + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + // Do not replace if the plan and state values are equal. + if req.PlanValue.Equal(req.StateValue) { + return + } + + ifFuncResp := &RequiresReplaceIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + resp.RequiresReplace = ifFuncResp.RequiresReplace +} diff --git a/resource/schema/int64planmodifier/requires_replace_if_configured.go b/resource/schema/int64planmodifier/requires_replace_if_configured.go new file mode 100644 index 000000000..cc0090e13 --- /dev/null +++ b/resource/schema/int64planmodifier/requires_replace_if_configured.go @@ -0,0 +1,31 @@ +package int64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfConfigured returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The configuration value is not null. +// +// Use RequiresReplace if the resource replacement should occur regardless of +// the presence of a configuration value. Use RequiresReplaceIf if the resource +// replacement should check provider-defined conditional logic. +func RequiresReplaceIfConfigured() planmodifier.Int64 { + return RequiresReplaceIf( + func(_ context.Context, req planmodifier.Int64Request, resp *RequiresReplaceIfFuncResponse) { + if req.ConfigValue.IsNull() { + return + } + + resp.RequiresReplace = true + }, + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/int64planmodifier/requires_replace_if_configured_test.go b/resource/schema/int64planmodifier/requires_replace_if_configured_test.go new file mode 100644 index 000000000..3d258e43f --- /dev/null +++ b/resource/schema/int64planmodifier/requires_replace_if_configured_test.go @@ -0,0 +1,167 @@ +package int64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfConfiguredModifierPlanModifyInt64(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.Int64Attribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Int64) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Int64) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.Int64Request + expected *planmodifier.Int64Response + }{ + "state-null": { + // resource creation + request: planmodifier.Int64Request{ + ConfigValue: types.Int64Value(1), + Plan: testPlan(types.Int64Value(1)), + PlanValue: types.Int64Value(1), + State: nullState, + StateValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(1), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.Int64Request{ + ConfigValue: types.Int64Null(), + Plan: nullPlan, + PlanValue: types.Int64Null(), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Value(1), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Null(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-configured": { + request: planmodifier.Int64Request{ + ConfigValue: types.Int64Value(2), + Plan: testPlan(types.Int64Value(2)), + PlanValue: types.Int64Value(2), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Value(1), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(2), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-different-unconfigured": { + request: planmodifier.Int64Request{ + ConfigValue: types.Int64Null(), + Plan: testPlan(types.Int64Value(2)), + PlanValue: types.Int64Value(2), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Value(1), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(2), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.Int64Request{ + ConfigValue: types.Int64Value(1), + Plan: testPlan(types.Int64Value(1)), + PlanValue: types.Int64Value(1), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Value(1), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(1), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int64Response{ + PlanValue: testCase.request.PlanValue, + } + + int64planmodifier.RequiresReplaceIfConfigured().PlanModifyInt64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/int64planmodifier/requires_replace_if_func.go b/resource/schema/int64planmodifier/requires_replace_if_func.go new file mode 100644 index 000000000..08c6f726e --- /dev/null +++ b/resource/schema/int64planmodifier/requires_replace_if_func.go @@ -0,0 +1,22 @@ +package int64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(context.Context, planmodifier.Int64Request, *RequiresReplaceIfFuncResponse) + +// RequiresReplaceIfFuncResponse is the response type for a RequiresReplaceIfFunc. +type RequiresReplaceIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // RequiresReplace should be enabled if the resource should be replaced. + RequiresReplace bool +} diff --git a/resource/schema/int64planmodifier/requires_replace_if_test.go b/resource/schema/int64planmodifier/requires_replace_if_test.go new file mode 100644 index 000000000..02cd2db3f --- /dev/null +++ b/resource/schema/int64planmodifier/requires_replace_if_test.go @@ -0,0 +1,178 @@ +package int64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfModifierPlanModifyInt64(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.Int64Attribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Int64) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Int64) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.Int64Request + ifFunc int64planmodifier.RequiresReplaceIfFunc + expected *planmodifier.Int64Response + }{ + "state-null": { + // resource creation + request: planmodifier.Int64Request{ + Plan: testPlan(types.Int64Unknown()), + PlanValue: types.Int64Unknown(), + State: nullState, + StateValue: types.Int64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.Int64Request{ + Plan: nullPlan, + PlanValue: types.Int64Null(), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Value(1), + }, + 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, + }, + }, + "planvalue-statevalue-different-if-false": { + request: planmodifier.Int64Request{ + Plan: testPlan(types.Int64Value(2)), + PlanValue: types.Int64Value(2), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Value(1), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = false // no change + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(2), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-true": { + request: planmodifier.Int64Request{ + Plan: testPlan(types.Int64Value(2)), + PlanValue: types.Int64Value(2), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Value(1), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should reach here + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(2), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.Int64Request{ + Plan: testPlan(types.Int64Value(1)), + PlanValue: types.Int64Value(1), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Value(1), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(1), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int64Response{ + PlanValue: testCase.request.PlanValue, + } + + int64planmodifier.RequiresReplaceIf(testCase.ifFunc, "test", "test").PlanModifyInt64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/int64planmodifier/requires_replace_test.go b/resource/schema/int64planmodifier/requires_replace_test.go new file mode 100644 index 000000000..1034d9ab9 --- /dev/null +++ b/resource/schema/int64planmodifier/requires_replace_test.go @@ -0,0 +1,150 @@ +package int64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceModifierPlanModifyInt64(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.Int64Attribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Int64) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Int64) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.Int64Request + expected *planmodifier.Int64Response + }{ + "state-null": { + // resource creation + request: planmodifier.Int64Request{ + Plan: testPlan(types.Int64Unknown()), + PlanValue: types.Int64Unknown(), + State: nullState, + StateValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.Int64Request{ + Plan: nullPlan, + PlanValue: types.Int64Null(), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Value(1), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Null(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different": { + request: planmodifier.Int64Request{ + Plan: testPlan(types.Int64Value(2)), + PlanValue: types.Int64Value(2), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Value(1), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(2), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.Int64Request{ + Plan: testPlan(types.Int64Value(1)), + PlanValue: types.Int64Value(1), + State: testState(types.Int64Value(1)), + StateValue: types.Int64Value(1), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(1), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int64Response{ + PlanValue: testCase.request.PlanValue, + } + + int64planmodifier.RequiresReplace().PlanModifyInt64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/int64planmodifier/use_state_for_unknown.go b/resource/schema/int64planmodifier/use_state_for_unknown.go new file mode 100644 index 000000000..850c054e2 --- /dev/null +++ b/resource/schema/int64planmodifier/use_state_for_unknown.go @@ -0,0 +1,52 @@ +package int64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknown returns a plan modifier that copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value. +func UseStateForUnknown() planmodifier.Int64 { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the plan modifier. +type useStateForUnknownModifier struct{} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// PlanModifyInt64 implements the plan modification logic. +func (m useStateForUnknownModifier) PlanModifyInt64(_ context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/int64planmodifier/use_state_for_unknown_test.go b/resource/schema/int64planmodifier/use_state_for_unknown_test.go new file mode 100644 index 000000000..c47cf6098 --- /dev/null +++ b/resource/schema/int64planmodifier/use_state_for_unknown_test.go @@ -0,0 +1,95 @@ +package int64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUseStateForUnknownModifierPlanModifyInt64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Int64Request + expected *planmodifier.Int64Response + }{ + "null-state": { + // when we first create the resource, use the unknown + // value + request: planmodifier.Int64Request{ + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(2), + PlanValue: types.Int64Value(1), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(1), + }, + }, + "non-null-state-unknown-plan": { + // this is the situation we want to preserve the state + // in + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(1), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(1), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(1), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Unknown(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int64Response{ + PlanValue: testCase.request.PlanValue, + } + + int64planmodifier.UseStateForUnknown().PlanModifyInt64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/listplanmodifier/doc.go b/resource/schema/listplanmodifier/doc.go new file mode 100644 index 000000000..bd85796b6 --- /dev/null +++ b/resource/schema/listplanmodifier/doc.go @@ -0,0 +1,2 @@ +// Package listplanmodifier provides plan modifiers for types.List attributes. +package listplanmodifier diff --git a/resource/schema/listplanmodifier/requires_replace.go b/resource/schema/listplanmodifier/requires_replace.go new file mode 100644 index 000000000..1e41d1360 --- /dev/null +++ b/resource/schema/listplanmodifier/requires_replace.go @@ -0,0 +1,27 @@ +package listplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplace returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// +// Use RequiresReplaceIfConfigured if the resource replacement should +// only occur if there is a configuration value (ignore unconfigured drift +// detection changes). Use RequiresReplaceIf if the resource replacement +// should check provider-defined conditional logic. +func RequiresReplace() planmodifier.List { + return RequiresReplaceIf( + func(_ context.Context, _ planmodifier.ListRequest, resp *RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/listplanmodifier/requires_replace_if.go b/resource/schema/listplanmodifier/requires_replace_if.go new file mode 100644 index 000000000..fd740fff3 --- /dev/null +++ b/resource/schema/listplanmodifier/requires_replace_if.go @@ -0,0 +1,70 @@ +package listplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIf returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The given function returns true. Returning false will not unset any +// prior resource replacement. +// +// Use RequiresReplace if the resource replacement should always occur on value +// changes. Use RequiresReplaceIfConfigured if the resource replacement should +// occur on value changes, but only if there is a configuration value (ignore +// unconfigured drift detection changes). +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) planmodifier.List { + return requiresReplaceIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// requiresReplaceIfModifier is an plan modifier that sets RequiresReplace +// on the attribute if a given function is true. +type requiresReplaceIfModifier struct { + ifFunc RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m requiresReplaceIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m requiresReplaceIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyList implements the plan modification logic. +func (m requiresReplaceIfModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return + } + + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + // Do not replace if the plan and state values are equal. + if req.PlanValue.Equal(req.StateValue) { + return + } + + ifFuncResp := &RequiresReplaceIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + resp.RequiresReplace = ifFuncResp.RequiresReplace +} diff --git a/resource/schema/listplanmodifier/requires_replace_if_configured.go b/resource/schema/listplanmodifier/requires_replace_if_configured.go new file mode 100644 index 000000000..461c3b49f --- /dev/null +++ b/resource/schema/listplanmodifier/requires_replace_if_configured.go @@ -0,0 +1,31 @@ +package listplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfConfigured returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The configuration value is not null. +// +// Use RequiresReplace if the resource replacement should occur regardless of +// the presence of a configuration value. Use RequiresReplaceIf if the resource +// replacement should check provider-defined conditional logic. +func RequiresReplaceIfConfigured() planmodifier.List { + return RequiresReplaceIf( + func(_ context.Context, req planmodifier.ListRequest, resp *RequiresReplaceIfFuncResponse) { + if req.ConfigValue.IsNull() { + return + } + + resp.RequiresReplace = true + }, + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/listplanmodifier/requires_replace_if_configured_test.go b/resource/schema/listplanmodifier/requires_replace_if_configured_test.go new file mode 100644 index 000000000..e87fc99b6 --- /dev/null +++ b/resource/schema/listplanmodifier/requires_replace_if_configured_test.go @@ -0,0 +1,170 @@ +package listplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfConfiguredModifierPlanModifyList(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.ListAttribute{ + ElementType: types.StringType, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.List) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.List) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.ListRequest + expected *planmodifier.ListResponse + }{ + "state-null": { + // resource creation + request: planmodifier.ListRequest{ + ConfigValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + Plan: testPlan(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + State: nullState, + StateValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.ListRequest{ + ConfigValue: types.ListNull(types.StringType), + Plan: nullPlan, + PlanValue: types.ListNull(types.StringType), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListNull(types.StringType), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-configured": { + request: planmodifier.ListRequest{ + ConfigValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + Plan: testPlan(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")})), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-different-unconfigured": { + request: planmodifier.ListRequest{ + ConfigValue: types.ListNull(types.StringType), + Plan: testPlan(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")})), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.ListRequest{ + ConfigValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + Plan: testPlan(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ListResponse{ + PlanValue: testCase.request.PlanValue, + } + + listplanmodifier.RequiresReplaceIfConfigured().PlanModifyList(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/listplanmodifier/requires_replace_if_func.go b/resource/schema/listplanmodifier/requires_replace_if_func.go new file mode 100644 index 000000000..a80c73563 --- /dev/null +++ b/resource/schema/listplanmodifier/requires_replace_if_func.go @@ -0,0 +1,22 @@ +package listplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(context.Context, planmodifier.ListRequest, *RequiresReplaceIfFuncResponse) + +// RequiresReplaceIfFuncResponse is the response type for a RequiresReplaceIfFunc. +type RequiresReplaceIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // RequiresReplace should be enabled if the resource should be replaced. + RequiresReplace bool +} diff --git a/resource/schema/listplanmodifier/requires_replace_if_test.go b/resource/schema/listplanmodifier/requires_replace_if_test.go new file mode 100644 index 000000000..be3073753 --- /dev/null +++ b/resource/schema/listplanmodifier/requires_replace_if_test.go @@ -0,0 +1,181 @@ +package listplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfModifierPlanModifyList(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.ListAttribute{ + ElementType: types.StringType, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.List) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.List) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.ListRequest + ifFunc listplanmodifier.RequiresReplaceIfFunc + expected *planmodifier.ListResponse + }{ + "state-null": { + // resource creation + request: planmodifier.ListRequest{ + Plan: testPlan(types.ListUnknown(types.StringType)), + PlanValue: types.ListUnknown(types.StringType), + State: nullState, + StateValue: types.ListNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.ListRequest{ + Plan: nullPlan, + PlanValue: types.ListNull(types.StringType), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + 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, + }, + }, + "planvalue-statevalue-different-if-false": { + request: planmodifier.ListRequest{ + Plan: testPlan(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")})), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = false // no change + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-true": { + request: planmodifier.ListRequest{ + Plan: testPlan(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")})), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should reach here + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.ListRequest{ + Plan: testPlan(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ListResponse{ + PlanValue: testCase.request.PlanValue, + } + + listplanmodifier.RequiresReplaceIf(testCase.ifFunc, "test", "test").PlanModifyList(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/listplanmodifier/requires_replace_test.go b/resource/schema/listplanmodifier/requires_replace_test.go new file mode 100644 index 000000000..5a03794bb --- /dev/null +++ b/resource/schema/listplanmodifier/requires_replace_test.go @@ -0,0 +1,153 @@ +package listplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceModifierPlanModifyList(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.ListAttribute{ + ElementType: types.StringType, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.List) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.List) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.ListRequest + expected *planmodifier.ListResponse + }{ + "state-null": { + // resource creation + request: planmodifier.ListRequest{ + Plan: testPlan(types.ListUnknown(types.StringType)), + PlanValue: types.ListUnknown(types.StringType), + State: nullState, + StateValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.ListRequest{ + Plan: nullPlan, + PlanValue: types.ListNull(types.StringType), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListNull(types.StringType), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different": { + request: planmodifier.ListRequest{ + Plan: testPlan(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")})), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.ListRequest{ + Plan: testPlan(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + State: testState(types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ListResponse{ + PlanValue: testCase.request.PlanValue, + } + + listplanmodifier.RequiresReplace().PlanModifyList(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/listplanmodifier/use_state_for_unknown.go b/resource/schema/listplanmodifier/use_state_for_unknown.go new file mode 100644 index 000000000..e568c8650 --- /dev/null +++ b/resource/schema/listplanmodifier/use_state_for_unknown.go @@ -0,0 +1,52 @@ +package listplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknown returns a plan modifier that copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value. +func UseStateForUnknown() planmodifier.List { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the plan modifier. +type useStateForUnknownModifier struct{} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// PlanModifyList implements the plan modification logic. +func (m useStateForUnknownModifier) PlanModifyList(_ context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/listplanmodifier/use_state_for_unknown_test.go b/resource/schema/listplanmodifier/use_state_for_unknown_test.go new file mode 100644 index 000000000..885718641 --- /dev/null +++ b/resource/schema/listplanmodifier/use_state_for_unknown_test.go @@ -0,0 +1,96 @@ +package listplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUseStateForUnknownModifierPlanModifyList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.ListRequest + expected *planmodifier.ListResponse + }{ + "null-state": { + // when we first create the resource, use the unknown + // value + request: planmodifier.ListRequest{ + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + }, + "non-null-state-unknown-plan": { + // this is the situation we want to preserve the state + // in + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListUnknown(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ListResponse{ + PlanValue: testCase.request.PlanValue, + } + + listplanmodifier.UseStateForUnknown().PlanModifyList(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/mapplanmodifier/doc.go b/resource/schema/mapplanmodifier/doc.go new file mode 100644 index 000000000..be0e110e1 --- /dev/null +++ b/resource/schema/mapplanmodifier/doc.go @@ -0,0 +1,2 @@ +// Package mapplanmodifier provides plan modifiers for types.Map attributes. +package mapplanmodifier diff --git a/resource/schema/mapplanmodifier/requires_replace.go b/resource/schema/mapplanmodifier/requires_replace.go new file mode 100644 index 000000000..ab3d7d4ac --- /dev/null +++ b/resource/schema/mapplanmodifier/requires_replace.go @@ -0,0 +1,27 @@ +package mapplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplace returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// +// Use RequiresReplaceIfConfigured if the resource replacement should +// only occur if there is a configuration value (ignore unconfigured drift +// detection changes). Use RequiresReplaceIf if the resource replacement +// should check provider-defined conditional logic. +func RequiresReplace() planmodifier.Map { + return RequiresReplaceIf( + func(_ context.Context, _ planmodifier.MapRequest, resp *RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/mapplanmodifier/requires_replace_if.go b/resource/schema/mapplanmodifier/requires_replace_if.go new file mode 100644 index 000000000..4fd291fdf --- /dev/null +++ b/resource/schema/mapplanmodifier/requires_replace_if.go @@ -0,0 +1,70 @@ +package mapplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIf returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The given function returns true. Returning false will not unset any +// prior resource replacement. +// +// Use RequiresReplace if the resource replacement should always occur on value +// changes. Use RequiresReplaceIfConfigured if the resource replacement should +// occur on value changes, but only if there is a configuration value (ignore +// unconfigured drift detection changes). +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) planmodifier.Map { + return requiresReplaceIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// requiresReplaceIfModifier is an plan modifier that sets RequiresReplace +// on the attribute if a given function is true. +type requiresReplaceIfModifier struct { + ifFunc RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m requiresReplaceIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m requiresReplaceIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyMap implements the plan modification logic. +func (m requiresReplaceIfModifier) PlanModifyMap(ctx context.Context, req planmodifier.MapRequest, resp *planmodifier.MapResponse) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return + } + + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + // Do not replace if the plan and state values are equal. + if req.PlanValue.Equal(req.StateValue) { + return + } + + ifFuncResp := &RequiresReplaceIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + resp.RequiresReplace = ifFuncResp.RequiresReplace +} diff --git a/resource/schema/mapplanmodifier/requires_replace_if_configured.go b/resource/schema/mapplanmodifier/requires_replace_if_configured.go new file mode 100644 index 000000000..68991e948 --- /dev/null +++ b/resource/schema/mapplanmodifier/requires_replace_if_configured.go @@ -0,0 +1,31 @@ +package mapplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfConfigured returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The configuration value is not null. +// +// Use RequiresReplace if the resource replacement should occur regardless of +// the presence of a configuration value. Use RequiresReplaceIf if the resource +// replacement should check provider-defined conditional logic. +func RequiresReplaceIfConfigured() planmodifier.Map { + return RequiresReplaceIf( + func(_ context.Context, req planmodifier.MapRequest, resp *RequiresReplaceIfFuncResponse) { + if req.ConfigValue.IsNull() { + return + } + + resp.RequiresReplace = true + }, + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/mapplanmodifier/requires_replace_if_configured_test.go b/resource/schema/mapplanmodifier/requires_replace_if_configured_test.go new file mode 100644 index 000000000..80cbfda82 --- /dev/null +++ b/resource/schema/mapplanmodifier/requires_replace_if_configured_test.go @@ -0,0 +1,170 @@ +package mapplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfConfiguredModifierPlanModifyMap(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.MapAttribute{ + ElementType: types.StringType, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Map) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Map) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.MapRequest + expected *planmodifier.MapResponse + }{ + "state-null": { + // resource creation + request: planmodifier.MapRequest{ + ConfigValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + Plan: testPlan(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + State: nullState, + StateValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.MapRequest{ + ConfigValue: types.MapNull(types.StringType), + Plan: nullPlan, + PlanValue: types.MapNull(types.StringType), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapNull(types.StringType), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-configured": { + request: planmodifier.MapRequest{ + ConfigValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + Plan: testPlan(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")})), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-different-unconfigured": { + request: planmodifier.MapRequest{ + ConfigValue: types.MapNull(types.StringType), + Plan: testPlan(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")})), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.MapRequest{ + ConfigValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + Plan: testPlan(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.MapResponse{ + PlanValue: testCase.request.PlanValue, + } + + mapplanmodifier.RequiresReplaceIfConfigured().PlanModifyMap(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/mapplanmodifier/requires_replace_if_func.go b/resource/schema/mapplanmodifier/requires_replace_if_func.go new file mode 100644 index 000000000..5bd837d23 --- /dev/null +++ b/resource/schema/mapplanmodifier/requires_replace_if_func.go @@ -0,0 +1,22 @@ +package mapplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(context.Context, planmodifier.MapRequest, *RequiresReplaceIfFuncResponse) + +// RequiresReplaceIfFuncResponse is the response type for a RequiresReplaceIfFunc. +type RequiresReplaceIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // RequiresReplace should be enabled if the resource should be replaced. + RequiresReplace bool +} diff --git a/resource/schema/mapplanmodifier/requires_replace_if_test.go b/resource/schema/mapplanmodifier/requires_replace_if_test.go new file mode 100644 index 000000000..cf38d2e71 --- /dev/null +++ b/resource/schema/mapplanmodifier/requires_replace_if_test.go @@ -0,0 +1,181 @@ +package mapplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfModifierPlanModifyMap(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.MapAttribute{ + ElementType: types.StringType, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Map) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Map) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.MapRequest + ifFunc mapplanmodifier.RequiresReplaceIfFunc + expected *planmodifier.MapResponse + }{ + "state-null": { + // resource creation + request: planmodifier.MapRequest{ + Plan: testPlan(types.MapUnknown(types.StringType)), + PlanValue: types.MapUnknown(types.StringType), + State: nullState, + StateValue: types.MapNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.MapRequest{ + Plan: nullPlan, + PlanValue: types.MapNull(types.StringType), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + 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, + }, + }, + "planvalue-statevalue-different-if-false": { + request: planmodifier.MapRequest{ + Plan: testPlan(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")})), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = false // no change + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-true": { + request: planmodifier.MapRequest{ + Plan: testPlan(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")})), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should reach here + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.MapRequest{ + Plan: testPlan(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.MapResponse{ + PlanValue: testCase.request.PlanValue, + } + + mapplanmodifier.RequiresReplaceIf(testCase.ifFunc, "test", "test").PlanModifyMap(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/mapplanmodifier/requires_replace_test.go b/resource/schema/mapplanmodifier/requires_replace_test.go new file mode 100644 index 000000000..c07b12440 --- /dev/null +++ b/resource/schema/mapplanmodifier/requires_replace_test.go @@ -0,0 +1,153 @@ +package mapplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceModifierPlanModifyMap(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.MapAttribute{ + ElementType: types.StringType, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Map) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Map) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.MapRequest + expected *planmodifier.MapResponse + }{ + "state-null": { + // resource creation + request: planmodifier.MapRequest{ + Plan: testPlan(types.MapUnknown(types.StringType)), + PlanValue: types.MapUnknown(types.StringType), + State: nullState, + StateValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.MapRequest{ + Plan: nullPlan, + PlanValue: types.MapNull(types.StringType), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapNull(types.StringType), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different": { + request: planmodifier.MapRequest{ + Plan: testPlan(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")})), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.MapRequest{ + Plan: testPlan(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + State: testState(types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")})), + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.MapResponse{ + PlanValue: testCase.request.PlanValue, + } + + mapplanmodifier.RequiresReplace().PlanModifyMap(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/mapplanmodifier/use_state_for_unknown.go b/resource/schema/mapplanmodifier/use_state_for_unknown.go new file mode 100644 index 000000000..fb8bc1b16 --- /dev/null +++ b/resource/schema/mapplanmodifier/use_state_for_unknown.go @@ -0,0 +1,52 @@ +package mapplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknown returns a plan modifier that copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value. +func UseStateForUnknown() planmodifier.Map { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the plan modifier. +type useStateForUnknownModifier struct{} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// PlanModifyMap implements the plan modification logic. +func (m useStateForUnknownModifier) PlanModifyMap(_ context.Context, req planmodifier.MapRequest, resp *planmodifier.MapResponse) { + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/mapplanmodifier/use_state_for_unknown_test.go b/resource/schema/mapplanmodifier/use_state_for_unknown_test.go new file mode 100644 index 000000000..c92c04c8b --- /dev/null +++ b/resource/schema/mapplanmodifier/use_state_for_unknown_test.go @@ -0,0 +1,96 @@ +package mapplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUseStateForUnknownModifierPlanModifyMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.MapRequest + expected *planmodifier.MapResponse + }{ + "null-state": { + // when we first create the resource, use the unknown + // value + request: planmodifier.MapRequest{ + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + }, + "non-null-state-unknown-plan": { + // this is the situation we want to preserve the state + // in + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapUnknown(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.MapResponse{ + PlanValue: testCase.request.PlanValue, + } + + mapplanmodifier.UseStateForUnknown().PlanModifyMap(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/numberplanmodifier/doc.go b/resource/schema/numberplanmodifier/doc.go new file mode 100644 index 000000000..dcda40eea --- /dev/null +++ b/resource/schema/numberplanmodifier/doc.go @@ -0,0 +1,2 @@ +// Package numberplanmodifier provides plan modifiers for types.Number attributes. +package numberplanmodifier diff --git a/resource/schema/numberplanmodifier/requires_replace.go b/resource/schema/numberplanmodifier/requires_replace.go new file mode 100644 index 000000000..0ab17b173 --- /dev/null +++ b/resource/schema/numberplanmodifier/requires_replace.go @@ -0,0 +1,27 @@ +package numberplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplace returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// +// Use RequiresReplaceIfConfigured if the resource replacement should +// only occur if there is a configuration value (ignore unconfigured drift +// detection changes). Use RequiresReplaceIf if the resource replacement +// should check provider-defined conditional logic. +func RequiresReplace() planmodifier.Number { + return RequiresReplaceIf( + func(_ context.Context, _ planmodifier.NumberRequest, resp *RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/numberplanmodifier/requires_replace_if.go b/resource/schema/numberplanmodifier/requires_replace_if.go new file mode 100644 index 000000000..03dcfe237 --- /dev/null +++ b/resource/schema/numberplanmodifier/requires_replace_if.go @@ -0,0 +1,70 @@ +package numberplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIf returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The given function returns true. Returning false will not unset any +// prior resource replacement. +// +// Use RequiresReplace if the resource replacement should always occur on value +// changes. Use RequiresReplaceIfConfigured if the resource replacement should +// occur on value changes, but only if there is a configuration value (ignore +// unconfigured drift detection changes). +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) planmodifier.Number { + return requiresReplaceIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// requiresReplaceIfModifier is an plan modifier that sets RequiresReplace +// on the attribute if a given function is true. +type requiresReplaceIfModifier struct { + ifFunc RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m requiresReplaceIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m requiresReplaceIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyNumber implements the plan modification logic. +func (m requiresReplaceIfModifier) PlanModifyNumber(ctx context.Context, req planmodifier.NumberRequest, resp *planmodifier.NumberResponse) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return + } + + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + // Do not replace if the plan and state values are equal. + if req.PlanValue.Equal(req.StateValue) { + return + } + + ifFuncResp := &RequiresReplaceIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + resp.RequiresReplace = ifFuncResp.RequiresReplace +} diff --git a/resource/schema/numberplanmodifier/requires_replace_if_configured.go b/resource/schema/numberplanmodifier/requires_replace_if_configured.go new file mode 100644 index 000000000..e5f774669 --- /dev/null +++ b/resource/schema/numberplanmodifier/requires_replace_if_configured.go @@ -0,0 +1,31 @@ +package numberplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfConfigured returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The configuration value is not null. +// +// Use RequiresReplace if the resource replacement should occur regardless of +// the presence of a configuration value. Use RequiresReplaceIf if the resource +// replacement should check provider-defined conditional logic. +func RequiresReplaceIfConfigured() planmodifier.Number { + return RequiresReplaceIf( + func(_ context.Context, req planmodifier.NumberRequest, resp *RequiresReplaceIfFuncResponse) { + if req.ConfigValue.IsNull() { + return + } + + resp.RequiresReplace = true + }, + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/numberplanmodifier/requires_replace_if_configured_test.go b/resource/schema/numberplanmodifier/requires_replace_if_configured_test.go new file mode 100644 index 000000000..e47c45231 --- /dev/null +++ b/resource/schema/numberplanmodifier/requires_replace_if_configured_test.go @@ -0,0 +1,168 @@ +package numberplanmodifier_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/numberplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfConfiguredModifierPlanModifyNumber(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.NumberAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Number) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Number) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.NumberRequest + expected *planmodifier.NumberResponse + }{ + "state-null": { + // resource creation + request: planmodifier.NumberRequest{ + ConfigValue: types.NumberValue(big.NewFloat(1.2)), + Plan: testPlan(types.NumberValue(big.NewFloat(1.2))), + PlanValue: types.NumberValue(big.NewFloat(1.2)), + State: nullState, + StateValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(1.2)), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.NumberRequest{ + ConfigValue: types.NumberNull(), + Plan: nullPlan, + PlanValue: types.NumberNull(), + State: testState(types.NumberValue(big.NewFloat(1.2))), + StateValue: types.NumberValue(big.NewFloat(1.2)), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberNull(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-configured": { + request: planmodifier.NumberRequest{ + ConfigValue: types.NumberValue(big.NewFloat(2.4)), + Plan: testPlan(types.NumberValue(big.NewFloat(2.4))), + PlanValue: types.NumberValue(big.NewFloat(2.4)), + State: testState(types.NumberValue(big.NewFloat(1.2))), + StateValue: types.NumberValue(big.NewFloat(1.2)), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(2.4)), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-different-unconfigured": { + request: planmodifier.NumberRequest{ + ConfigValue: types.NumberNull(), + Plan: testPlan(types.NumberValue(big.NewFloat(2.4))), + PlanValue: types.NumberValue(big.NewFloat(2.4)), + State: testState(types.NumberValue(big.NewFloat(1.2))), + StateValue: types.NumberValue(big.NewFloat(1.2)), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(2.4)), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.NumberRequest{ + ConfigValue: types.NumberValue(big.NewFloat(1.2)), + Plan: testPlan(types.NumberValue(big.NewFloat(1.2))), + PlanValue: types.NumberValue(big.NewFloat(1.2)), + State: testState(types.NumberValue(big.NewFloat(1.2))), + StateValue: types.NumberValue(big.NewFloat(1.2)), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(1.2)), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.NumberResponse{ + PlanValue: testCase.request.PlanValue, + } + + numberplanmodifier.RequiresReplaceIfConfigured().PlanModifyNumber(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/numberplanmodifier/requires_replace_if_func.go b/resource/schema/numberplanmodifier/requires_replace_if_func.go new file mode 100644 index 000000000..b8f5f759b --- /dev/null +++ b/resource/schema/numberplanmodifier/requires_replace_if_func.go @@ -0,0 +1,22 @@ +package numberplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(context.Context, planmodifier.NumberRequest, *RequiresReplaceIfFuncResponse) + +// RequiresReplaceIfFuncResponse is the response type for a RequiresReplaceIfFunc. +type RequiresReplaceIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // RequiresReplace should be enabled if the resource should be replaced. + RequiresReplace bool +} diff --git a/resource/schema/numberplanmodifier/requires_replace_if_test.go b/resource/schema/numberplanmodifier/requires_replace_if_test.go new file mode 100644 index 000000000..f6138d921 --- /dev/null +++ b/resource/schema/numberplanmodifier/requires_replace_if_test.go @@ -0,0 +1,179 @@ +package numberplanmodifier_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/numberplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfModifierPlanModifyNumber(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.NumberAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Number) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Number) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.NumberRequest + ifFunc numberplanmodifier.RequiresReplaceIfFunc + expected *planmodifier.NumberResponse + }{ + "state-null": { + // resource creation + request: planmodifier.NumberRequest{ + Plan: testPlan(types.NumberUnknown()), + PlanValue: types.NumberUnknown(), + State: nullState, + StateValue: types.NumberNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.NumberRequest{ + Plan: nullPlan, + PlanValue: types.NumberNull(), + State: testState(types.NumberValue(big.NewFloat(1.2))), + StateValue: types.NumberValue(big.NewFloat(1.2)), + }, + 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, + }, + }, + "planvalue-statevalue-different-if-false": { + request: planmodifier.NumberRequest{ + Plan: testPlan(types.NumberValue(big.NewFloat(2.4))), + PlanValue: types.NumberValue(big.NewFloat(2.4)), + State: testState(types.NumberValue(big.NewFloat(1.2))), + StateValue: types.NumberValue(big.NewFloat(1.2)), + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = false // no change + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(2.4)), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-true": { + request: planmodifier.NumberRequest{ + Plan: testPlan(types.NumberValue(big.NewFloat(2.4))), + PlanValue: types.NumberValue(big.NewFloat(2.4)), + State: testState(types.NumberValue(big.NewFloat(1.2))), + StateValue: types.NumberValue(big.NewFloat(1.2)), + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should reach here + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(2.4)), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.NumberRequest{ + Plan: testPlan(types.NumberValue(big.NewFloat(1.2))), + PlanValue: types.NumberValue(big.NewFloat(1.2)), + State: testState(types.NumberValue(big.NewFloat(1.2))), + StateValue: types.NumberValue(big.NewFloat(1.2)), + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(1.2)), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.NumberResponse{ + PlanValue: testCase.request.PlanValue, + } + + numberplanmodifier.RequiresReplaceIf(testCase.ifFunc, "test", "test").PlanModifyNumber(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/numberplanmodifier/requires_replace_test.go b/resource/schema/numberplanmodifier/requires_replace_test.go new file mode 100644 index 000000000..e10a15b69 --- /dev/null +++ b/resource/schema/numberplanmodifier/requires_replace_test.go @@ -0,0 +1,151 @@ +package numberplanmodifier_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/numberplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceModifierPlanModifyNumber(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.NumberAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Number) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Number) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.NumberRequest + expected *planmodifier.NumberResponse + }{ + "state-null": { + // resource creation + request: planmodifier.NumberRequest{ + Plan: testPlan(types.NumberUnknown()), + PlanValue: types.NumberUnknown(), + State: nullState, + StateValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.NumberRequest{ + Plan: nullPlan, + PlanValue: types.NumberNull(), + State: testState(types.NumberValue(big.NewFloat(1.2))), + StateValue: types.NumberValue(big.NewFloat(1.2)), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberNull(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different": { + request: planmodifier.NumberRequest{ + Plan: testPlan(types.NumberValue(big.NewFloat(2.4))), + PlanValue: types.NumberValue(big.NewFloat(2.4)), + State: testState(types.NumberValue(big.NewFloat(1.2))), + StateValue: types.NumberValue(big.NewFloat(1.2)), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(2.4)), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.NumberRequest{ + Plan: testPlan(types.NumberValue(big.NewFloat(1.2))), + PlanValue: types.NumberValue(big.NewFloat(1.2)), + State: testState(types.NumberValue(big.NewFloat(1.2))), + StateValue: types.NumberValue(big.NewFloat(1.2)), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(1.2)), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.NumberResponse{ + PlanValue: testCase.request.PlanValue, + } + + numberplanmodifier.RequiresReplace().PlanModifyNumber(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/numberplanmodifier/use_state_for_unknown.go b/resource/schema/numberplanmodifier/use_state_for_unknown.go new file mode 100644 index 000000000..e49c428b4 --- /dev/null +++ b/resource/schema/numberplanmodifier/use_state_for_unknown.go @@ -0,0 +1,52 @@ +package numberplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknown returns a plan modifier that copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value. +func UseStateForUnknown() planmodifier.Number { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the plan modifier. +type useStateForUnknownModifier struct{} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// PlanModifyNumber implements the plan modification logic. +func (m useStateForUnknownModifier) PlanModifyNumber(_ context.Context, req planmodifier.NumberRequest, resp *planmodifier.NumberResponse) { + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/numberplanmodifier/use_state_for_unknown_test.go b/resource/schema/numberplanmodifier/use_state_for_unknown_test.go new file mode 100644 index 000000000..06c9e6754 --- /dev/null +++ b/resource/schema/numberplanmodifier/use_state_for_unknown_test.go @@ -0,0 +1,96 @@ +package numberplanmodifier_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/numberplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUseStateForUnknownModifierPlanModifyNumber(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.NumberRequest + expected *planmodifier.NumberResponse + }{ + "null-state": { + // when we first create the resource, use the unknown + // value + request: planmodifier.NumberRequest{ + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(2.4)), + PlanValue: types.NumberValue(big.NewFloat(1.2)), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(1.2)), + }, + }, + "non-null-state-unknown-plan": { + // this is the situation we want to preserve the state + // in + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(1.2)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(1.2)), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(1.2)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberUnknown(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.NumberResponse{ + PlanValue: testCase.request.PlanValue, + } + + numberplanmodifier.UseStateForUnknown().PlanModifyNumber(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/objectplanmodifier/doc.go b/resource/schema/objectplanmodifier/doc.go new file mode 100644 index 000000000..3c824315f --- /dev/null +++ b/resource/schema/objectplanmodifier/doc.go @@ -0,0 +1,2 @@ +// Package objectplanmodifier provides plan modifiers for types.Object attributes. +package objectplanmodifier diff --git a/resource/schema/objectplanmodifier/requires_replace.go b/resource/schema/objectplanmodifier/requires_replace.go new file mode 100644 index 000000000..96b9fa7bb --- /dev/null +++ b/resource/schema/objectplanmodifier/requires_replace.go @@ -0,0 +1,27 @@ +package objectplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplace returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// +// Use RequiresReplaceIfConfigured if the resource replacement should +// only occur if there is a configuration value (ignore unconfigured drift +// detection changes). Use RequiresReplaceIf if the resource replacement +// should check provider-defined conditional logic. +func RequiresReplace() planmodifier.Object { + return RequiresReplaceIf( + func(_ context.Context, _ planmodifier.ObjectRequest, resp *RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/objectplanmodifier/requires_replace_if.go b/resource/schema/objectplanmodifier/requires_replace_if.go new file mode 100644 index 000000000..3f8e2f8aa --- /dev/null +++ b/resource/schema/objectplanmodifier/requires_replace_if.go @@ -0,0 +1,70 @@ +package objectplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIf returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The given function returns true. Returning false will not unset any +// prior resource replacement. +// +// Use RequiresReplace if the resource replacement should always occur on value +// changes. Use RequiresReplaceIfConfigured if the resource replacement should +// occur on value changes, but only if there is a configuration value (ignore +// unconfigured drift detection changes). +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) planmodifier.Object { + return requiresReplaceIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// requiresReplaceIfModifier is an plan modifier that sets RequiresReplace +// on the attribute if a given function is true. +type requiresReplaceIfModifier struct { + ifFunc RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m requiresReplaceIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m requiresReplaceIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyObject implements the plan modification logic. +func (m requiresReplaceIfModifier) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return + } + + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + // Do not replace if the plan and state values are equal. + if req.PlanValue.Equal(req.StateValue) { + return + } + + ifFuncResp := &RequiresReplaceIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + resp.RequiresReplace = ifFuncResp.RequiresReplace +} diff --git a/resource/schema/objectplanmodifier/requires_replace_if_configured.go b/resource/schema/objectplanmodifier/requires_replace_if_configured.go new file mode 100644 index 000000000..ff6884334 --- /dev/null +++ b/resource/schema/objectplanmodifier/requires_replace_if_configured.go @@ -0,0 +1,31 @@ +package objectplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfConfigured returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The configuration value is not null. +// +// Use RequiresReplace if the resource replacement should occur regardless of +// the presence of a configuration value. Use RequiresReplaceIf if the resource +// replacement should check provider-defined conditional logic. +func RequiresReplaceIfConfigured() planmodifier.Object { + return RequiresReplaceIf( + func(_ context.Context, req planmodifier.ObjectRequest, resp *RequiresReplaceIfFuncResponse) { + if req.ConfigValue.IsNull() { + return + } + + resp.RequiresReplace = true + }, + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/objectplanmodifier/requires_replace_if_configured_test.go b/resource/schema/objectplanmodifier/requires_replace_if_configured_test.go new file mode 100644 index 000000000..c1cfaabc7 --- /dev/null +++ b/resource/schema/objectplanmodifier/requires_replace_if_configured_test.go @@ -0,0 +1,170 @@ +package objectplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfConfiguredModifierPlanModifyObject(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.ObjectAttribute{ + AttributeTypes: map[string]attr.Type{"testattr": types.StringType}, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Object) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Object) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.ObjectRequest + expected *planmodifier.ObjectResponse + }{ + "state-null": { + // resource creation + request: planmodifier.ObjectRequest{ + ConfigValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + Plan: testPlan(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + State: nullState, + StateValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.ObjectRequest{ + ConfigValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + Plan: nullPlan, + 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.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-configured": { + request: planmodifier.ObjectRequest{ + ConfigValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + Plan: testPlan(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")})), + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + State: testState(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-different-unconfigured": { + request: planmodifier.ObjectRequest{ + ConfigValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + Plan: testPlan(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")})), + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + State: testState(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.ObjectRequest{ + ConfigValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + Plan: testPlan(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + State: testState(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ObjectResponse{ + PlanValue: testCase.request.PlanValue, + } + + objectplanmodifier.RequiresReplaceIfConfigured().PlanModifyObject(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/objectplanmodifier/requires_replace_if_func.go b/resource/schema/objectplanmodifier/requires_replace_if_func.go new file mode 100644 index 000000000..0b7462a1a --- /dev/null +++ b/resource/schema/objectplanmodifier/requires_replace_if_func.go @@ -0,0 +1,22 @@ +package objectplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(context.Context, planmodifier.ObjectRequest, *RequiresReplaceIfFuncResponse) + +// RequiresReplaceIfFuncResponse is the response type for a RequiresReplaceIfFunc. +type RequiresReplaceIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // RequiresReplace should be enabled if the resource should be replaced. + RequiresReplace bool +} diff --git a/resource/schema/objectplanmodifier/requires_replace_if_test.go b/resource/schema/objectplanmodifier/requires_replace_if_test.go new file mode 100644 index 000000000..f3decf31d --- /dev/null +++ b/resource/schema/objectplanmodifier/requires_replace_if_test.go @@ -0,0 +1,181 @@ +package objectplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfModifierPlanModifyObject(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.ObjectAttribute{ + AttributeTypes: map[string]attr.Type{"testattr": types.StringType}, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Object) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Object) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.ObjectRequest + ifFunc objectplanmodifier.RequiresReplaceIfFunc + expected *planmodifier.ObjectResponse + }{ + "state-null": { + // resource creation + request: planmodifier.ObjectRequest{ + Plan: testPlan(types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType})), + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + State: nullState, + StateValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.ObjectRequest{ + Plan: nullPlan, + 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.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + 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, + }, + }, + "planvalue-statevalue-different-if-false": { + request: planmodifier.ObjectRequest{ + Plan: testPlan(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")})), + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + State: testState(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = false // no change + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-true": { + request: planmodifier.ObjectRequest{ + Plan: testPlan(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")})), + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + State: testState(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should reach here + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.ObjectRequest{ + Plan: testPlan(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + State: testState(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ObjectResponse{ + PlanValue: testCase.request.PlanValue, + } + + objectplanmodifier.RequiresReplaceIf(testCase.ifFunc, "test", "test").PlanModifyObject(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/objectplanmodifier/requires_replace_test.go b/resource/schema/objectplanmodifier/requires_replace_test.go new file mode 100644 index 000000000..70c8f873d --- /dev/null +++ b/resource/schema/objectplanmodifier/requires_replace_test.go @@ -0,0 +1,153 @@ +package objectplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceModifierPlanModifyObject(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.ObjectAttribute{ + AttributeTypes: map[string]attr.Type{"testattr": types.StringType}, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Object) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Object) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.ObjectRequest + expected *planmodifier.ObjectResponse + }{ + "state-null": { + // resource creation + request: planmodifier.ObjectRequest{ + Plan: testPlan(types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType})), + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + State: nullState, + StateValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.ObjectRequest{ + Plan: nullPlan, + 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.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different": { + request: planmodifier.ObjectRequest{ + Plan: testPlan(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")})), + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + State: testState(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.ObjectRequest{ + Plan: testPlan(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + State: testState(types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")})), + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ObjectResponse{ + PlanValue: testCase.request.PlanValue, + } + + objectplanmodifier.RequiresReplace().PlanModifyObject(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/objectplanmodifier/use_state_for_unknown.go b/resource/schema/objectplanmodifier/use_state_for_unknown.go new file mode 100644 index 000000000..b3405a44a --- /dev/null +++ b/resource/schema/objectplanmodifier/use_state_for_unknown.go @@ -0,0 +1,52 @@ +package objectplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknown returns a plan modifier that copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value. +func UseStateForUnknown() planmodifier.Object { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the plan modifier. +type useStateForUnknownModifier struct{} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// PlanModifyObject implements the plan modification logic. +func (m useStateForUnknownModifier) PlanModifyObject(_ context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/objectplanmodifier/use_state_for_unknown_test.go b/resource/schema/objectplanmodifier/use_state_for_unknown_test.go new file mode 100644 index 000000000..dce8cdb3b --- /dev/null +++ b/resource/schema/objectplanmodifier/use_state_for_unknown_test.go @@ -0,0 +1,96 @@ +package objectplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUseStateForUnknownModifierPlanModifyObject(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.ObjectRequest + expected *planmodifier.ObjectResponse + }{ + "null-state": { + // when we first create the resource, use the unknown + // value + request: planmodifier.ObjectRequest{ + StateValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + ConfigValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.ObjectRequest{ + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + ConfigValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + }, + "non-null-state-unknown-plan": { + // this is the situation we want to preserve the state + // in + request: planmodifier.ObjectRequest{ + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + ConfigValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.ObjectRequest{ + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + ConfigValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ObjectResponse{ + PlanValue: testCase.request.PlanValue, + } + + objectplanmodifier.UseStateForUnknown().PlanModifyObject(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/planmodifier/doc.go b/resource/schema/planmodifier/doc.go index 6d3a66daf..97ec55235 100644 --- a/resource/schema/planmodifier/doc.go +++ b/resource/schema/planmodifier/doc.go @@ -1,5 +1,9 @@ // Package planmodifier contains schema plan modifier interfaces and -// implementations. These plan modifiers are used by resource/schema. +// request/response implementations. These plan modifier interfaces +// are used by resource/schema and internally in the framework. +// Refer to the typed plan modifier packages, such as stringplanmodifier, +// for framework-defined plan modifiers that can be used in +// provider-defined schemas. // // Each attr.Type has a corresponding {TYPE}PlanModifer interface which // implements concretely typed Modify{TYPE} methods, such as diff --git a/resource/schema/setplanmodifier/doc.go b/resource/schema/setplanmodifier/doc.go new file mode 100644 index 000000000..edaaa9fa3 --- /dev/null +++ b/resource/schema/setplanmodifier/doc.go @@ -0,0 +1,2 @@ +// Package setplanmodifier provides plan modifiers for types.Set attributes. +package setplanmodifier diff --git a/resource/schema/setplanmodifier/requires_replace.go b/resource/schema/setplanmodifier/requires_replace.go new file mode 100644 index 000000000..34cf7a31d --- /dev/null +++ b/resource/schema/setplanmodifier/requires_replace.go @@ -0,0 +1,27 @@ +package setplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplace returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// +// Use RequiresReplaceIfConfigured if the resource replacement should +// only occur if there is a configuration value (ignore unconfigured drift +// detection changes). Use RequiresReplaceIf if the resource replacement +// should check provider-defined conditional logic. +func RequiresReplace() planmodifier.Set { + return RequiresReplaceIf( + func(_ context.Context, _ planmodifier.SetRequest, resp *RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/setplanmodifier/requires_replace_if.go b/resource/schema/setplanmodifier/requires_replace_if.go new file mode 100644 index 000000000..4f4a41d73 --- /dev/null +++ b/resource/schema/setplanmodifier/requires_replace_if.go @@ -0,0 +1,70 @@ +package setplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIf returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The given function returns true. Returning false will not unset any +// prior resource replacement. +// +// Use RequiresReplace if the resource replacement should always occur on value +// changes. Use RequiresReplaceIfConfigured if the resource replacement should +// occur on value changes, but only if there is a configuration value (ignore +// unconfigured drift detection changes). +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) planmodifier.Set { + return requiresReplaceIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// requiresReplaceIfModifier is an plan modifier that sets RequiresReplace +// on the attribute if a given function is true. +type requiresReplaceIfModifier struct { + ifFunc RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m requiresReplaceIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m requiresReplaceIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifySet implements the plan modification logic. +func (m requiresReplaceIfModifier) PlanModifySet(ctx context.Context, req planmodifier.SetRequest, resp *planmodifier.SetResponse) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return + } + + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + // Do not replace if the plan and state values are equal. + if req.PlanValue.Equal(req.StateValue) { + return + } + + ifFuncResp := &RequiresReplaceIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + resp.RequiresReplace = ifFuncResp.RequiresReplace +} diff --git a/resource/schema/setplanmodifier/requires_replace_if_configured.go b/resource/schema/setplanmodifier/requires_replace_if_configured.go new file mode 100644 index 000000000..40a250844 --- /dev/null +++ b/resource/schema/setplanmodifier/requires_replace_if_configured.go @@ -0,0 +1,31 @@ +package setplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfConfigured returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The configuration value is not null. +// +// Use RequiresReplace if the resource replacement should occur regardless of +// the presence of a configuration value. Use RequiresReplaceIf if the resource +// replacement should check provider-defined conditional logic. +func RequiresReplaceIfConfigured() planmodifier.Set { + return RequiresReplaceIf( + func(_ context.Context, req planmodifier.SetRequest, resp *RequiresReplaceIfFuncResponse) { + if req.ConfigValue.IsNull() { + return + } + + resp.RequiresReplace = true + }, + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/setplanmodifier/requires_replace_if_configured_test.go b/resource/schema/setplanmodifier/requires_replace_if_configured_test.go new file mode 100644 index 000000000..78a7bdf4c --- /dev/null +++ b/resource/schema/setplanmodifier/requires_replace_if_configured_test.go @@ -0,0 +1,170 @@ +package setplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfConfiguredModifierPlanModifySet(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.SetAttribute{ + ElementType: types.StringType, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Set) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Set) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.SetRequest + expected *planmodifier.SetResponse + }{ + "state-null": { + // resource creation + request: planmodifier.SetRequest{ + ConfigValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + Plan: testPlan(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + State: nullState, + StateValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.SetRequest{ + ConfigValue: types.SetNull(types.StringType), + Plan: nullPlan, + PlanValue: types.SetNull(types.StringType), + State: testState(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetNull(types.StringType), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-configured": { + request: planmodifier.SetRequest{ + ConfigValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + Plan: testPlan(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")})), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + State: testState(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-different-unconfigured": { + request: planmodifier.SetRequest{ + ConfigValue: types.SetNull(types.StringType), + Plan: testPlan(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")})), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + State: testState(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.SetRequest{ + ConfigValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + Plan: testPlan(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + State: testState(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.SetResponse{ + PlanValue: testCase.request.PlanValue, + } + + setplanmodifier.RequiresReplaceIfConfigured().PlanModifySet(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/setplanmodifier/requires_replace_if_func.go b/resource/schema/setplanmodifier/requires_replace_if_func.go new file mode 100644 index 000000000..1415c3196 --- /dev/null +++ b/resource/schema/setplanmodifier/requires_replace_if_func.go @@ -0,0 +1,22 @@ +package setplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(context.Context, planmodifier.SetRequest, *RequiresReplaceIfFuncResponse) + +// RequiresReplaceIfFuncResponse is the response type for a RequiresReplaceIfFunc. +type RequiresReplaceIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // RequiresReplace should be enabled if the resource should be replaced. + RequiresReplace bool +} diff --git a/resource/schema/setplanmodifier/requires_replace_if_test.go b/resource/schema/setplanmodifier/requires_replace_if_test.go new file mode 100644 index 000000000..b34579600 --- /dev/null +++ b/resource/schema/setplanmodifier/requires_replace_if_test.go @@ -0,0 +1,181 @@ +package setplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfModifierPlanModifySet(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.SetAttribute{ + ElementType: types.StringType, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Set) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Set) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.SetRequest + ifFunc setplanmodifier.RequiresReplaceIfFunc + expected *planmodifier.SetResponse + }{ + "state-null": { + // resource creation + request: planmodifier.SetRequest{ + Plan: testPlan(types.SetUnknown(types.StringType)), + PlanValue: types.SetUnknown(types.StringType), + State: nullState, + StateValue: types.SetNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.SetRequest{ + Plan: nullPlan, + PlanValue: types.SetNull(types.StringType), + State: testState(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetNull(types.StringType), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-false": { + request: planmodifier.SetRequest{ + Plan: testPlan(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")})), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + State: testState(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = false // no change + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-true": { + request: planmodifier.SetRequest{ + Plan: testPlan(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")})), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + State: testState(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should reach here + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.SetRequest{ + Plan: testPlan(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + State: testState(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + ifFunc: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.SetResponse{ + PlanValue: testCase.request.PlanValue, + } + + setplanmodifier.RequiresReplaceIf(testCase.ifFunc, "test", "test").PlanModifySet(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/setplanmodifier/requires_replace_test.go b/resource/schema/setplanmodifier/requires_replace_test.go new file mode 100644 index 000000000..a14a17ae1 --- /dev/null +++ b/resource/schema/setplanmodifier/requires_replace_test.go @@ -0,0 +1,153 @@ +package setplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceModifierPlanModifySet(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.SetAttribute{ + ElementType: types.StringType, + }, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Set) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Set) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.SetRequest + expected *planmodifier.SetResponse + }{ + "state-null": { + // resource creation + request: planmodifier.SetRequest{ + Plan: testPlan(types.SetUnknown(types.StringType)), + PlanValue: types.SetUnknown(types.StringType), + State: nullState, + StateValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.SetRequest{ + Plan: nullPlan, + PlanValue: types.SetNull(types.StringType), + State: testState(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetNull(types.StringType), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different": { + request: planmodifier.SetRequest{ + Plan: testPlan(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")})), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + State: testState(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.SetRequest{ + Plan: testPlan(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + State: testState(types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")})), + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.SetResponse{ + PlanValue: testCase.request.PlanValue, + } + + setplanmodifier.RequiresReplace().PlanModifySet(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/setplanmodifier/use_state_for_unknown.go b/resource/schema/setplanmodifier/use_state_for_unknown.go new file mode 100644 index 000000000..2dd8b2906 --- /dev/null +++ b/resource/schema/setplanmodifier/use_state_for_unknown.go @@ -0,0 +1,52 @@ +package setplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknown returns a plan modifier that copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value. +func UseStateForUnknown() planmodifier.Set { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the plan modifier. +type useStateForUnknownModifier struct{} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// PlanModifySet implements the plan modification logic. +func (m useStateForUnknownModifier) PlanModifySet(_ context.Context, req planmodifier.SetRequest, resp *planmodifier.SetResponse) { + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/setplanmodifier/use_state_for_unknown_test.go b/resource/schema/setplanmodifier/use_state_for_unknown_test.go new file mode 100644 index 000000000..3f4d8cf9b --- /dev/null +++ b/resource/schema/setplanmodifier/use_state_for_unknown_test.go @@ -0,0 +1,96 @@ +package setplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUseStateForUnknownModifierPlanModifySet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.SetRequest + expected *planmodifier.SetResponse + }{ + "null-state": { + // when we first create the resource, use the unknown + // value + request: planmodifier.SetRequest{ + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + }, + "non-null-state-unknown-plan": { + // this is the situation we want to preserve the state + // in + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetUnknown(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.SetResponse{ + PlanValue: testCase.request.PlanValue, + } + + setplanmodifier.UseStateForUnknown().PlanModifySet(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/stringplanmodifier/doc.go b/resource/schema/stringplanmodifier/doc.go new file mode 100644 index 000000000..d88e1306b --- /dev/null +++ b/resource/schema/stringplanmodifier/doc.go @@ -0,0 +1,2 @@ +// Package stringplanmodifier provides plan modifiers for types.String attributes. +package stringplanmodifier diff --git a/resource/schema/stringplanmodifier/requires_replace.go b/resource/schema/stringplanmodifier/requires_replace.go new file mode 100644 index 000000000..1e281b462 --- /dev/null +++ b/resource/schema/stringplanmodifier/requires_replace.go @@ -0,0 +1,27 @@ +package stringplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplace returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// +// Use RequiresReplaceIfConfigured if the resource replacement should +// only occur if there is a configuration value (ignore unconfigured drift +// detection changes). Use RequiresReplaceIf if the resource replacement +// should check provider-defined conditional logic. +func RequiresReplace() planmodifier.String { + return RequiresReplaceIf( + func(_ context.Context, _ planmodifier.StringRequest, resp *RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/stringplanmodifier/requires_replace_if.go b/resource/schema/stringplanmodifier/requires_replace_if.go new file mode 100644 index 000000000..4ad7516b9 --- /dev/null +++ b/resource/schema/stringplanmodifier/requires_replace_if.go @@ -0,0 +1,70 @@ +package stringplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIf returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The given function returns true. Returning false will not unset any +// prior resource replacement. +// +// Use RequiresReplace if the resource replacement should always occur on value +// changes. Use RequiresReplaceIfConfigured if the resource replacement should +// occur on value changes, but only if there is a configuration value (ignore +// unconfigured drift detection changes). +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) planmodifier.String { + return requiresReplaceIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// requiresReplaceIfModifier is an plan modifier that sets RequiresReplace +// on the attribute if a given function is true. +type requiresReplaceIfModifier struct { + ifFunc RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m requiresReplaceIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m requiresReplaceIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyString implements the plan modification logic. +func (m requiresReplaceIfModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return + } + + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + // Do not replace if the plan and state values are equal. + if req.PlanValue.Equal(req.StateValue) { + return + } + + ifFuncResp := &RequiresReplaceIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + resp.RequiresReplace = ifFuncResp.RequiresReplace +} diff --git a/resource/schema/stringplanmodifier/requires_replace_if_configured.go b/resource/schema/stringplanmodifier/requires_replace_if_configured.go new file mode 100644 index 000000000..ac5a42e3d --- /dev/null +++ b/resource/schema/stringplanmodifier/requires_replace_if_configured.go @@ -0,0 +1,31 @@ +package stringplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfConfigured returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The configuration value is not null. +// +// Use RequiresReplace if the resource replacement should occur regardless of +// the presence of a configuration value. Use RequiresReplaceIf if the resource +// replacement should check provider-defined conditional logic. +func RequiresReplaceIfConfigured() planmodifier.String { + return RequiresReplaceIf( + func(_ context.Context, req planmodifier.StringRequest, resp *RequiresReplaceIfFuncResponse) { + if req.ConfigValue.IsNull() { + return + } + + resp.RequiresReplace = true + }, + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/stringplanmodifier/requires_replace_if_configured_test.go b/resource/schema/stringplanmodifier/requires_replace_if_configured_test.go new file mode 100644 index 000000000..156aafc40 --- /dev/null +++ b/resource/schema/stringplanmodifier/requires_replace_if_configured_test.go @@ -0,0 +1,167 @@ +package stringplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfConfiguredModifierPlanModifyString(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.String) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.String) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.StringRequest + expected *planmodifier.StringResponse + }{ + "state-null": { + // resource creation + request: planmodifier.StringRequest{ + ConfigValue: types.StringValue("test"), + Plan: testPlan(types.StringValue("test")), + PlanValue: types.StringValue("test"), + State: nullState, + StateValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.StringRequest{ + ConfigValue: types.StringNull(), + Plan: nullPlan, + PlanValue: types.StringNull(), + State: testState(types.StringValue("test")), + StateValue: types.StringValue("test"), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringNull(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-configured": { + request: planmodifier.StringRequest{ + ConfigValue: types.StringValue("other"), + Plan: testPlan(types.StringValue("other")), + PlanValue: types.StringValue("other"), + State: testState(types.StringValue("test")), + StateValue: types.StringValue("test"), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("other"), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-different-unconfigured": { + request: planmodifier.StringRequest{ + ConfigValue: types.StringNull(), + Plan: testPlan(types.StringValue("other")), + PlanValue: types.StringValue("other"), + State: testState(types.StringValue("test")), + StateValue: types.StringValue("test"), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("other"), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.StringRequest{ + ConfigValue: types.StringValue("test"), + Plan: testPlan(types.StringValue("test")), + PlanValue: types.StringValue("test"), + State: testState(types.StringValue("test")), + StateValue: types.StringValue("test"), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.StringResponse{ + PlanValue: testCase.request.PlanValue, + } + + stringplanmodifier.RequiresReplaceIfConfigured().PlanModifyString(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/stringplanmodifier/requires_replace_if_func.go b/resource/schema/stringplanmodifier/requires_replace_if_func.go new file mode 100644 index 000000000..64dde9abb --- /dev/null +++ b/resource/schema/stringplanmodifier/requires_replace_if_func.go @@ -0,0 +1,22 @@ +package stringplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(context.Context, planmodifier.StringRequest, *RequiresReplaceIfFuncResponse) + +// RequiresReplaceIfFuncResponse is the response type for a RequiresReplaceIfFunc. +type RequiresReplaceIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // RequiresReplace should be enabled if the resource should be replaced. + RequiresReplace bool +} diff --git a/resource/schema/stringplanmodifier/requires_replace_if_test.go b/resource/schema/stringplanmodifier/requires_replace_if_test.go new file mode 100644 index 000000000..295f54518 --- /dev/null +++ b/resource/schema/stringplanmodifier/requires_replace_if_test.go @@ -0,0 +1,178 @@ +package stringplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfModifierPlanModifyString(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.String) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.String) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.StringRequest + ifFunc stringplanmodifier.RequiresReplaceIfFunc + expected *planmodifier.StringResponse + }{ + "state-null": { + // resource creation + request: planmodifier.StringRequest{ + Plan: testPlan(types.StringUnknown()), + PlanValue: types.StringUnknown(), + State: nullState, + StateValue: types.StringNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.StringRequest{ + Plan: nullPlan, + PlanValue: types.StringNull(), + State: testState(types.StringValue("test")), + StateValue: types.StringValue("test"), + }, + 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, + }, + }, + "planvalue-statevalue-different-if-false": { + request: planmodifier.StringRequest{ + Plan: testPlan(types.StringValue("other")), + PlanValue: types.StringValue("other"), + State: testState(types.StringValue("test")), + StateValue: types.StringValue("test"), + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = false // no change + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("other"), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-true": { + request: planmodifier.StringRequest{ + Plan: testPlan(types.StringValue("other")), + PlanValue: types.StringValue("other"), + State: testState(types.StringValue("test")), + StateValue: types.StringValue("test"), + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should reach here + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("other"), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.StringRequest{ + Plan: testPlan(types.StringValue("test")), + PlanValue: types.StringValue("test"), + State: testState(types.StringValue("test")), + StateValue: types.StringValue("test"), + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.StringResponse{ + PlanValue: testCase.request.PlanValue, + } + + stringplanmodifier.RequiresReplaceIf(testCase.ifFunc, "test", "test").PlanModifyString(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/stringplanmodifier/requires_replace_test.go b/resource/schema/stringplanmodifier/requires_replace_test.go new file mode 100644 index 000000000..32374d86e --- /dev/null +++ b/resource/schema/stringplanmodifier/requires_replace_test.go @@ -0,0 +1,150 @@ +package stringplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceModifierPlanModifyString(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.String) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.String) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.StringRequest + expected *planmodifier.StringResponse + }{ + "state-null": { + // resource creation + request: planmodifier.StringRequest{ + Plan: testPlan(types.StringUnknown()), + PlanValue: types.StringUnknown(), + State: nullState, + StateValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.StringRequest{ + Plan: nullPlan, + PlanValue: types.StringNull(), + State: testState(types.StringValue("test")), + StateValue: types.StringValue("test"), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringNull(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different": { + request: planmodifier.StringRequest{ + Plan: testPlan(types.StringValue("other")), + PlanValue: types.StringValue("other"), + State: testState(types.StringValue("test")), + StateValue: types.StringValue("test"), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("other"), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.StringRequest{ + Plan: testPlan(types.StringValue("test")), + PlanValue: types.StringValue("test"), + State: testState(types.StringValue("test")), + StateValue: types.StringValue("test"), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.StringResponse{ + PlanValue: testCase.request.PlanValue, + } + + stringplanmodifier.RequiresReplace().PlanModifyString(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/stringplanmodifier/use_state_for_unknown.go b/resource/schema/stringplanmodifier/use_state_for_unknown.go new file mode 100644 index 000000000..3c1e29838 --- /dev/null +++ b/resource/schema/stringplanmodifier/use_state_for_unknown.go @@ -0,0 +1,52 @@ +package stringplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknown returns a plan modifier that copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value. +func UseStateForUnknown() planmodifier.String { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the plan modifier. +type useStateForUnknownModifier struct{} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// PlanModifyString implements the plan modification logic. +func (m useStateForUnknownModifier) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/stringplanmodifier/use_state_for_unknown_test.go b/resource/schema/stringplanmodifier/use_state_for_unknown_test.go new file mode 100644 index 000000000..cc6dfeee5 --- /dev/null +++ b/resource/schema/stringplanmodifier/use_state_for_unknown_test.go @@ -0,0 +1,95 @@ +package stringplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUseStateForUnknownModifierPlanModifyString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.StringRequest + expected *planmodifier.StringResponse + }{ + "null-state": { + // when we first create the resource, use the unknown + // value + request: planmodifier.StringRequest{ + StateValue: types.StringNull(), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.StringRequest{ + StateValue: types.StringValue("other"), + PlanValue: types.StringValue("test"), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + }, + }, + "non-null-state-unknown-plan": { + // this is the situation we want to preserve the state + // in + request: planmodifier.StringRequest{ + StateValue: types.StringValue("test"), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.StringRequest{ + StateValue: types.StringValue("test"), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringUnknown(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.StringResponse{ + PlanValue: testCase.request.PlanValue, + } + + stringplanmodifier.UseStateForUnknown().PlanModifyString(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/website/docs/plugin/framework/resources/plan-modification.mdx b/website/docs/plugin/framework/resources/plan-modification.mdx index e03afba3d..c2ad855fc 100644 --- a/website/docs/plugin/framework/resources/plan-modification.mdx +++ b/website/docs/plugin/framework/resources/plan-modification.mdx @@ -50,11 +50,12 @@ If defined, plan modifiers are applied to the current attribute. If any nested a ### Common Use Case Attribute Plan Modifiers -The framework implements some common use case modifiers: +The framework implements some common use case modifiers in the typed packages under `resource/schema/`, such as `resource/schema/stringplanmodifier`: -- [`resource.RequiresReplace()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource#RequiresReplace): If the value of the attribute changes, in-place update is not possible and instead the resource should be replaced for the change to occur. Refer to the Go documentation for full details on its behavior. -- [`resource.RequiresReplaceIf()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource#RequiresReplaceIf): Similar to `resource.RequiresReplace()`, however it also accepts provider-defined conditional logic. Refer to the Go documentation for full details on its behavior. -- [`resource.UseStateForUnknown()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource#UseStateForUnknown): Copies the prior state value, if not null. This is useful for reducing `(known after apply)` plan outputs for computed attributes which are known to not change over time. +- `RequiresReplace()`: If the value of the attribute changes, in-place update is not possible and instead the resource should be replaced for the change to occur. Refer to the Go documentation for full details on its behavior. +- `RequiresReplaceIf()`: Similar to `resource.RequiresReplace()`, however it also accepts provider-defined conditional logic. Refer to the Go documentation for full details on its behavior. +- `RequiresReplaceIfConfigured()`: Similar to `resource.RequiresReplace()`, however it also will only trigger if the practitioner has configured a value. Refer to the Go documentation for full details on its behavior. +- `UseStateForUnknown()`: Copies the prior state value, if not null. This is useful for reducing `(known after apply)` plan outputs for computed attributes which are known to not change over time. ### Creating Attribute Plan Modifiers