Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add state preserving plan modifiers #204

Merged
merged 8 commits into from
Nov 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/204.txt
Original file line number Diff line number Diff line change
@@ -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.
```
80 changes: 80 additions & 0 deletions tfsdk/attribute_plan_modification.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tfsdk

import (
"context"
"fmt"

"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
Expand Down Expand Up @@ -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
Expand Down
162 changes: 162 additions & 0 deletions tfsdk/attribute_plan_modification_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}