From 98e41bc34dad1f599dc54700299219dde4670adb Mon Sep 17 00:00:00 2001 From: Brian Flad Date: Tue, 2 Aug 2022 10:39:12 -0400 Subject: [PATCH 1/2] tfsdk: Migrate `RequiresReplace()`, `RequiresReplaceIf()`, and `UseStateForUnknown()` plan modifier functions to the `resource` package Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/132 Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/365 Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/366 Reference: https://github.com/hashicorp/terraform-plugin-framework/pull/432 Plan modification in the framework is a concept that only applies to managed resources via the protocol `PlanResourceChange` RPC. Following the migration of other `tfsdk` package types into separate `datasource`, `provider`, and `resource` packages, this change migrates the `RequiresReplace()`, `RequiresReplaceIf()` and `UseStateForUnknown()` plan modifier functions into the `resource` package. This change should be bundled in the same release as the package refactoring since it is similar in nature. This change has two immediate benefits: - Aligning the Go package placement tof these functions o hint to provider developers that these are only intended for managed resources. - For future refactoring this removes additional schema-based code imports from the `tfsdk` package, which could enable refactoring the schema logic into internal packages with potentially less breaking changes for provider developers by removing a source of import cycles. Provider developers should be able to update their code using find and replace operations using the table below: | Prior tfsdk Package Function | New resource Package Function | | --- | --- | | `tfsdk.RequiresReplace` | `resource.RequiresReplace` | | `tfsdk.RequiresReplaceIf` | `resource.RequiresReplaceIf` | | `tfsdk.UseStateForUnknown` | `resource.UseStateForUnknown` | To reduce framework maintainer review burden, this code migrations was primarily a lift and shift operation while most code and documentation updates were find and replace operations. The modifier types were unexported as exposing them should not be beneficial for provider developers and should help reduce the Go documentation surface area. The plan modifier unit testing was updated to use a `_test` package so it is verifying only exported functionality. --- .changelog/pending.txt | 3 + .../attribute_plan_modification_test.go | 25 +- .../fwserver/block_plan_modification_test.go | 33 +- resource/plan_modifiers.go | 346 ++++++++++++++++++ .../plan_modifiers_test.go | 264 ++++++------- tfsdk/attribute_plan_modification.go | 333 ----------------- .../framework/resources/plan-modification.mdx | 8 +- 7 files changed, 521 insertions(+), 491 deletions(-) create mode 100644 .changelog/pending.txt create mode 100644 resource/plan_modifiers.go rename tfsdk/attribute_plan_modification_test.go => resource/plan_modifiers_test.go (91%) diff --git a/.changelog/pending.txt b/.changelog/pending.txt new file mode 100644 index 000000000..aec83a485 --- /dev/null +++ b/.changelog/pending.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 From a49c78775f1b82a054a4e2a0904375f95134873e Mon Sep 17 00:00:00 2001 From: Brian Flad Date: Tue, 2 Aug 2022 10:42:01 -0400 Subject: [PATCH 2/2] Update CHANGELOG for #434 --- .changelog/{pending.txt => 434.txt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changelog/{pending.txt => 434.txt} (100%) diff --git a/.changelog/pending.txt b/.changelog/434.txt similarity index 100% rename from .changelog/pending.txt rename to .changelog/434.txt