diff --git a/resource/schema/boolplanmodifier/use_state_for_unknown_if.go b/resource/schema/boolplanmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..412fa128a --- /dev/null +++ b/resource/schema/boolplanmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.Bool { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyBool implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/boolplanmodifier/use_state_for_unknown_if_func.go b/resource/schema/boolplanmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..0a5ff8096 --- /dev/null +++ b/resource/schema/boolplanmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.BoolRequest, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/boolplanmodifier/use_state_for_unknown_if_test.go b/resource/schema/boolplanmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..907fa4442 --- /dev/null +++ b/resource/schema/boolplanmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,207 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package boolplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifyBool(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.BoolRequest + ifFunc boolplanmodifier.UseStateForUnknownIfFunc + expected *planmodifier.BoolResponse + }{ + "null-state": { + // when we first create the resource, use the unknown + // value + request: planmodifier.BoolRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Bool, + }, + }, + nil, + ), + }, + StateValue: types.BoolNull(), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + request: planmodifier.BoolRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Bool, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Bool, true), + }, + ), + }, + StateValue: types.BoolValue(true), + PlanValue: types.BoolValue(false), + ConfigValue: types.BoolNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(false), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + // this is the situation we want to preserve the state + // in when condition is true + request: planmodifier.BoolRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Bool, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Bool, true), + }, + ), + }, + StateValue: types.BoolValue(true), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(true), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + // this is the situation we want to keep unknown + // when condition is false + request: planmodifier.BoolRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Bool, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Bool, true), + }, + ), + }, + StateValue: types.BoolValue(true), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown(), + }, + }, + "null-state-value-unknown-plan-if-true": { + // Null state values are still known, so we should preserve this as well. + request: planmodifier.BoolRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Bool, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Bool, nil), + }, + ), + }, + StateValue: types.BoolNull(), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolNull(), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.BoolRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Bool, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Bool, true), + }, + ), + }, + StateValue: types.BoolValue(true), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolUnknown(), + }, + ifFunc: func(ctx context.Context, req planmodifier.BoolRequest, resp *boolplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.BoolResponse{ + PlanValue: testCase.request.PlanValue, + } + + boolplanmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifyBool(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/dynamicplanmodifier/use_state_for_unknown_if.go b/resource/schema/dynamicplanmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..d72a3c682 --- /dev/null +++ b/resource/schema/dynamicplanmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.Dynamic { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyDynamic implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifyDynamic(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/dynamicplanmodifier/use_state_for_unknown_if_func.go b/resource/schema/dynamicplanmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..c6b3a4892 --- /dev/null +++ b/resource/schema/dynamicplanmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.DynamicRequest, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/dynamicplanmodifier/use_state_for_unknown_if_test.go b/resource/schema/dynamicplanmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..580948622 --- /dev/null +++ b/resource/schema/dynamicplanmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,191 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifyDynamic(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.DynamicRequest + ifFunc dynamicplanmodifier.UseStateForUnknownIfFunc + expected *planmodifier.DynamicResponse + }{ + "null-state": { + request: planmodifier.DynamicRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.DynamicPseudoType, + }, + }, + nil, + ), + }, + StateValue: types.DynamicNull(), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicUnknown(), + }, + }, + "known-plan": { + request: planmodifier.DynamicRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + StateValue: types.DynamicValue(types.StringValue("test")), + PlanValue: types.DynamicValue(types.StringValue("updated")), + ConfigValue: types.DynamicNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("updated")), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + request: planmodifier.DynamicRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + StateValue: types.DynamicValue(types.StringValue("test")), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("test")), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + request: planmodifier.DynamicRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + StateValue: types.DynamicValue(types.StringValue("test")), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicUnknown(), + }, + }, + "null-state-value-unknown-plan-if-true": { + request: planmodifier.DynamicRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + }, + StateValue: types.DynamicNull(), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicNull(), + }, + }, + "unknown-config": { + request: planmodifier.DynamicRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + StateValue: types.DynamicValue(types.StringValue("test")), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicUnknown(), + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicUnknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.DynamicResponse{ + PlanValue: testCase.request.PlanValue, + } + + dynamicplanmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifyDynamic(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/float32planmodifier/use_state_for_unknown_if.go b/resource/schema/float32planmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..1450e2277 --- /dev/null +++ b/resource/schema/float32planmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.Float32 { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyFloat32 implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifyFloat32(ctx context.Context, req planmodifier.Float32Request, resp *planmodifier.Float32Response) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/float32planmodifier/use_state_for_unknown_if_func.go b/resource/schema/float32planmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..370dc7ac4 --- /dev/null +++ b/resource/schema/float32planmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.Float32Request, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/float32planmodifier/use_state_for_unknown_if_test.go b/resource/schema/float32planmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..e304816b1 --- /dev/null +++ b/resource/schema/float32planmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,191 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/float32planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifyFloat32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Float32Request + ifFunc float32planmodifier.UseStateForUnknownIfFunc + expected *planmodifier.Float32Response + }{ + "null-state": { + request: planmodifier.Float32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + nil, + ), + }, + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float32Request, resp *float32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown(), + }, + }, + "known-plan": { + request: planmodifier.Float32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.Float32Value(123.45), + PlanValue: types.Float32Value(456.78), + ConfigValue: types.Float32Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float32Request, resp *float32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Value(456.78), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + request: planmodifier.Float32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.Float32Value(123.45), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float32Request, resp *float32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Value(123.45), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + request: planmodifier.Float32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.Float32Value(123.45), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float32Request, resp *float32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown(), + }, + }, + "null-state-value-unknown-plan-if-true": { + request: planmodifier.Float32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, nil), + }, + ), + }, + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float32Request, resp *float32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Null(), + }, + }, + "unknown-config": { + request: planmodifier.Float32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.Float32Value(123.45), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Unknown(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float32Request, resp *float32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float32Response{ + PlanValue: testCase.request.PlanValue, + } + + float32planmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifyFloat32(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/float64planmodifier/use_state_for_unknown_if.go b/resource/schema/float64planmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..a0a2d7e25 --- /dev/null +++ b/resource/schema/float64planmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.Float64 { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyFloat64 implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifyFloat64(ctx context.Context, req planmodifier.Float64Request, resp *planmodifier.Float64Response) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/float64planmodifier/use_state_for_unknown_if_func.go b/resource/schema/float64planmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..60e1b3eda --- /dev/null +++ b/resource/schema/float64planmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.Float64Request, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/float64planmodifier/use_state_for_unknown_if_test.go b/resource/schema/float64planmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..ad434cf41 --- /dev/null +++ b/resource/schema/float64planmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,191 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/float64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifyFloat64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Float64Request + ifFunc float64planmodifier.UseStateForUnknownIfFunc + expected *planmodifier.Float64Response + }{ + "null-state": { + request: planmodifier.Float64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + nil, + ), + }, + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + "known-plan": { + request: planmodifier.Float64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.Float64Value(123.45), + PlanValue: types.Float64Value(456.78), + ConfigValue: types.Float64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(456.78), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + request: planmodifier.Float64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.Float64Value(123.45), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(123.45), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + request: planmodifier.Float64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.Float64Value(123.45), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + "null-state-value-unknown-plan-if-true": { + request: planmodifier.Float64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, nil), + }, + ), + }, + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Null(), + }, + }, + "unknown-config": { + request: planmodifier.Float64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.Float64Value(123.45), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Unknown(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Float64Request, resp *float64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float64Response{ + PlanValue: testCase.request.PlanValue, + } + + float64planmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifyFloat64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/int32planmodifier/use_state_for_unknown_if.go b/resource/schema/int32planmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..be93e08f0 --- /dev/null +++ b/resource/schema/int32planmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.Int32 { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyInt32 implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifyInt32(ctx context.Context, req planmodifier.Int32Request, resp *planmodifier.Int32Response) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/int32planmodifier/use_state_for_unknown_if_func.go b/resource/schema/int32planmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..67616de8f --- /dev/null +++ b/resource/schema/int32planmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.Int32Request, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/int32planmodifier/use_state_for_unknown_if_test.go b/resource/schema/int32planmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..7e18b4725 --- /dev/null +++ b/resource/schema/int32planmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,191 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifyInt32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Int32Request + ifFunc int32planmodifier.UseStateForUnknownIfFunc + expected *planmodifier.Int32Response + }{ + "null-state": { + request: planmodifier.Int32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + nil, + ), + }, + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int32Request, resp *int32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown(), + }, + }, + "known-plan": { + request: planmodifier.Int32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123), + }, + ), + }, + StateValue: types.Int32Value(123), + PlanValue: types.Int32Value(456), + ConfigValue: types.Int32Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int32Request, resp *int32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Value(456), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + request: planmodifier.Int32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123), + }, + ), + }, + StateValue: types.Int32Value(123), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int32Request, resp *int32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Value(123), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + request: planmodifier.Int32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123), + }, + ), + }, + StateValue: types.Int32Value(123), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int32Request, resp *int32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown(), + }, + }, + "null-state-value-unknown-plan-if-true": { + request: planmodifier.Int32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, nil), + }, + ), + }, + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int32Request, resp *int32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Null(), + }, + }, + "unknown-config": { + request: planmodifier.Int32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123), + }, + ), + }, + StateValue: types.Int32Value(123), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Unknown(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int32Request, resp *int32planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int32Response{ + PlanValue: testCase.request.PlanValue, + } + + int32planmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifyInt32(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/int64planmodifier/use_state_for_unknown_if.go b/resource/schema/int64planmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..fa6106c54 --- /dev/null +++ b/resource/schema/int64planmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.Int64 { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyInt64 implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifyInt64(ctx context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/int64planmodifier/use_state_for_unknown_if_func.go b/resource/schema/int64planmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..cd446df04 --- /dev/null +++ b/resource/schema/int64planmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.Int64Request, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/int64planmodifier/use_state_for_unknown_if_test.go b/resource/schema/int64planmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..0a280fb07 --- /dev/null +++ b/resource/schema/int64planmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,191 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifyInt64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Int64Request + ifFunc int64planmodifier.UseStateForUnknownIfFunc + expected *planmodifier.Int64Response + }{ + "null-state": { + request: planmodifier.Int64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + nil, + ), + }, + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + "known-plan": { + request: planmodifier.Int64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123), + }, + ), + }, + StateValue: types.Int64Value(123), + PlanValue: types.Int64Value(456), + ConfigValue: types.Int64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(456), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + request: planmodifier.Int64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123), + }, + ), + }, + StateValue: types.Int64Value(123), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(123), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + request: planmodifier.Int64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123), + }, + ), + }, + StateValue: types.Int64Value(123), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + "null-state-value-unknown-plan-if-true": { + request: planmodifier.Int64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, nil), + }, + ), + }, + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Null(), + }, + }, + "unknown-config": { + request: planmodifier.Int64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123), + }, + ), + }, + StateValue: types.Int64Value(123), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Unknown(), + }, + ifFunc: func(ctx context.Context, req planmodifier.Int64Request, resp *int64planmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int64Response{ + PlanValue: testCase.request.PlanValue, + } + + int64planmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifyInt64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/listplanmodifier/use_state_for_unknown_if.go b/resource/schema/listplanmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..6b4777361 --- /dev/null +++ b/resource/schema/listplanmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.List { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyList implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/listplanmodifier/use_state_for_unknown_if_func.go b/resource/schema/listplanmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..c40ca99b4 --- /dev/null +++ b/resource/schema/listplanmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.ListRequest, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/listplanmodifier/use_state_for_unknown_if_test.go b/resource/schema/listplanmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..aad85db0c --- /dev/null +++ b/resource/schema/listplanmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,204 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifyList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.ListRequest + ifFunc listplanmodifier.UseStateForUnknownIfFunc + expected *planmodifier.ListResponse + }{ + "null-state": { + request: planmodifier.ListRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.List{ElementType: tftypes.String}, + }, + }, + nil, + ), + }, + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + "known-plan": { + request: planmodifier.ListRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.List{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }), + }, + ), + }, + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test1"), types.StringValue("test2")}), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other1"), types.StringValue("other2")}), + ConfigValue: types.ListNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other1"), types.StringValue("other2")}), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + request: planmodifier.ListRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.List{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }), + }, + ), + }, + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test1"), types.StringValue("test2")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test1"), types.StringValue("test2")}), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + request: planmodifier.ListRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.List{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }), + }, + ), + }, + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test1"), types.StringValue("test2")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + "null-state-value-unknown-plan-if-true": { + request: planmodifier.ListRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.List{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil), + }, + ), + }, + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListNull(types.StringType), + }, + }, + "unknown-config": { + request: planmodifier.ListRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.List{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }), + }, + ), + }, + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test1"), types.StringValue("test2")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListUnknown(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.ListRequest, resp *listplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ListResponse{ + PlanValue: testCase.request.PlanValue, + } + + listplanmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifyList(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/mapplanmodifier/use_state_for_unknown_if.go b/resource/schema/mapplanmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..3546bf53e --- /dev/null +++ b/resource/schema/mapplanmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.Map { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyMap implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifyMap(ctx context.Context, req planmodifier.MapRequest, resp *planmodifier.MapResponse) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/mapplanmodifier/use_state_for_unknown_if_func.go b/resource/schema/mapplanmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..05b815686 --- /dev/null +++ b/resource/schema/mapplanmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.MapRequest, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/mapplanmodifier/use_state_for_unknown_if_test.go b/resource/schema/mapplanmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..3522a2d15 --- /dev/null +++ b/resource/schema/mapplanmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,204 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifyMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.MapRequest + ifFunc mapplanmodifier.UseStateForUnknownIfFunc + expected *planmodifier.MapResponse + }{ + "null-state": { + request: planmodifier.MapRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Map{ElementType: tftypes.String}, + }, + }, + nil, + ), + }, + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + "known-plan": { + request: planmodifier.MapRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Map{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, map[string]tftypes.Value{ + "key1": tftypes.NewValue(tftypes.String, "value1"), + "key2": tftypes.NewValue(tftypes.String, "value2"), + }), + }, + ), + }, + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("value1"), "key2": types.StringValue("value2")}), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key3": types.StringValue("value3"), "key4": types.StringValue("value4")}), + ConfigValue: types.MapNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key3": types.StringValue("value3"), "key4": types.StringValue("value4")}), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + request: planmodifier.MapRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Map{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, map[string]tftypes.Value{ + "key1": tftypes.NewValue(tftypes.String, "value1"), + "key2": tftypes.NewValue(tftypes.String, "value2"), + }), + }, + ), + }, + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("value1"), "key2": types.StringValue("value2")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("value1"), "key2": types.StringValue("value2")}), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + request: planmodifier.MapRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Map{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, map[string]tftypes.Value{ + "key1": tftypes.NewValue(tftypes.String, "value1"), + "key2": tftypes.NewValue(tftypes.String, "value2"), + }), + }, + ), + }, + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("value1"), "key2": types.StringValue("value2")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + "null-state-value-unknown-plan-if-true": { + request: planmodifier.MapRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Map{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), + }, + ), + }, + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapNull(types.StringType), + }, + }, + "unknown-config": { + request: planmodifier.MapRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Map{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, map[string]tftypes.Value{ + "key1": tftypes.NewValue(tftypes.String, "value1"), + "key2": tftypes.NewValue(tftypes.String, "value2"), + }), + }, + ), + }, + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("value1"), "key2": types.StringValue("value2")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapUnknown(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.MapRequest, resp *mapplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.MapResponse{ + PlanValue: testCase.request.PlanValue, + } + + mapplanmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifyMap(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/numberplanmodifier/use_state_for_unknown_if.go b/resource/schema/numberplanmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..355141572 --- /dev/null +++ b/resource/schema/numberplanmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package numberplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.Number { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyNumber implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifyNumber(ctx context.Context, req planmodifier.NumberRequest, resp *planmodifier.NumberResponse) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/numberplanmodifier/use_state_for_unknown_if_func.go b/resource/schema/numberplanmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..405d6cee0 --- /dev/null +++ b/resource/schema/numberplanmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package numberplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.NumberRequest, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/numberplanmodifier/use_state_for_unknown_if_test.go b/resource/schema/numberplanmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..0a7c27d83 --- /dev/null +++ b/resource/schema/numberplanmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,192 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package numberplanmodifier_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/numberplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifyNumber(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.NumberRequest + ifFunc numberplanmodifier.UseStateForUnknownIfFunc + expected *planmodifier.NumberResponse + }{ + "null-state": { + request: planmodifier.NumberRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + nil, + ), + }, + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + "known-plan": { + request: planmodifier.NumberRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.NumberValue(big.NewFloat(123.45)), + PlanValue: types.NumberValue(big.NewFloat(456.78)), + ConfigValue: types.NumberNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(456.78)), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + request: planmodifier.NumberRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.NumberValue(big.NewFloat(123.45)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(123.45)), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + request: planmodifier.NumberRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.NumberValue(big.NewFloat(123.45)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + "null-state-value-unknown-plan-if-true": { + request: planmodifier.NumberRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, nil), + }, + ), + }, + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberNull(), + }, + }, + "unknown-config": { + request: planmodifier.NumberRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 123.45), + }, + ), + }, + StateValue: types.NumberValue(big.NewFloat(123.45)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberUnknown(), + }, + ifFunc: func(ctx context.Context, req planmodifier.NumberRequest, resp *numberplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.NumberResponse{ + PlanValue: testCase.request.PlanValue, + } + + numberplanmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifyNumber(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/objectplanmodifier/use_state_for_unknown_if.go b/resource/schema/objectplanmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..a0fee0326 --- /dev/null +++ b/resource/schema/objectplanmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package objectplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.Object { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyObject implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/objectplanmodifier/use_state_for_unknown_if_func.go b/resource/schema/objectplanmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..8ba4b2279 --- /dev/null +++ b/resource/schema/objectplanmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package objectplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.ObjectRequest, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/objectplanmodifier/use_state_for_unknown_if_test.go b/resource/schema/objectplanmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..4c82ece63 --- /dev/null +++ b/resource/schema/objectplanmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,202 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package objectplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifyObject(t *testing.T) { + t.Parallel() + + objTypes := map[string]attr.Type{"testattr": types.StringType} + + testCases := map[string]struct { + request planmodifier.ObjectRequest + ifFunc objectplanmodifier.UseStateForUnknownIfFunc + expected *planmodifier.ObjectResponse + }{ + "null-state": { + request: planmodifier.ObjectRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + }, + }, + nil, + ), + }, + StateValue: types.ObjectNull(objTypes), + PlanValue: types.ObjectUnknown(objTypes), + ConfigValue: types.ObjectNull(objTypes), + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(objTypes), + }, + }, + "known-plan": { + request: planmodifier.ObjectRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, map[string]tftypes.Value{ + "testattr": tftypes.NewValue(tftypes.String, "test"), + }), + }, + ), + }, + StateValue: types.ObjectValueMust(objTypes, map[string]attr.Value{"testattr": types.StringValue("test")}), + PlanValue: types.ObjectValueMust(objTypes, map[string]attr.Value{"testattr": types.StringValue("other")}), + ConfigValue: types.ObjectNull(objTypes), + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(objTypes, map[string]attr.Value{"testattr": types.StringValue("other")}), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + request: planmodifier.ObjectRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, map[string]tftypes.Value{ + "testattr": tftypes.NewValue(tftypes.String, "test"), + }), + }, + ), + }, + StateValue: types.ObjectValueMust(objTypes, map[string]attr.Value{"testattr": types.StringValue("test")}), + PlanValue: types.ObjectUnknown(objTypes), + ConfigValue: types.ObjectNull(objTypes), + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(objTypes, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + request: planmodifier.ObjectRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, map[string]tftypes.Value{ + "testattr": tftypes.NewValue(tftypes.String, "test"), + }), + }, + ), + }, + StateValue: types.ObjectValueMust(objTypes, map[string]attr.Value{"testattr": types.StringValue("test")}), + PlanValue: types.ObjectUnknown(objTypes), + ConfigValue: types.ObjectNull(objTypes), + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(objTypes), + }, + }, + "null-state-value-unknown-plan-if-true": { + request: planmodifier.ObjectRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, nil), + }, + ), + }, + StateValue: types.ObjectNull(objTypes), + PlanValue: types.ObjectUnknown(objTypes), + ConfigValue: types.ObjectNull(objTypes), + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectNull(objTypes), + }, + }, + "unknown-config": { + request: planmodifier.ObjectRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, map[string]tftypes.Value{ + "testattr": tftypes.NewValue(tftypes.String, "test"), + }), + }, + ), + }, + StateValue: types.ObjectValueMust(objTypes, map[string]attr.Value{"testattr": types.StringValue("test")}), + PlanValue: types.ObjectUnknown(objTypes), + ConfigValue: types.ObjectUnknown(objTypes), + }, + ifFunc: func(ctx context.Context, req planmodifier.ObjectRequest, resp *objectplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(objTypes), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ObjectResponse{ + PlanValue: testCase.request.PlanValue, + } + + objectplanmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifyObject(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/setplanmodifier/use_state_for_unknown_if.go b/resource/schema/setplanmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..d6bad94dd --- /dev/null +++ b/resource/schema/setplanmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.Set { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifySet implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifySet(ctx context.Context, req planmodifier.SetRequest, resp *planmodifier.SetResponse) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/setplanmodifier/use_state_for_unknown_if_func.go b/resource/schema/setplanmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..ef327321a --- /dev/null +++ b/resource/schema/setplanmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.SetRequest, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/setplanmodifier/use_state_for_unknown_if_test.go b/resource/schema/setplanmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..8a3b57bed --- /dev/null +++ b/resource/schema/setplanmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,204 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifySet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.SetRequest + ifFunc setplanmodifier.UseStateForUnknownIfFunc + expected *planmodifier.SetResponse + }{ + "null-state": { + request: planmodifier.SetRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Set{ElementType: tftypes.String}, + }, + }, + nil, + ), + }, + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + "known-plan": { + request: planmodifier.SetRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Set{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }), + }, + ), + }, + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test1"), types.StringValue("test2")}), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other1"), types.StringValue("other2")}), + ConfigValue: types.SetNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other1"), types.StringValue("other2")}), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + request: planmodifier.SetRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Set{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }), + }, + ), + }, + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test1"), types.StringValue("test2")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test1"), types.StringValue("test2")}), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + request: planmodifier.SetRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Set{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }), + }, + ), + }, + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test1"), types.StringValue("test2")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + "null-state-value-unknown-plan-if-true": { + request: planmodifier.SetRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Set{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, nil), + }, + ), + }, + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetNull(types.StringType), + }, + }, + "unknown-config": { + request: planmodifier.SetRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Set{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }), + }, + ), + }, + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test1"), types.StringValue("test2")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetUnknown(types.StringType), + }, + ifFunc: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.SetResponse{ + PlanValue: testCase.request.PlanValue, + } + + setplanmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifySet(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/stringplanmodifier/use_state_for_unknown_if.go b/resource/schema/stringplanmodifier/use_state_for_unknown_if.go new file mode 100644 index 000000000..aaf784fdf --- /dev/null +++ b/resource/schema/stringplanmodifier/use_state_for_unknown_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package stringplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIf returns a plan modifier that conditionally copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update, but only if the given +// condition is met. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value, but only if the +// condition function returns true. +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description, markdownDescription string) planmodifier.String { + return useStateForUnknownIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// useStateForUnknownIfModifier implements the conditional plan modifier. +type useStateForUnknownIfModifier struct { + ifFunc UseStateForUnknownIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyString implements the plan modification logic. +func (m useStateForUnknownIfModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + + if ifFuncResp.UseState { + resp.PlanValue = req.StateValue + } +} diff --git a/resource/schema/stringplanmodifier/use_state_for_unknown_if_func.go b/resource/schema/stringplanmodifier/use_state_for_unknown_if_func.go new file mode 100644 index 000000000..403053fda --- /dev/null +++ b/resource/schema/stringplanmodifier/use_state_for_unknown_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package stringplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknownIfFunc is a conditional function used in the UseStateForUnknownIf +// plan modifier to determine whether the attribute should use the state value for unknown. +type UseStateForUnknownIfFunc func(context.Context, planmodifier.StringRequest, *UseStateForUnknownIfFuncResponse) + +// UseStateForUnknownIfFuncResponse is the response type for a UseStateForUnknownIfFunc. +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if the state value should be used for the plan value. + UseState bool +} diff --git a/resource/schema/stringplanmodifier/use_state_for_unknown_if_test.go b/resource/schema/stringplanmodifier/use_state_for_unknown_if_test.go new file mode 100644 index 000000000..9b19c3634 --- /dev/null +++ b/resource/schema/stringplanmodifier/use_state_for_unknown_if_test.go @@ -0,0 +1,207 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package stringplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownIfModifierPlanModifyString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.StringRequest + ifFunc stringplanmodifier.UseStateForUnknownIfFunc + expected *planmodifier.StringResponse + }{ + "null-state": { + // when we first create the resource, use the unknown + // value + request: planmodifier.StringRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.String, + }, + }, + nil, + ), + }, + StateValue: types.StringNull(), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + request: planmodifier.StringRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "other"), + }, + ), + }, + StateValue: types.StringValue("other"), + PlanValue: types.StringValue("test"), + ConfigValue: types.StringNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + }, + }, + "non-null-state-value-unknown-plan-if-true": { + // this is the situation we want to preserve the state + // in when condition is true + request: planmodifier.StringRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + StateValue: types.StringValue("test"), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + }, + }, + "non-null-state-value-unknown-plan-if-false": { + // this is the situation we want to keep unknown + // when condition is false + request: planmodifier.StringRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + StateValue: types.StringValue("test"), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = false + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + }, + }, + "null-state-value-unknown-plan-if-true": { + // Null state values are still known, so we should preserve this as well. + request: planmodifier.StringRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + StateValue: types.StringNull(), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringNull(), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.StringRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + StateValue: types.StringValue("test"), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringUnknown(), + }, + ifFunc: func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.UseStateForUnknownIfFuncResponse) { + resp.UseState = true // should never reach here + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.StringResponse{ + PlanValue: testCase.request.PlanValue, + } + + stringplanmodifier.UseStateForUnknownIf(testCase.ifFunc, "test description", "test markdown description").PlanModifyString(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +}