diff --git a/.changelog/434.txt b/.changelog/434.txt new file mode 100644 index 000000000..aec83a485 --- /dev/null +++ b/.changelog/434.txt @@ -0,0 +1,3 @@ +```release-note:breaking-change +tfsdk: The `RequiresReplace()`, `RequiresReplaceIf()`, and `UseStateForUnknown()` plan modifier functions, which only apply to managed resources, have been moved to the `resource` package. +``` diff --git a/internal/fwserver/attribute_plan_modification_test.go b/internal/fwserver/attribute_plan_modification_test.go index 7d7326ae4..d4b9c8f1e 100644 --- a/internal/fwserver/attribute_plan_modification_test.go +++ b/internal/fwserver/attribute_plan_modification_test.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/testing/planmodifiers" "github.com/hashicorp/terraform-plugin-framework/internal/totftypes" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -849,7 +850,7 @@ func TestAttributeModifyPlan(t *testing.T) { Type: types.StringType, Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, }, }, @@ -869,7 +870,7 @@ func TestAttributeModifyPlan(t *testing.T) { Type: types.StringType, Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, }, }, @@ -889,7 +890,7 @@ func TestAttributeModifyPlan(t *testing.T) { Type: types.StringType, Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, }, }, @@ -937,7 +938,7 @@ func TestAttributeModifyPlan(t *testing.T) { Type: types.StringType, Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, }, }, @@ -957,7 +958,7 @@ func TestAttributeModifyPlan(t *testing.T) { Type: types.StringType, Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, }, }, @@ -977,7 +978,7 @@ func TestAttributeModifyPlan(t *testing.T) { Type: types.StringType, Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, }, }, @@ -1039,7 +1040,7 @@ func TestAttributeModifyPlan(t *testing.T) { Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ planmodifiers.TestAttrPlanValueModifierOne{}, - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, }, }, @@ -1059,7 +1060,7 @@ func TestAttributeModifyPlan(t *testing.T) { Type: types.StringType, Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), planmodifiers.TestAttrPlanValueModifierOne{}, }, }, @@ -1080,7 +1081,7 @@ func TestAttributeModifyPlan(t *testing.T) { Type: types.StringType, Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), planmodifiers.TestAttrPlanValueModifierOne{}, }, }, @@ -1129,7 +1130,7 @@ func TestAttributeModifyPlan(t *testing.T) { Type: types.StringType, Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), planmodifiers.TestRequiresReplaceFalseModifier{}, }, }, @@ -1150,7 +1151,7 @@ func TestAttributeModifyPlan(t *testing.T) { Type: types.StringType, Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), planmodifiers.TestRequiresReplaceFalseModifier{}, }, }, @@ -1171,7 +1172,7 @@ func TestAttributeModifyPlan(t *testing.T) { Type: types.StringType, Required: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), planmodifiers.TestRequiresReplaceFalseModifier{}, }, }, diff --git a/internal/fwserver/block_plan_modification_test.go b/internal/fwserver/block_plan_modification_test.go index 2291bc8ce..550e1ebe5 100644 --- a/internal/fwserver/block_plan_modification_test.go +++ b/internal/fwserver/block_plan_modification_test.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/testing/planmodifiers" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -208,7 +209,7 @@ func TestBlockModifyPlan(t *testing.T) { req: modifyAttributePlanRequest( path.Root("test"), schema([]tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, nil), modifyAttributePlanValues{ config: "newtestvalue", @@ -221,7 +222,7 @@ func TestBlockModifyPlan(t *testing.T) { Plan: tfsdk.Plan{ Raw: schemaTfValue("newtestvalue"), Schema: schema([]tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, nil), }, RequiresReplace: path.Paths{ @@ -233,7 +234,7 @@ func TestBlockModifyPlan(t *testing.T) { req: modifyAttributePlanRequest( path.Root("test"), schema([]tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, nil), modifyAttributePlanValues{ config: "newtestvalue", @@ -259,7 +260,7 @@ func TestBlockModifyPlan(t *testing.T) { Plan: tfsdk.Plan{ Raw: schemaTfValue("newtestvalue"), Schema: schema([]tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, nil), }, RequiresReplace: path.Paths{ @@ -271,7 +272,7 @@ func TestBlockModifyPlan(t *testing.T) { req: modifyAttributePlanRequest( path.Root("test"), schema([]tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), testBlockPlanModifierNullList{}, }, nil), modifyAttributePlanValues{ @@ -285,7 +286,7 @@ func TestBlockModifyPlan(t *testing.T) { Plan: tfsdk.Plan{ Raw: schemaNullTfValue, Schema: schema([]tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), testBlockPlanModifierNullList{}, }, nil), }, @@ -298,7 +299,7 @@ func TestBlockModifyPlan(t *testing.T) { req: modifyAttributePlanRequest( path.Root("test"), schema([]tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), planmodifiers.TestRequiresReplaceFalseModifier{}, }, nil), modifyAttributePlanValues{ @@ -312,7 +313,7 @@ func TestBlockModifyPlan(t *testing.T) { Plan: tfsdk.Plan{ Raw: schemaTfValue("newtestvalue"), Schema: schema([]tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), planmodifiers.TestRequiresReplaceFalseModifier{}, }, nil), }, @@ -531,7 +532,7 @@ func TestBlockModifyPlan(t *testing.T) { req: modifyAttributePlanRequest( path.Root("test"), schema(nil, []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }), modifyAttributePlanValues{ config: "newtestvalue", @@ -544,7 +545,7 @@ func TestBlockModifyPlan(t *testing.T) { Plan: tfsdk.Plan{ Raw: schemaTfValue("newtestvalue"), Schema: schema(nil, []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }), }, RequiresReplace: path.Paths{ @@ -556,7 +557,7 @@ func TestBlockModifyPlan(t *testing.T) { req: modifyAttributePlanRequest( path.Root("test"), schema(nil, []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }), modifyAttributePlanValues{ config: "newtestvalue", @@ -582,7 +583,7 @@ func TestBlockModifyPlan(t *testing.T) { Plan: tfsdk.Plan{ Raw: schemaTfValue("newtestvalue"), Schema: schema(nil, []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }), }, RequiresReplace: path.Paths{ @@ -594,7 +595,7 @@ func TestBlockModifyPlan(t *testing.T) { req: modifyAttributePlanRequest( path.Root("test"), schema(nil, []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), planmodifiers.TestAttrPlanValueModifierOne{}, }), modifyAttributePlanValues{ @@ -608,7 +609,7 @@ func TestBlockModifyPlan(t *testing.T) { Plan: tfsdk.Plan{ Raw: schemaTfValue("TESTATTRTWO"), Schema: schema(nil, []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), planmodifiers.TestAttrPlanValueModifierOne{}, }), }, @@ -621,7 +622,7 @@ func TestBlockModifyPlan(t *testing.T) { req: modifyAttributePlanRequest( path.Root("test"), schema(nil, []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), planmodifiers.TestRequiresReplaceFalseModifier{}, }), modifyAttributePlanValues{ @@ -635,7 +636,7 @@ func TestBlockModifyPlan(t *testing.T) { Plan: tfsdk.Plan{ Raw: schemaTfValue("newtestvalue"), Schema: schema(nil, []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), planmodifiers.TestRequiresReplaceFalseModifier{}, }), }, diff --git a/resource/plan_modifiers.go b/resource/plan_modifiers.go new file mode 100644 index 000000000..23f9f1677 --- /dev/null +++ b/resource/plan_modifiers.go @@ -0,0 +1,346 @@ +package resource + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/logging" + "github.com/hashicorp/terraform-plugin-framework/internal/totftypes" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// RequiresReplace returns an AttributePlanModifier specifying the attribute as +// requiring replacement. This behaviour is identical to the ForceNew behaviour +// in terraform-plugin-sdk and will result in the resource being destroyed and +// recreated when the following conditions are met: +// +// 1. The resource's state is not null; a null state indicates that we're +// creating a resource, and we never need to destroy and recreate a resource +// when we're creating it. +// +// 2. The resource's plan is not null; a null plan indicates that we're +// deleting a resource, and we never need to destroy and recreate a resource +// when we're deleting it. +// +// 3. The attribute's config is not null or the attribute is not computed; a +// computed attribute with a null config almost always means that the provider +// is changing the value, and practitioners are usually unpleasantly surprised +// when a resource is destroyed and recreated when their configuration hasn't +// changed. This has the unfortunate side effect that removing a computed field +// from the config will not trigger a destroy and recreate cycle, even when +// that is warranted. To get around this, provider developer can implement +// their own AttributePlanModifier that handles that behavior in the way that +// most makes sense for their use case. +// +// 4. The attribute's value in the plan does not match the attribute's value in +// the state. +func RequiresReplace() tfsdk.AttributePlanModifier { + return requiresReplaceModifier{} +} + +// requiresReplaceModifier is an AttributePlanModifier that sets RequiresReplace +// on the attribute. +type requiresReplaceModifier struct{} + +// Modify fills the AttributePlanModifier interface. It sets RequiresReplace on +// the response to true if the following criteria are met: +// +// 1. The resource's state is not null; a null state indicates that we're +// creating a resource, and we never need to destroy and recreate a resource +// when we're creating it. +// +// 2. The resource's plan is not null; a null plan indicates that we're +// deleting a resource, and we never need to destroy and recreate a resource +// when we're deleting it. +// +// 3. The attribute's config is not null or the attribute is not computed; a +// computed attribute with a null config almost always means that the provider +// is changing the value, and practitioners are usually unpleasantly surprised +// when a resource is destroyed and recreated when their configuration hasn't +// changed. This has the unfortunate side effect that removing a computed field +// from the config will not trigger a destroy and recreate cycle, even when +// that is warranted. To get around this, provider developer can implement +// their own AttributePlanModifier that handles that behavior in the way that +// most makes sense for their use case. +// +// 4. The attribute's value in the plan does not match the attribute's value in +// the state. +func (r requiresReplaceModifier) Modify(ctx context.Context, req tfsdk.ModifyAttributePlanRequest, resp *tfsdk.ModifyAttributePlanResponse) { + if req.AttributeConfig == nil || req.AttributePlan == nil || req.AttributeState == nil { + // shouldn't happen, but let's not panic if it does + return + } + + if req.State.Raw.IsNull() { + // if we're creating the resource, no need to delete and + // recreate it + return + } + + if req.Plan.Raw.IsNull() { + // if we're deleting the resource, no need to delete and + // recreate it + return + } + + // TODO: Remove after schema refactoring, Attribute is exposed in + // ModifyAttributePlanRequest, or Computed is exposed in + // ModifyAttributePlanRequest. + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/365 + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/389 + tftypesPath, diags := totftypes.AttributePath(ctx, req.AttributePath) + + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + attrSchema, err := req.State.Schema.AttributeAtPath(tftypesPath) + + // Path may lead to block instead of attribute. Blocks cannot be Computed. + // If ErrPathIsBlock, attrSchema.Computed will still be false later. + if err != nil && !errors.Is(err, tfsdk.ErrPathIsBlock) { + resp.Diagnostics.AddAttributeError(req.AttributePath, + "Error finding attribute schema", + fmt.Sprintf("An unexpected error was encountered retrieving the schema for this attribute. This is always a bug in the provider.\n\nError: %s", err), + ) + return + } + + if req.AttributeConfig.IsNull() && attrSchema.Computed { + // if the config is null and the attribute is computed, this + // could be an out of band change, don't require replace + return + } + + if req.AttributePlan.Equal(req.AttributeState) { + // if the plan and the state are in agreement, this attribute + // isn't changing, don't require replace + return + } + + resp.RequiresReplace = true +} + +// Description returns a human-readable description of the plan modifier. +func (r requiresReplaceModifier) Description(ctx context.Context) string { + return "If the value of this attribute changes, Terraform will destroy and recreate the resource." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (r requiresReplaceModifier) MarkdownDescription(ctx context.Context) string { + return "If the value of this attribute changes, Terraform will destroy and recreate the resource." +} + +// RequiresReplaceIf returns an AttributePlanModifier that mimics +// RequiresReplace, but only when the passed function `f` returns true. The +// resource will be destroyed and recreated if `f` returns true and the +// following conditions are met: +// +// 1. The resource's state is not null; a null state indicates that we're +// creating a resource, and we never need to destroy and recreate a resource +// when we're creating it. +// +// 2. The resource's plan is not null; a null plan indicates that we're +// deleting a resource, and we never need to destroy and recreate a resource +// when we're deleting it. +// +// 3. The attribute's config is not null or the attribute is not computed; a +// computed attribute with a null config almost always means that the provider +// is changing the value, and practitioners are usually unpleasantly surprised +// when a resource is destroyed and recreated when their configuration hasn't +// changed. This has the unfortunate side effect that removing a computed field +// from the config will not trigger a destroy and recreate cycle, even when +// that is warranted. To get around this, provider developer can implement +// their own AttributePlanModifier that handles that behavior in the way that +// most makes sense for their use case. +// +// 4. The attribute's value in the plan does not match the attribute's value in +// the state. +// +// If `f` does not return true, RequiresReplaceIf will *not* override prior +// AttributePlanModifiers' determination of whether the resource needs to be +// recreated or not. This allows for multiple RequiresReplaceIf (or other +// modifiers that sometimes set RequiresReplace) to be used on a single +// attribute without the last one in the list always determining the outcome. +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) tfsdk.AttributePlanModifier { + return requiresReplaceIfModifier{ + f: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(ctx context.Context, state, config attr.Value, path path.Path) (bool, diag.Diagnostics) + +// requiresReplaceIfModifier is an AttributePlanModifier that sets RequiresReplace +// on the attribute if the conditional function returns true. +type requiresReplaceIfModifier struct { + f RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Modify fills the AttributePlanModifier interface. It sets RequiresReplace on +// the response to true if the following criteria are met: +// +// 1. `f` returns true. If `f` returns false, the response will not be modified +// at all. +// +// 2. The resource's state is not null; a null state indicates that we're +// creating a resource, and we never need to destroy and recreate a resource +// when we're creating it. +// +// 3. The resource's plan is not null; a null plan indicates that we're +// deleting a resource, and we never need to destroy and recreate a resource +// when we're deleting it. +// +// 4. The attribute's config is not null or the attribute is not computed; a +// computed attribute with a null config almost always means that the provider +// is changing the value, and practitioners are usually unpleasantly surprised +// when a resource is destroyed and recreated when their configuration hasn't +// changed. This has the unfortunate side effect that removing a computed field +// from the config will not trigger a destroy and recreate cycle, even when +// that is warranted. To get around this, provider developer can implement +// their own AttributePlanModifier that handles that behavior in the way that +// most makes sense for their use case. +// +// 5. The attribute's value in the plan does not match the attribute's value in +// the state. +func (r requiresReplaceIfModifier) Modify(ctx context.Context, req tfsdk.ModifyAttributePlanRequest, resp *tfsdk.ModifyAttributePlanResponse) { + if req.AttributeConfig == nil || req.AttributePlan == nil || req.AttributeState == nil { + // shouldn't happen, but let's not panic if it does + return + } + + if req.State.Raw.IsNull() { + // if we're creating the resource, no need to delete and + // recreate it + return + } + + if req.Plan.Raw.IsNull() { + // if we're deleting the resource, no need to delete and + // recreate it + return + } + + // TODO: Remove after schema refactoring, Attribute is exposed in + // ModifyAttributePlanRequest, or Computed is exposed in + // ModifyAttributePlanRequest. + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/365 + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/389 + tftypesPath, diags := totftypes.AttributePath(ctx, req.AttributePath) + + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + attrSchema, err := req.State.Schema.AttributeAtPath(tftypesPath) + + // Path may lead to block instead of attribute. Blocks cannot be Computed. + // If ErrPathIsBlock, attrSchema.Computed will still be false later. + if err != nil && !errors.Is(err, tfsdk.ErrPathIsBlock) { + resp.Diagnostics.AddAttributeError(req.AttributePath, + "Error finding attribute schema", + fmt.Sprintf("An unexpected error was encountered retrieving the schema for this attribute. This is always a bug in the provider.\n\nError: %s", err), + ) + return + } + + if req.AttributeConfig.IsNull() && attrSchema.Computed { + // if the config is null and the attribute is computed, this + // could be an out of band change, don't require replace + return + } + + if req.AttributePlan.Equal(req.AttributeState) { + // if the plan and the state are in agreement, this attribute + // isn't changing, don't require replace + return + } + + res, diags := r.f(ctx, req.AttributeState, req.AttributeConfig, req.AttributePath) + resp.Diagnostics.Append(diags...) + + // If the function says to require replacing, we require replacing. + // If the function says not to, we don't change the value that prior + // plan modifiers may have set. + if res { + resp.RequiresReplace = true + } else if resp.RequiresReplace { + logging.FrameworkDebug(ctx, "Keeping previous attribute replacement requirement") + } +} + +// Description returns a human-readable description of the plan modifier. +func (r requiresReplaceIfModifier) Description(ctx context.Context) string { + return r.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (r requiresReplaceIfModifier) MarkdownDescription(ctx context.Context) string { + return r.markdownDescription +} + +// UseStateForUnknown returns an AttributePlanModifier that copies the prior +// state value for an attribute into that attribute's plan, if that state is +// non-null. +// +// Computed attributes without the UseStateForUnknown attribute plan modifier +// will have their value set to Unknown in the plan by the framework to prevent +// Terraform errors, so their value always will be displayed as "(known after +// apply)" in the CLI plan output. Using this plan modifier will instead +// display the prior state value in the plan, unless a prior plan modifier +// adjusts the value. +func UseStateForUnknown() tfsdk.AttributePlanModifier { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the UseStateForUnknown +// AttributePlanModifier. +type useStateForUnknownModifier struct{} + +// Modify copies the attribute's prior state to the attribute plan if the prior +// state value is not null. +func (r useStateForUnknownModifier) Modify(ctx context.Context, req tfsdk.ModifyAttributePlanRequest, resp *tfsdk.ModifyAttributePlanResponse) { + if req.AttributeState == nil || resp.AttributePlan == nil || req.AttributeConfig == nil { + return + } + + // if we have no state value, there's nothing to preserve + if req.AttributeState.IsNull() { + return + } + + // if it's not planned to be the unknown value, stick with the concrete plan + if !resp.AttributePlan.IsUnknown() { + return + } + + // if the config is the unknown value, use the unknown value otherwise, interpolation gets messed up + if req.AttributeConfig.IsUnknown() { + return + } + + resp.AttributePlan = req.AttributeState +} + +// Description returns a human-readable description of the plan modifier. +func (r useStateForUnknownModifier) Description(ctx 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 (r useStateForUnknownModifier) MarkdownDescription(ctx context.Context) string { + return "Once set, the value of this attribute in state will not change." +} diff --git a/tfsdk/attribute_plan_modification_test.go b/resource/plan_modifiers_test.go similarity index 91% rename from tfsdk/attribute_plan_modification_test.go rename to resource/plan_modifiers_test.go index 42db44371..8416fb3f7 100644 --- a/tfsdk/attribute_plan_modification_test.go +++ b/resource/plan_modifiers_test.go @@ -1,4 +1,4 @@ -package tfsdk +package resource_test import ( "context" @@ -8,6 +8,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -86,8 +88,8 @@ func TestUseStateForUnknownModifier(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - schema := Schema{ - Attributes: map[string]Attribute{ + schema := tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ "a": { Type: types.StringType, Optional: true, @@ -121,21 +123,21 @@ func TestUseStateForUnknownModifier(t *testing.T) { planVal = val } - req := ModifyAttributePlanRequest{ + req := tfsdk.ModifyAttributePlanRequest{ AttributePath: path.Empty(), - Config: Config{ + Config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "a": configVal, }), }, - State: State{ + State: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "a": stateVal, }), }, - Plan: Plan{ + Plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "a": planVal, @@ -144,12 +146,12 @@ func TestUseStateForUnknownModifier(t *testing.T) { AttributeConfig: tc.config, AttributeState: tc.state, AttributePlan: tc.plan, - ProviderMeta: Config{}, + ProviderMeta: tfsdk.Config{}, } - resp := &ModifyAttributePlanResponse{ + resp := &tfsdk.ModifyAttributePlanResponse{ AttributePlan: req.AttributePlan, } - modifier := UseStateForUnknown() + modifier := resource.UseStateForUnknown() modifier.Modify(context.Background(), req, resp) if resp.Diagnostics.HasError() { @@ -166,16 +168,16 @@ func TestRequiresReplaceModifier(t *testing.T) { t.Parallel() type testCase struct { - state State - plan Plan - config Config + state tfsdk.State + plan tfsdk.Plan + config tfsdk.Config path path.Path expectedPlan attr.Value expectedRR bool } - schema := Schema{ - Attributes: map[string]Attribute{ + schema := tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ "optional-computed": { Type: types.StringType, Optional: true, @@ -188,10 +190,10 @@ func TestRequiresReplaceModifier(t *testing.T) { }, } - blockSchema := Schema{ - Blocks: map[string]Block{ + blockSchema := tfsdk.Schema{ + Blocks: map[string]tfsdk.Block{ "block": { - Attributes: map[string]Attribute{ + Attributes: map[string]tfsdk.Attribute{ "optional-computed": { Type: types.StringType, Optional: true, @@ -202,7 +204,7 @@ func TestRequiresReplaceModifier(t *testing.T) { Optional: true, }, }, - NestingMode: BlockNestingModeList, + NestingMode: tfsdk.BlockNestingModeList, }, }, } @@ -211,18 +213,18 @@ func TestRequiresReplaceModifier(t *testing.T) { "null-state": { // when we first create the resource, it shouldn't // require replacing immediately - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), nil), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -240,18 +242,18 @@ func TestRequiresReplaceModifier(t *testing.T) { // Terraform doesn't usually ask for provider input on // the plan when destroying resources, but in case it // does, let's make sure we handle it right - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), nil), }, - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), nil), }, @@ -263,21 +265,21 @@ func TestRequiresReplaceModifier(t *testing.T) { // make sure we're not confusing an attribute going // from null to a value with the resource getting // created - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, nil), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -292,21 +294,21 @@ func TestRequiresReplaceModifier(t *testing.T) { // make sure we're not confusing an attribute going // from a value to null with the resource getting // destroyed - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, nil), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -320,21 +322,21 @@ func TestRequiresReplaceModifier(t *testing.T) { "known-state-change": { // when updating the attribute, if it has changed, it // should require replacing - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "quux"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -348,21 +350,21 @@ func TestRequiresReplaceModifier(t *testing.T) { "known-state-no-change": { // when the attribute hasn't changed, it shouldn't // require replacing - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "quux"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -383,21 +385,21 @@ func TestRequiresReplaceModifier(t *testing.T) { // practitioners pretty much never expect the resource // to be recreated if the provider is the one changing // the value. - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, nil), @@ -419,21 +421,21 @@ func TestRequiresReplaceModifier(t *testing.T) { // this test is technically covered by // null-attribute-plan, but let's duplicate it just to // be explicit about what each case is actually testing - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, nil), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -445,7 +447,7 @@ func TestRequiresReplaceModifier(t *testing.T) { expectedRR: true, }, "block-no-change": { - state: State{ + state: tfsdk.State{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -469,7 +471,7 @@ func TestRequiresReplaceModifier(t *testing.T) { }), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -493,7 +495,7 @@ func TestRequiresReplaceModifier(t *testing.T) { }), }), }, - config: Config{ + config: tfsdk.Config{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -541,7 +543,7 @@ func TestRequiresReplaceModifier(t *testing.T) { expectedRR: false, }, "block-element-count-change": { - state: State{ + state: tfsdk.State{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -565,7 +567,7 @@ func TestRequiresReplaceModifier(t *testing.T) { }), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -598,7 +600,7 @@ func TestRequiresReplaceModifier(t *testing.T) { }), }), }, - config: Config{ + config: tfsdk.Config{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -665,7 +667,7 @@ func TestRequiresReplaceModifier(t *testing.T) { expectedRR: true, }, "block-nested-attribute-change": { - state: State{ + state: tfsdk.State{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -689,7 +691,7 @@ func TestRequiresReplaceModifier(t *testing.T) { }), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -713,7 +715,7 @@ func TestRequiresReplaceModifier(t *testing.T) { }), }), }, - config: Config{ + config: tfsdk.Config{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -767,22 +769,27 @@ func TestRequiresReplaceModifier(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - attrConfig, diags := tc.config.getAttributeValue(context.Background(), tc.path) - if diags.HasError() { - t.Fatalf("Got unexpected diagnostics: %s", diags) + var attrConfig, attrPlan, attrState attr.Value + + if !tc.config.Raw.IsNull() { + if diags := tc.config.GetAttribute(context.Background(), tc.path, &attrConfig); diags.HasError() { + t.Fatalf("Got unexpected diagnostics: %s", diags) + } } - attrState, diags := tc.state.getAttributeValue(context.Background(), tc.path) - if diags.HasError() { - t.Fatalf("Got unexpected diagnostics: %s", diags) + if !tc.state.Raw.IsNull() { + if diags := tc.state.GetAttribute(context.Background(), tc.path, &attrState); diags.HasError() { + t.Fatalf("Got unexpected diagnostics: %s", diags) + } } - attrPlan, diags := tc.plan.getAttributeValue(context.Background(), tc.path) - if diags.HasError() { - t.Fatalf("Got unexpected diagnostics: %s", diags) + if !tc.plan.Raw.IsNull() { + if diags := tc.plan.GetAttribute(context.Background(), tc.path, &attrPlan); diags.HasError() { + t.Fatalf("Got unexpected diagnostics: %s", diags) + } } - req := ModifyAttributePlanRequest{ + req := tfsdk.ModifyAttributePlanRequest{ AttributePath: tc.path, Config: tc.config, State: tc.state, @@ -790,12 +797,12 @@ func TestRequiresReplaceModifier(t *testing.T) { AttributeConfig: attrConfig, AttributeState: attrState, AttributePlan: attrPlan, - ProviderMeta: Config{}, + ProviderMeta: tfsdk.Config{}, } - resp := &ModifyAttributePlanResponse{ + resp := &tfsdk.ModifyAttributePlanResponse{ AttributePlan: req.AttributePlan, } - modifier := RequiresReplace() + modifier := resource.RequiresReplace() modifier.Modify(context.Background(), req, resp) if resp.Diagnostics.HasError() { @@ -815,9 +822,9 @@ func TestRequiresReplaceIfModifier(t *testing.T) { t.Parallel() type testCase struct { - state State - plan Plan - config Config + state tfsdk.State + plan tfsdk.Plan + config tfsdk.Config priorRR bool path path.Path ifReturn bool @@ -825,8 +832,8 @@ func TestRequiresReplaceIfModifier(t *testing.T) { expectedRR bool } - schema := Schema{ - Attributes: map[string]Attribute{ + schema := tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ "optional-computed": { Type: types.StringType, Optional: true, @@ -839,10 +846,10 @@ func TestRequiresReplaceIfModifier(t *testing.T) { }, } - blockSchema := Schema{ - Blocks: map[string]Block{ + blockSchema := tfsdk.Schema{ + Blocks: map[string]tfsdk.Block{ "block": { - Attributes: map[string]Attribute{ + Attributes: map[string]tfsdk.Attribute{ "optional-computed": { Type: types.StringType, Optional: true, @@ -853,7 +860,7 @@ func TestRequiresReplaceIfModifier(t *testing.T) { Optional: true, }, }, - NestingMode: BlockNestingModeList, + NestingMode: tfsdk.BlockNestingModeList, }, }, } @@ -862,18 +869,18 @@ func TestRequiresReplaceIfModifier(t *testing.T) { "null-state": { // when we first create the resource, it shouldn't // require replacing immediately - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), nil), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -893,18 +900,18 @@ func TestRequiresReplaceIfModifier(t *testing.T) { // Terraform doesn't usually ask for provider input on // the plan when destroying resources, but in case it // does, let's make sure we handle it right - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), nil), }, - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), nil), }, @@ -918,21 +925,21 @@ func TestRequiresReplaceIfModifier(t *testing.T) { // make sure we're not confusing an attribute going // from null to a value with the resource getting // created - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, nil), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -949,21 +956,21 @@ func TestRequiresReplaceIfModifier(t *testing.T) { // make sure we're not confusing an attribute going // from a value to null with the resource getting // destroyed - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, nil), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -980,21 +987,21 @@ func TestRequiresReplaceIfModifier(t *testing.T) { // when updating the attribute, if it has changed and // the function returns true, it should require // replacing - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "quux"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -1011,21 +1018,21 @@ func TestRequiresReplaceIfModifier(t *testing.T) { // when updating the attribute, if it has changed and // the function returns false, it should not require // replacing - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "quux"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -1043,21 +1050,21 @@ func TestRequiresReplaceIfModifier(t *testing.T) { // the function returns false, but a prior plan // modifier already marked the resource as needing to // be recreated, we shouldn't override that - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "quux"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -1073,21 +1080,21 @@ func TestRequiresReplaceIfModifier(t *testing.T) { "known-state-no-change": { // when the attribute hasn't changed, it shouldn't // require replacing - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "quux"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -1110,21 +1117,21 @@ func TestRequiresReplaceIfModifier(t *testing.T) { // practitioners pretty much never expect the resource // to be recreated if the provider is the one changing // the value. - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, nil), @@ -1148,21 +1155,21 @@ func TestRequiresReplaceIfModifier(t *testing.T) { // this test is technically covered by // null-attribute-plan, but let's duplicate it just to // be explicit about what each case is actually testing - state: State{ + state: tfsdk.State{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, "bar"), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), "optional": tftypes.NewValue(tftypes.String, nil), }), }, - config: Config{ + config: tfsdk.Config{ Schema: schema, Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ "optional-computed": tftypes.NewValue(tftypes.String, "foo"), @@ -1176,7 +1183,7 @@ func TestRequiresReplaceIfModifier(t *testing.T) { expectedRR: true, }, "block-no-change": { - state: State{ + state: tfsdk.State{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -1200,7 +1207,7 @@ func TestRequiresReplaceIfModifier(t *testing.T) { }), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -1224,7 +1231,7 @@ func TestRequiresReplaceIfModifier(t *testing.T) { }), }), }, - config: Config{ + config: tfsdk.Config{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -1273,7 +1280,7 @@ func TestRequiresReplaceIfModifier(t *testing.T) { expectedRR: false, }, "block-element-count-change": { - state: State{ + state: tfsdk.State{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -1297,7 +1304,7 @@ func TestRequiresReplaceIfModifier(t *testing.T) { }), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -1330,7 +1337,7 @@ func TestRequiresReplaceIfModifier(t *testing.T) { }), }), }, - config: Config{ + config: tfsdk.Config{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -1398,7 +1405,7 @@ func TestRequiresReplaceIfModifier(t *testing.T) { expectedRR: true, }, "block-nested-attribute-change": { - state: State{ + state: tfsdk.State{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -1422,7 +1429,7 @@ func TestRequiresReplaceIfModifier(t *testing.T) { }), }), }, - plan: Plan{ + plan: tfsdk.Plan{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -1446,7 +1453,7 @@ func TestRequiresReplaceIfModifier(t *testing.T) { }), }), }, - config: Config{ + config: tfsdk.Config{ Schema: blockSchema, Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ "block": tftypes.NewValue( @@ -1501,22 +1508,27 @@ func TestRequiresReplaceIfModifier(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - attrConfig, diags := tc.config.getAttributeValue(context.Background(), tc.path) - if diags.HasError() { - t.Fatalf("Got unexpected diagnostics: %s", diags) + var attrConfig, attrPlan, attrState attr.Value + + if !tc.config.Raw.IsNull() { + if diags := tc.config.GetAttribute(context.Background(), tc.path, &attrConfig); diags.HasError() { + t.Fatalf("Got unexpected diagnostics: %s", diags) + } } - attrState, diags := tc.state.getAttributeValue(context.Background(), tc.path) - if diags.HasError() { - t.Fatalf("Got unexpected diagnostics: %s", diags) + if !tc.state.Raw.IsNull() { + if diags := tc.state.GetAttribute(context.Background(), tc.path, &attrState); diags.HasError() { + t.Fatalf("Got unexpected diagnostics: %s", diags) + } } - attrPlan, diags := tc.plan.getAttributeValue(context.Background(), tc.path) - if diags.HasError() { - t.Fatalf("Got unexpected diagnostics: %s", diags) + if !tc.plan.Raw.IsNull() { + if diags := tc.plan.GetAttribute(context.Background(), tc.path, &attrPlan); diags.HasError() { + t.Fatalf("Got unexpected diagnostics: %s", diags) + } } - req := ModifyAttributePlanRequest{ + req := tfsdk.ModifyAttributePlanRequest{ AttributePath: tc.path, Config: tc.config, State: tc.state, @@ -1524,13 +1536,13 @@ func TestRequiresReplaceIfModifier(t *testing.T) { AttributeConfig: attrConfig, AttributeState: attrState, AttributePlan: attrPlan, - ProviderMeta: Config{}, + ProviderMeta: tfsdk.Config{}, } - resp := &ModifyAttributePlanResponse{ + resp := &tfsdk.ModifyAttributePlanResponse{ AttributePlan: req.AttributePlan, RequiresReplace: tc.priorRR, } - modifier := RequiresReplaceIf(func(ctx context.Context, state, config attr.Value, path path.Path) (bool, diag.Diagnostics) { + modifier := resource.RequiresReplaceIf(func(ctx context.Context, state, config attr.Value, path path.Path) (bool, diag.Diagnostics) { return tc.ifReturn, nil }, "", "") diff --git a/tfsdk/attribute_plan_modification.go b/tfsdk/attribute_plan_modification.go index d4f0fd8ec..5dad778e7 100644 --- a/tfsdk/attribute_plan_modification.go +++ b/tfsdk/attribute_plan_modification.go @@ -2,13 +2,9 @@ package tfsdk import ( "context" - "errors" - "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/internal/logging" - "github.com/hashicorp/terraform-plugin-framework/internal/totftypes" "github.com/hashicorp/terraform-plugin-framework/path" ) @@ -55,335 +51,6 @@ type AttributePlanModifier interface { // order. type AttributePlanModifiers []AttributePlanModifier -// RequiresReplace returns an AttributePlanModifier specifying the attribute as -// requiring replacement. This behaviour is identical to the ForceNew behaviour -// in terraform-plugin-sdk and will result in the resource being destroyed and -// recreated when the following conditions are met: -// -// 1. The resource's state is not null; a null state indicates that we're -// creating a resource, and we never need to destroy and recreate a resource -// when we're creating it. -// -// 2. The resource's plan is not null; a null plan indicates that we're -// deleting a resource, and we never need to destroy and recreate a resource -// when we're deleting it. -// -// 3. The attribute's config is not null or the attribute is not computed; a -// computed attribute with a null config almost always means that the provider -// is changing the value, and practitioners are usually unpleasantly surprised -// when a resource is destroyed and recreated when their configuration hasn't -// changed. This has the unfortunate side effect that removing a computed field -// from the config will not trigger a destroy and recreate cycle, even when -// that is warranted. To get around this, provider developer can implement -// their own AttributePlanModifier that handles that behavior in the way that -// most makes sense for their use case. -// -// 4. The attribute's value in the plan does not match the attribute's value in -// the state. -func RequiresReplace() AttributePlanModifier { - return RequiresReplaceModifier{} -} - -// RequiresReplaceModifier is an AttributePlanModifier that sets RequiresReplace -// on the attribute. -type RequiresReplaceModifier struct{} - -// Modify fills the AttributePlanModifier interface. It sets RequiresReplace on -// the response to true if the following criteria are met: -// -// 1. The resource's state is not null; a null state indicates that we're -// creating a resource, and we never need to destroy and recreate a resource -// when we're creating it. -// -// 2. The resource's plan is not null; a null plan indicates that we're -// deleting a resource, and we never need to destroy and recreate a resource -// when we're deleting it. -// -// 3. The attribute's config is not null or the attribute is not computed; a -// computed attribute with a null config almost always means that the provider -// is changing the value, and practitioners are usually unpleasantly surprised -// when a resource is destroyed and recreated when their configuration hasn't -// changed. This has the unfortunate side effect that removing a computed field -// from the config will not trigger a destroy and recreate cycle, even when -// that is warranted. To get around this, provider developer can implement -// their own AttributePlanModifier that handles that behavior in the way that -// most makes sense for their use case. -// -// 4. The attribute's value in the plan does not match the attribute's value in -// the state. -func (r RequiresReplaceModifier) Modify(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) { - if req.AttributeConfig == nil || req.AttributePlan == nil || req.AttributeState == nil { - // shouldn't happen, but let's not panic if it does - return - } - - if req.State.Raw.IsNull() { - // if we're creating the resource, no need to delete and - // recreate it - return - } - - if req.Plan.Raw.IsNull() { - // if we're deleting the resource, no need to delete and - // recreate it - return - } - - // TODO: Remove after schema refactoring, Attribute is exposed in - // ModifyAttributePlanRequest, or Computed is exposed in - // ModifyAttributePlanRequest. - // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/365 - // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/389 - tftypesPath, diags := totftypes.AttributePath(ctx, req.AttributePath) - - resp.Diagnostics.Append(diags...) - - if resp.Diagnostics.HasError() { - return - } - - attrSchema, err := req.State.Schema.AttributeAtPath(tftypesPath) - - // Path may lead to block instead of attribute. Blocks cannot be Computed. - // If ErrPathIsBlock, attrSchema.Computed will still be false later. - if err != nil && !errors.Is(err, ErrPathIsBlock) { - resp.Diagnostics.AddAttributeError(req.AttributePath, - "Error finding attribute schema", - fmt.Sprintf("An unexpected error was encountered retrieving the schema for this attribute. This is always a bug in the provider.\n\nError: %s", err), - ) - return - } - - if req.AttributeConfig.IsNull() && attrSchema.Computed { - // if the config is null and the attribute is computed, this - // could be an out of band change, don't require replace - return - } - - if req.AttributePlan.Equal(req.AttributeState) { - // if the plan and the state are in agreement, this attribute - // isn't changing, don't require replace - return - } - - resp.RequiresReplace = true -} - -// Description returns a human-readable description of the plan modifier. -func (r RequiresReplaceModifier) Description(ctx context.Context) string { - return "If the value of this attribute changes, Terraform will destroy and recreate the resource." -} - -// MarkdownDescription returns a markdown description of the plan modifier. -func (r RequiresReplaceModifier) MarkdownDescription(ctx context.Context) string { - return "If the value of this attribute changes, Terraform will destroy and recreate the resource." -} - -// RequiresReplaceIf returns an AttributePlanModifier that mimics -// RequiresReplace, but only when the passed function `f` returns true. The -// resource will be destroyed and recreated if `f` returns true and the -// following conditions are met: -// -// 1. The resource's state is not null; a null state indicates that we're -// creating a resource, and we never need to destroy and recreate a resource -// when we're creating it. -// -// 2. The resource's plan is not null; a null plan indicates that we're -// deleting a resource, and we never need to destroy and recreate a resource -// when we're deleting it. -// -// 3. The attribute's config is not null or the attribute is not computed; a -// computed attribute with a null config almost always means that the provider -// is changing the value, and practitioners are usually unpleasantly surprised -// when a resource is destroyed and recreated when their configuration hasn't -// changed. This has the unfortunate side effect that removing a computed field -// from the config will not trigger a destroy and recreate cycle, even when -// that is warranted. To get around this, provider developer can implement -// their own AttributePlanModifier that handles that behavior in the way that -// most makes sense for their use case. -// -// 4. The attribute's value in the plan does not match the attribute's value in -// the state. -// -// If `f` does not return true, RequiresReplaceIf will *not* override prior -// AttributePlanModifiers' determination of whether the resource needs to be -// recreated or not. This allows for multiple RequiresReplaceIf (or other -// modifiers that sometimes set RequiresReplace) to be used on a single -// attribute without the last one in the list always determining the outcome. -func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) AttributePlanModifier { - return RequiresReplaceIfModifier{ - f: f, - description: description, - markdownDescription: markdownDescription, - } -} - -// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf -// plan modifier to determine whether the attribute requires replacement. -type RequiresReplaceIfFunc func(ctx context.Context, state, config attr.Value, path path.Path) (bool, diag.Diagnostics) - -// RequiresReplaceIfModifier is an AttributePlanModifier that sets RequiresReplace -// on the attribute if the conditional function returns true. -type RequiresReplaceIfModifier struct { - f RequiresReplaceIfFunc - description string - markdownDescription string -} - -// Modify fills the AttributePlanModifier interface. It sets RequiresReplace on -// the response to true if the following criteria are met: -// -// 1. `f` returns true. If `f` returns false, the response will not be modified -// at all. -// -// 2. The resource's state is not null; a null state indicates that we're -// creating a resource, and we never need to destroy and recreate a resource -// when we're creating it. -// -// 3. The resource's plan is not null; a null plan indicates that we're -// deleting a resource, and we never need to destroy and recreate a resource -// when we're deleting it. -// -// 4. The attribute's config is not null or the attribute is not computed; a -// computed attribute with a null config almost always means that the provider -// is changing the value, and practitioners are usually unpleasantly surprised -// when a resource is destroyed and recreated when their configuration hasn't -// changed. This has the unfortunate side effect that removing a computed field -// from the config will not trigger a destroy and recreate cycle, even when -// that is warranted. To get around this, provider developer can implement -// their own AttributePlanModifier that handles that behavior in the way that -// most makes sense for their use case. -// -// 5. The attribute's value in the plan does not match the attribute's value in -// the state. -func (r RequiresReplaceIfModifier) Modify(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) { - if req.AttributeConfig == nil || req.AttributePlan == nil || req.AttributeState == nil { - // shouldn't happen, but let's not panic if it does - return - } - - if req.State.Raw.IsNull() { - // if we're creating the resource, no need to delete and - // recreate it - return - } - - if req.Plan.Raw.IsNull() { - // if we're deleting the resource, no need to delete and - // recreate it - return - } - - // TODO: Remove after schema refactoring, Attribute is exposed in - // ModifyAttributePlanRequest, or Computed is exposed in - // ModifyAttributePlanRequest. - // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/365 - // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/389 - tftypesPath, diags := totftypes.AttributePath(ctx, req.AttributePath) - - resp.Diagnostics.Append(diags...) - - if resp.Diagnostics.HasError() { - return - } - - attrSchema, err := req.State.Schema.AttributeAtPath(tftypesPath) - - // Path may lead to block instead of attribute. Blocks cannot be Computed. - // If ErrPathIsBlock, attrSchema.Computed will still be false later. - if err != nil && !errors.Is(err, ErrPathIsBlock) { - resp.Diagnostics.AddAttributeError(req.AttributePath, - "Error finding attribute schema", - fmt.Sprintf("An unexpected error was encountered retrieving the schema for this attribute. This is always a bug in the provider.\n\nError: %s", err), - ) - return - } - - if req.AttributeConfig.IsNull() && attrSchema.Computed { - // if the config is null and the attribute is computed, this - // could be an out of band change, don't require replace - return - } - - if req.AttributePlan.Equal(req.AttributeState) { - // if the plan and the state are in agreement, this attribute - // isn't changing, don't require replace - return - } - - res, diags := r.f(ctx, req.AttributeState, req.AttributeConfig, req.AttributePath) - resp.Diagnostics.Append(diags...) - - // If the function says to require replacing, we require replacing. - // If the function says not to, we don't change the value that prior - // plan modifiers may have set. - if res { - resp.RequiresReplace = true - } else if resp.RequiresReplace { - logging.FrameworkDebug(ctx, "Keeping previous attribute replacement requirement") - } -} - -// Description returns a human-readable description of the plan modifier. -func (r RequiresReplaceIfModifier) Description(ctx context.Context) string { - return r.description -} - -// MarkdownDescription returns a markdown description of the plan modifier. -func (r RequiresReplaceIfModifier) MarkdownDescription(ctx context.Context) string { - return r.markdownDescription -} - -// UseStateForUnknown returns a UseStateForUnknownModifier. -func UseStateForUnknown() AttributePlanModifier { - return UseStateForUnknownModifier{} -} - -// UseStateForUnknownModifier is an AttributePlanModifier that copies the prior state -// value for an attribute into that attribute's plan, if that state is non-null. -// -// Computed attributes without the UseStateForUnknown attribute plan modifier will -// have their value set to Unknown in the plan, so their value always will be -// displayed as "(known after apply)" in the CLI plan output. -// If this plan modifier is used, the prior state value will be displayed in -// the plan instead unless a prior plan modifier adjusts the value. -type UseStateForUnknownModifier struct{} - -// Modify copies the attribute's prior state to the attribute plan if the prior -// state value is not null. -func (r UseStateForUnknownModifier) Modify(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) { - if req.AttributeState == nil || resp.AttributePlan == nil || req.AttributeConfig == nil { - return - } - - // if we have no state value, there's nothing to preserve - if req.AttributeState.IsNull() { - return - } - - // if it's not planned to be the unknown value, stick with the concrete plan - if !resp.AttributePlan.IsUnknown() { - return - } - - // if the config is the unknown value, use the unknown value otherwise, interpolation gets messed up - if req.AttributeConfig.IsUnknown() { - return - } - - resp.AttributePlan = req.AttributeState -} - -// Description returns a human-readable description of the plan modifier. -func (r UseStateForUnknownModifier) Description(ctx 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 (r UseStateForUnknownModifier) MarkdownDescription(ctx context.Context) string { - return "Once set, the value of this attribute in state will not change." -} - // ModifyAttributePlanRequest represents a request for the provider to modify an // attribute value, or mark it as requiring replacement, at plan time. An // instance of this request struct is supplied as an argument to the Modify diff --git a/website/docs/plugin/framework/resources/plan-modification.mdx b/website/docs/plugin/framework/resources/plan-modification.mdx index d8f08f0c2..95d652d43 100644 --- a/website/docs/plugin/framework/resources/plan-modification.mdx +++ b/website/docs/plugin/framework/resources/plan-modification.mdx @@ -39,7 +39,7 @@ tfsdk.Attribute{ // ... other Attribute configuration ... PlanModifiers: []AttributePlanModifiers{ - tfsdk.RequiresReplace(), + resource.RequiresReplace(), }, } ``` @@ -50,9 +50,9 @@ If defined, plan modifiers are applied to the current attribute. If any nested a The framework implements some common use case modifiers: -- [`tfsdk.RequiresReplace()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/tfsdk#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. -- [`tfsdk.RequiresReplaceIf()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/tfsdk#RequiresReplaceIf): Similar to `tfsdk.RequiresReplace()`, however it also accepts provider-defined conditional logic. Refer to the Go documentation for full details on its behavior. -- [`tfsdk.UseStateForUnknown()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/tfsdk#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. +- [`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. ### Creating Attribute Plan Modifiers