Skip to content

Commit

Permalink
Add state preserving plan modifiers (#204)
Browse files Browse the repository at this point in the history
Add a UseStateForUnknown plan modifier.
  • Loading branch information
kmoe authored Nov 3, 2021
1 parent 16188d2 commit ebd91d2
Show file tree
Hide file tree
Showing 3 changed files with 245 additions and 0 deletions.
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)
}
})
}
}

0 comments on commit ebd91d2

Please sign in to comment.