Skip to content

Commit 65d7a6c

Browse files
chore: Add create only plan modifier for non-updateable attributes in autogenerated resources (#3747)
* adjusting CreateOnlyAttributePlanModifier to support all attribute types * handling parsing correct create only boolean * adding refactor of transformation * adding code generation of plan modifier * update generated resources * renaming plan modifier * small renaming * adjust renaming * adjusting unit tests associated to model generation * add plan modifier to latest changes * adjust singleton resource unit test * avoid plan modifier for any computed attributes to avoid blocking update operation * clarifying why usage of single interface, renaming result var, clean up of unused code
1 parent cb3e83c commit 65d7a6c

File tree

28 files changed

+740
-354
lines changed

28 files changed

+740
-354
lines changed

internal/common/customplanmodifier/create_only.go

Lines changed: 39 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,28 @@ import (
88
"github.com/hashicorp/terraform-plugin-framework/diag"
99
"github.com/hashicorp/terraform-plugin-framework/path"
1010
planmodifier "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
11-
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
12-
"github.com/hashicorp/terraform-plugin-framework/types"
1311
)
1412

15-
// CreateOnlyStringPlanModifier creates a plan modifier that prevents updates to string attributes.
16-
func CreateOnlyStringPlanModifier() planmodifier.String {
13+
// CreateOnly returns a plan modifier that ensures that update operations fails when the attribute is changed.
14+
// This is useful for attributes only supported in create and not in update.
15+
// It shows a helpful error message helping the user to update their config to match the state.
16+
// Never use a schema.Default for create only attributes, instead use WithXXXDefault, the default will lead to plan changes that are not expected after import.
17+
// Implement CopyFromPlan if the attribute is not in the API Response.
18+
func CreateOnly() CreateOnlyModifier {
1719
return &createOnlyAttributePlanModifier{}
1820
}
1921

20-
// CreateOnlyBoolPlanModifier creates a plan modifier that prevents updates to boolean attributes.
21-
func CreateOnlyBoolPlanModifier() planmodifier.Bool {
22-
return &createOnlyAttributePlanModifier{}
22+
// Single interface allows customplanmodifier.CreateOnly() to be used by all attribute types, simplifying code generation of auto-generated resources
23+
type CreateOnlyModifier interface {
24+
planmodifier.String
25+
planmodifier.Bool
26+
planmodifier.Int64
27+
planmodifier.Float64
28+
planmodifier.Number
29+
planmodifier.List
30+
planmodifier.Map
31+
planmodifier.Set
32+
planmodifier.Object
2333
}
2434

2535
// Plan modifier that implements create-only behavior for multiple attribute types
@@ -41,95 +51,41 @@ func (d *createOnlyAttributePlanModifier) PlanModifyBool(ctx context.Context, re
4151
validateCreateOnly(req.PlanValue, req.StateValue, req.Path, &resp.Diagnostics)
4252
}
4353

44-
// validateCreateOnly checks if an attribute value has changed and adds an error if it has
45-
func validateCreateOnly(planValue, stateValue attr.Value, attrPath path.Path, diagnostics *diag.Diagnostics,
46-
) {
47-
if !stateValue.IsNull() && !stateValue.Equal(planValue) {
48-
diagnostics.AddError(
49-
fmt.Sprintf("%s cannot be updated", attrPath),
50-
fmt.Sprintf("%s cannot be updated", attrPath),
51-
)
52-
}
53-
}
54-
55-
type CreateOnlyModifier interface {
56-
planmodifier.String
57-
planmodifier.Bool
58-
}
59-
60-
// CreateOnlyAttributePlanModifier returns a plan modifier that ensures that update operations fails when the attribute is changed.
61-
// This is useful for attributes only supported in create and not in update.
62-
// It shows a helpful error message helping the user to update their config to match the state.
63-
// Never use a schema.Default for create only attributes, instead use WithXXXDefault, the default will lead to plan changes that are not expected after import.
64-
// Implement CopyFromPlan if the attribute is not in the API Response.
65-
func CreateOnlyAttributePlanModifier() CreateOnlyModifier {
66-
return &createOnlyAttributePlanModifier{}
67-
}
68-
69-
// CreateOnlyAttributePlanModifierWithBoolDefault sets a default value on create operation that will show in the plan.
70-
// This avoids any custom logic in the resource "Create" handler.
71-
// On update the default has no impact and the UseStateForUnknown behavior is observed instead.
72-
// Always use Optional+Computed when using a default value.
73-
func CreateOnlyAttributePlanModifierWithBoolDefault(b bool) CreateOnlyModifier {
74-
return &createOnlyAttributePlanModifierWithBoolDefault{defaultBool: &b}
75-
}
76-
77-
type createOnlyAttributePlanModifierWithBoolDefault struct {
78-
defaultBool *bool
54+
func (d *createOnlyAttributePlanModifier) PlanModifyInt64(ctx context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) {
55+
validateCreateOnly(req.PlanValue, req.StateValue, req.Path, &resp.Diagnostics)
7956
}
8057

81-
func (d *createOnlyAttributePlanModifierWithBoolDefault) Description(ctx context.Context) string {
82-
return d.MarkdownDescription(ctx)
58+
func (d *createOnlyAttributePlanModifier) PlanModifyFloat64(ctx context.Context, req planmodifier.Float64Request, resp *planmodifier.Float64Response) {
59+
validateCreateOnly(req.PlanValue, req.StateValue, req.Path, &resp.Diagnostics)
8360
}
8461

85-
func (d *createOnlyAttributePlanModifierWithBoolDefault) MarkdownDescription(ctx context.Context) string {
86-
return "Ensures the update operation fails when updating an attribute. If the read after import don't equal the configuration value it will also raise an error."
62+
func (d *createOnlyAttributePlanModifier) PlanModifyNumber(ctx context.Context, req planmodifier.NumberRequest, resp *planmodifier.NumberResponse) {
63+
validateCreateOnly(req.PlanValue, req.StateValue, req.Path, &resp.Diagnostics)
8764
}
8865

89-
func isCreate(t *tfsdk.State) bool {
90-
return t.Raw.IsNull()
66+
func (d *createOnlyAttributePlanModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) {
67+
validateCreateOnly(req.PlanValue, req.StateValue, req.Path, &resp.Diagnostics)
9168
}
9269

93-
func (d *createOnlyAttributePlanModifierWithBoolDefault) UseDefault() bool {
94-
return d.defaultBool != nil
70+
func (d *createOnlyAttributePlanModifier) PlanModifyMap(ctx context.Context, req planmodifier.MapRequest, resp *planmodifier.MapResponse) {
71+
validateCreateOnly(req.PlanValue, req.StateValue, req.Path, &resp.Diagnostics)
9572
}
9673

97-
func (d *createOnlyAttributePlanModifierWithBoolDefault) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) {
98-
if isCreate(&req.State) {
99-
if !IsKnown(req.PlanValue) && d.UseDefault() {
100-
resp.PlanValue = types.BoolPointerValue(d.defaultBool)
101-
}
102-
return
103-
}
104-
if isUpdated(req.StateValue, req.PlanValue) {
105-
d.addDiags(&resp.Diagnostics, req.Path, req.StateValue)
106-
}
107-
if !IsKnown(req.PlanValue) {
108-
resp.PlanValue = req.StateValue
109-
}
74+
func (d *createOnlyAttributePlanModifier) PlanModifySet(ctx context.Context, req planmodifier.SetRequest, resp *planmodifier.SetResponse) {
75+
validateCreateOnly(req.PlanValue, req.StateValue, req.Path, &resp.Diagnostics)
11076
}
11177

112-
func (d *createOnlyAttributePlanModifierWithBoolDefault) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
113-
if isCreate(&req.State) {
114-
return
115-
}
116-
if isUpdated(req.StateValue, req.PlanValue) {
117-
d.addDiags(&resp.Diagnostics, req.Path, req.StateValue)
118-
}
119-
if !IsKnown(req.PlanValue) {
120-
resp.PlanValue = req.StateValue
121-
}
78+
func (d *createOnlyAttributePlanModifier) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) {
79+
validateCreateOnly(req.PlanValue, req.StateValue, req.Path, &resp.Diagnostics)
12280
}
12381

124-
func isUpdated(state, plan attr.Value) bool {
125-
if !IsKnown(plan) {
126-
return false
82+
// validateCreateOnly checks if an attribute value has changed and adds an error if it has
83+
func validateCreateOnly(planValue, stateValue attr.Value, attrPath path.Path, diagnostics *diag.Diagnostics,
84+
) {
85+
if !stateValue.IsNull() && !stateValue.Equal(planValue) {
86+
diagnostics.AddError(
87+
fmt.Sprintf("%s cannot be updated", attrPath),
88+
fmt.Sprintf("%s cannot be updated", attrPath),
89+
)
12790
}
128-
return !state.Equal(plan)
129-
}
130-
131-
func (d *createOnlyAttributePlanModifierWithBoolDefault) addDiags(diags *diag.Diagnostics, attrPath path.Path, stateValue attr.Value) {
132-
message := fmt.Sprintf("%s cannot be updated or set after import, remove it from the configuration or use the state value (see below).", attrPath)
133-
detail := fmt.Sprintf("The current state value is %s", stateValue)
134-
diags.AddError(message, detail)
13591
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package customplanmodifier
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/hashicorp/terraform-plugin-framework/attr"
8+
"github.com/hashicorp/terraform-plugin-framework/diag"
9+
"github.com/hashicorp/terraform-plugin-framework/path"
10+
planmodifier "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
11+
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
12+
"github.com/hashicorp/terraform-plugin-framework/types"
13+
)
14+
15+
// CreateOnlyAttributePlanModifierWithBoolDefault sets a default value on create operation that will show in the plan.
16+
// This avoids any custom logic in the resource "Create" handler.
17+
// On update the default has no impact and the UseStateForUnknown behavior is observed instead.
18+
// Always use Optional+Computed when using a default value.
19+
func CreateOnlyAttributePlanModifierWithBoolDefault(b bool) planmodifier.Bool {
20+
return &createOnlyAttributePlanModifierWithBoolDefault{defaultBool: &b}
21+
}
22+
23+
type createOnlyAttributePlanModifierWithBoolDefault struct {
24+
defaultBool *bool
25+
}
26+
27+
func (d *createOnlyAttributePlanModifierWithBoolDefault) Description(ctx context.Context) string {
28+
return d.MarkdownDescription(ctx)
29+
}
30+
31+
func (d *createOnlyAttributePlanModifierWithBoolDefault) MarkdownDescription(ctx context.Context) string {
32+
return "Ensures the update operation fails when updating an attribute. If the read after import don't equal the configuration value it will also raise an error."
33+
}
34+
35+
func isCreate(t *tfsdk.State) bool {
36+
return t.Raw.IsNull()
37+
}
38+
39+
func (d *createOnlyAttributePlanModifierWithBoolDefault) UseDefault() bool {
40+
return d.defaultBool != nil
41+
}
42+
43+
func (d *createOnlyAttributePlanModifierWithBoolDefault) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) {
44+
if isCreate(&req.State) {
45+
if !IsKnown(req.PlanValue) && d.UseDefault() {
46+
resp.PlanValue = types.BoolPointerValue(d.defaultBool)
47+
}
48+
return
49+
}
50+
if isUpdated(req.StateValue, req.PlanValue) {
51+
d.addDiags(&resp.Diagnostics, req.Path, req.StateValue)
52+
}
53+
if !IsKnown(req.PlanValue) {
54+
resp.PlanValue = req.StateValue
55+
}
56+
}
57+
58+
func isUpdated(state, plan attr.Value) bool {
59+
if !IsKnown(plan) {
60+
return false
61+
}
62+
return !state.Equal(plan)
63+
}
64+
65+
func (d *createOnlyAttributePlanModifierWithBoolDefault) addDiags(diags *diag.Diagnostics, attrPath path.Path, stateValue attr.Value) {
66+
message := fmt.Sprintf("%s cannot be updated or set after import, remove it from the configuration or use the state value (see below).", attrPath)
67+
detail := fmt.Sprintf("The current state value is %s", stateValue)
68+
diags.AddError(message, detail)
69+
}

internal/service/encryptionatrestprivateendpoint/resource_schema.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func ResourceSchema(ctx context.Context) schema.Schema {
4848
"delete_on_create_timeout": schema.BoolAttribute{
4949
Optional: true,
5050
PlanModifiers: []planmodifier.Bool{
51-
customplanmodifier.CreateOnlyBoolPlanModifier(),
51+
customplanmodifier.CreateOnly(),
5252
},
5353
MarkdownDescription: "Indicates whether to delete the resource being created if a timeout is reached when waiting for completion. When set to `true` and timeout occurs, it triggers the deletion and returns immediately without waiting for deletion to complete. When set to `false`, the timeout will not trigger resource deletion. If you suspect a transient error when the value is `true`, wait before retrying to allow resource deletion to finish. Default is `true`.",
5454
},

internal/service/flexcluster/resource_schema.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ func ResourceSchema(ctx context.Context) schema.Schema {
2222
"project_id": schema.StringAttribute{
2323
Required: true,
2424
PlanModifiers: []planmodifier.String{
25-
customplanmodifier.CreateOnlyStringPlanModifier(),
25+
customplanmodifier.CreateOnly(),
2626
},
2727
MarkdownDescription: "Unique 24-hexadecimal character string that identifies the project.",
2828
},
2929
"name": schema.StringAttribute{
3030
Required: true,
3131
PlanModifiers: []planmodifier.String{
32-
customplanmodifier.CreateOnlyStringPlanModifier(),
32+
customplanmodifier.CreateOnly(),
3333
},
3434
MarkdownDescription: "Human-readable label that identifies the instance.",
3535
},
@@ -38,7 +38,7 @@ func ResourceSchema(ctx context.Context) schema.Schema {
3838
"backing_provider_name": schema.StringAttribute{
3939
Required: true,
4040
PlanModifiers: []planmodifier.String{
41-
customplanmodifier.CreateOnlyStringPlanModifier(),
41+
customplanmodifier.CreateOnly(),
4242
},
4343
MarkdownDescription: "Cloud service provider on which MongoDB Cloud provisioned the flex cluster.",
4444
},
@@ -59,7 +59,7 @@ func ResourceSchema(ctx context.Context) schema.Schema {
5959
"region_name": schema.StringAttribute{
6060
Required: true,
6161
PlanModifiers: []planmodifier.String{
62-
customplanmodifier.CreateOnlyStringPlanModifier(),
62+
customplanmodifier.CreateOnly(),
6363
},
6464
MarkdownDescription: "Human-readable label that identifies the geographic location of your MongoDB flex cluster. The region you choose can affect network latency for clients accessing your databases. For a complete list of region names, see [AWS](https://docs.atlas.mongodb.com/reference/amazon-aws/#std-label-amazon-aws), [GCP](https://docs.atlas.mongodb.com/reference/google-gcp/), and [Azure](https://docs.atlas.mongodb.com/reference/microsoft-azure/).",
6565
},
@@ -149,7 +149,7 @@ func ResourceSchema(ctx context.Context) schema.Schema {
149149
"delete_on_create_timeout": schema.BoolAttribute{
150150
Optional: true,
151151
PlanModifiers: []planmodifier.Bool{
152-
customplanmodifier.CreateOnlyBoolPlanModifier(),
152+
customplanmodifier.CreateOnly(),
153153
},
154154
MarkdownDescription: "Indicates whether to delete the resource being created if a timeout is reached when waiting for completion. When set to `true` and timeout occurs, it triggers the deletion and returns immediately without waiting for deletion to complete. When set to `false`, the timeout will not trigger resource deletion. If you suspect a transient error when the value is `true`, wait before retrying to allow resource deletion to finish. Default is `true`.",
155155
},

internal/service/project/resource_project_schema.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func ResourceSchema(ctx context.Context) schema.Schema {
5353
"project_owner_id": schema.StringAttribute{
5454
Optional: true,
5555
PlanModifiers: []planmodifier.String{
56-
customplanmodifier.CreateOnlyStringPlanModifier(),
56+
customplanmodifier.CreateOnly(),
5757
},
5858
},
5959
"with_default_alerts_settings": schema.BoolAttribute{

internal/service/pushbasedlogexport/resource_schema.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func ResourceSchema(ctx context.Context) schema.Schema {
5959
"delete_on_create_timeout": schema.BoolAttribute{
6060
Optional: true,
6161
PlanModifiers: []planmodifier.Bool{
62-
customplanmodifier.CreateOnlyBoolPlanModifier(),
62+
customplanmodifier.CreateOnly(),
6363
},
6464
MarkdownDescription: "Indicates whether to delete the resource being created if a timeout is reached when waiting for completion. When set to `true` and timeout occurs, it triggers the deletion and returns immediately without waiting for deletion to complete. When set to `false`, the timeout will not trigger resource deletion. If you suspect a transient error when the value is `true`, wait before retrying to allow resource deletion to finish. Default is `true`.",
6565
},

internal/service/streamprocessor/resource_schema.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func ResourceSchema(ctx context.Context) schema.Schema {
8181
"delete_on_create_timeout": schema.BoolAttribute{
8282
Optional: true,
8383
PlanModifiers: []planmodifier.Bool{
84-
customplanmodifier.CreateOnlyBoolPlanModifier(),
84+
customplanmodifier.CreateOnly(),
8585
},
8686
MarkdownDescription: "Indicates whether to delete the resource being created if a timeout is reached when waiting for completion. When set to `true` and timeout occurs, it triggers the deletion and returns immediately without waiting for deletion to complete. When set to `false`, the timeout will not trigger resource deletion. If you suspect a transient error when the value is `true`, wait before retrying to allow resource deletion to finish. Default is `true`.",
8787
},

internal/serviceapi/auditingapi/resource_schema.go

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/serviceapi/clusterapi/resource_schema.go

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/serviceapi/customdbroleapi/resource_schema.go

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)