diff --git a/.changelog/204.txt b/.changelog/204.txt new file mode 100644 index 000000000..232b33ffb --- /dev/null +++ b/.changelog/204.txt @@ -0,0 +1,3 @@ +```release-note:feature +Added `tfsdk.UseStateForUnknown()` as a built-in plan modifier, which will automatically replace an unknown value in the plan with the value from the state. This mimics the behavior of computed and optional+computed values in Terraform Plugin SDK versions 1 and 2. Provider developers will likely want to use it for "write-once" attributes that never change once they're set in state. +``` diff --git a/tfsdk/attribute_plan_modification.go b/tfsdk/attribute_plan_modification.go index 8295f6972..612fae589 100644 --- a/tfsdk/attribute_plan_modification.go +++ b/tfsdk/attribute_plan_modification.go @@ -2,6 +2,7 @@ package tfsdk import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -118,6 +119,85 @@ func (r RequiresReplaceIfModifier) MarkdownDescription(ctx context.Context) stri return r.markdownDescription } +// UseStateForUnknown returns a UseStateForUnknownModifier. +func UseStateForUnknown() AttributePlanModifier { + return UseStateForUnknownModifier{} +} + +// UseStateForUnknownModifier is an AttributePlanModifier that copies the prior state +// value for an attribute into that attribute's plan, if that state is non-null. +// +// Computed attributes without the UseStateForUnknown attribute plan modifier will +// have their value set to Unknown in the plan, so their value always will be +// displayed as "(known after apply)" in the CLI plan output. +// If this plan modifier is used, the prior state value will be displayed in +// the plan instead unless a prior plan modifier adjusts the value. +type UseStateForUnknownModifier struct{} + +// Modify copies the attribute's prior state to the attribute plan if the prior +// state value is not null. +func (r UseStateForUnknownModifier) Modify(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) { + if req.AttributeState == nil || resp.AttributePlan == nil || req.AttributeConfig == nil { + return + } + + val, err := req.AttributeState.ToTerraformValue(ctx) + if err != nil { + resp.Diagnostics.AddAttributeError(req.AttributePath, + "Error converting state value", + fmt.Sprintf("An unexpected error was encountered converting a %s to its equivalent Terraform representation. This is always a bug in the provider.\n\nError: %s", req.AttributeState.Type(ctx), err), + ) + return + } + + // if we have no state value, there's nothing to preserve + if val == nil { + return + } + + val, err = resp.AttributePlan.ToTerraformValue(ctx) + if err != nil { + resp.Diagnostics.AddAttributeError(req.AttributePath, + "Error converting plan value", + fmt.Sprintf("An unexpected error was encountered converting a %s to its equivalent Terraform representation. This is always a bug in the provider.\n\nError: %s", resp.AttributePlan.Type(ctx), err), + ) + return + } + + // if it's not planned to be the unknown value, stick with + // the concrete plan + if val != tftypes.UnknownValue { + return + } + + val, err = req.AttributeConfig.ToTerraformValue(ctx) + if err != nil { + resp.Diagnostics.AddAttributeError(req.AttributePath, + "Error converting config value", + fmt.Sprintf("An unexpected error was encountered converting a %s to its equivalent Terraform representation. This is always a bug in the provider.\n\nError: %s", req.AttributeConfig.Type(ctx), err), + ) + return + } + + // if the config is the unknown value, use the unknown value + // otherwise, interpolation gets messed up + if val == tftypes.UnknownValue { + return + } + + resp.AttributePlan = req.AttributeState +} + +// Description returns a human-readable description of the plan modifier. +func (r UseStateForUnknownModifier) Description(ctx context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (r UseStateForUnknownModifier) MarkdownDescription(ctx context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + // ModifyAttributePlanRequest represents a request for the provider to modify an // attribute value, or mark it as requiring replacement, at plan time. An // instance of this request struct is supplied as an argument to the Modify diff --git a/tfsdk/attribute_plan_modification_test.go b/tfsdk/attribute_plan_modification_test.go new file mode 100644 index 000000000..db5b4842f --- /dev/null +++ b/tfsdk/attribute_plan_modification_test.go @@ -0,0 +1,162 @@ +package tfsdk + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseStateForUnknownModifier(t *testing.T) { + t.Parallel() + + type testCase struct { + state attr.Value + plan attr.Value + config attr.Value + expected attr.Value + } + + tests := map[string]testCase{ + "nil-state": { + // this honestly just shouldn't happen, but let's be + // sure we're not going to panic if it does + state: nil, + plan: types.String{Unknown: true}, + config: types.String{Null: true}, + expected: types.String{Unknown: true}, + }, + "nil-plan": { + // this honestly just shouldn't happen, but let's be + // sure we're not going to panic if it does + state: types.String{Null: true}, + plan: nil, + config: types.String{Null: true}, + expected: nil, + }, + "null-state": { + // when we first create the resource, use the unknown + // value + state: types.String{Null: true}, + plan: types.String{Unknown: true}, + config: types.String{Null: true}, + expected: types.String{Unknown: true}, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + state: types.String{Value: "foo"}, + plan: types.String{Value: "bar"}, + config: types.String{Null: true}, + expected: types.String{Value: "bar"}, + }, + "non-null-state-unknown-plan": { + // this is the situation we want to preserve the state + // in + state: types.String{Value: "foo"}, + plan: types.String{Unknown: true}, + config: types.String{Null: true}, + expected: types.String{Value: "foo"}, + }, + "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 + state: types.String{Value: "foo"}, + plan: types.String{Unknown: true}, + config: types.String{Unknown: true}, + expected: types.String{Unknown: true}, + }, + } + + for name, tc := range tests { + name, tc := name, tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + schema := Schema{ + Attributes: map[string]Attribute{ + "a": { + Type: types.StringType, + Optional: true, + Computed: true, + }, + }, + } + + var configRaw, planRaw, stateRaw interface{} + if tc.config != nil { + val, err := tc.config.ToTerraformValue(context.Background()) + if err != nil { + t.Fatal(err) + } + configRaw = val + } + if tc.state != nil { + val, err := tc.state.ToTerraformValue(context.Background()) + if err != nil { + t.Fatal(err) + } + stateRaw = val + } + if tc.plan != nil { + val, err := tc.plan.ToTerraformValue(context.Background()) + if err != nil { + t.Fatal(err) + } + planRaw = val + } + configVal := tftypes.NewValue(tftypes.String, configRaw) + stateVal := tftypes.NewValue(tftypes.String, stateRaw) + planVal := tftypes.NewValue(tftypes.String, planRaw) + + req := ModifyAttributePlanRequest{ + AttributePath: tftypes.NewAttributePath(), + Config: Config{ + Schema: schema, + Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ + "a": configVal, + }), + }, + State: State{ + Schema: schema, + Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ + "a": stateVal, + }), + }, + Plan: Plan{ + Schema: schema, + Raw: tftypes.NewValue(schema.TerraformType(context.Background()), map[string]tftypes.Value{ + "a": planVal, + }), + }, + AttributeConfig: tc.config, + AttributeState: tc.state, + AttributePlan: tc.plan, + ProviderMeta: Config{}, + } + resp := &ModifyAttributePlanResponse{ + AttributePlan: req.AttributePlan, + } + modifier := UseStateForUnknown() + + modifier.Modify(context.Background(), req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("Unexpected diagnostics: %s", resp.Diagnostics) + } + if diff := cmp.Diff(tc.expected, resp.AttributePlan); diff != "" { + t.Errorf("Unexpected diff (-wanted, +got): %s", diff) + } + }) + } +}