diff --git a/internal/common/customplanmodifier/create_only.go b/internal/common/customplanmodifier/create_only.go deleted file mode 100644 index c7de0750ef..0000000000 --- a/internal/common/customplanmodifier/create_only.go +++ /dev/null @@ -1,135 +0,0 @@ -package customplanmodifier - -import ( - "context" - "fmt" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/path" - planmodifier "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// CreateOnlyStringPlanModifier creates a plan modifier that prevents updates to string attributes. -func CreateOnlyStringPlanModifier() planmodifier.String { - return &createOnlyAttributePlanModifier{} -} - -// CreateOnlyBoolPlanModifier creates a plan modifier that prevents updates to boolean attributes. -func CreateOnlyBoolPlanModifier() planmodifier.Bool { - return &createOnlyAttributePlanModifier{} -} - -// Plan modifier that implements create-only behavior for multiple attribute types -type createOnlyAttributePlanModifier struct{} - -func (d *createOnlyAttributePlanModifier) Description(ctx context.Context) string { - return d.MarkdownDescription(ctx) -} - -func (d *createOnlyAttributePlanModifier) MarkdownDescription(ctx context.Context) string { - return "Ensures that update operations fail when attempting to modify a create-only attribute." -} - -func (d *createOnlyAttributePlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { - validateCreateOnly(req.PlanValue, req.StateValue, req.Path, &resp.Diagnostics) -} - -func (d *createOnlyAttributePlanModifier) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { - validateCreateOnly(req.PlanValue, req.StateValue, req.Path, &resp.Diagnostics) -} - -// validateCreateOnly checks if an attribute value has changed and adds an error if it has -func validateCreateOnly(planValue, stateValue attr.Value, attrPath path.Path, diagnostics *diag.Diagnostics, -) { - if !stateValue.IsNull() && !stateValue.Equal(planValue) { - diagnostics.AddError( - fmt.Sprintf("%s cannot be updated", attrPath), - fmt.Sprintf("%s cannot be updated", attrPath), - ) - } -} - -type CreateOnlyModifier interface { - planmodifier.String - planmodifier.Bool -} - -// CreateOnlyAttributePlanModifier returns a plan modifier that ensures that update operations fails when the attribute is changed. -// This is useful for attributes only supported in create and not in update. -// It shows a helpful error message helping the user to update their config to match the state. -// 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. -// Implement CopyFromPlan if the attribute is not in the API Response. -func CreateOnlyAttributePlanModifier() CreateOnlyModifier { - return &createOnlyAttributePlanModifier{} -} - -// CreateOnlyAttributePlanModifierWithBoolDefault sets a default value on create operation that will show in the plan. -// This avoids any custom logic in the resource "Create" handler. -// On update the default has no impact and the UseStateForUnknown behavior is observed instead. -// Always use Optional+Computed when using a default value. -func CreateOnlyAttributePlanModifierWithBoolDefault(b bool) CreateOnlyModifier { - return &createOnlyAttributePlanModifierWithBoolDefault{defaultBool: &b} -} - -type createOnlyAttributePlanModifierWithBoolDefault struct { - defaultBool *bool -} - -func (d *createOnlyAttributePlanModifierWithBoolDefault) Description(ctx context.Context) string { - return d.MarkdownDescription(ctx) -} - -func (d *createOnlyAttributePlanModifierWithBoolDefault) MarkdownDescription(ctx context.Context) string { - 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." -} - -func isCreate(t *tfsdk.State) bool { - return t.Raw.IsNull() -} - -func (d *createOnlyAttributePlanModifierWithBoolDefault) UseDefault() bool { - return d.defaultBool != nil -} - -func (d *createOnlyAttributePlanModifierWithBoolDefault) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { - if isCreate(&req.State) { - if !IsKnown(req.PlanValue) && d.UseDefault() { - resp.PlanValue = types.BoolPointerValue(d.defaultBool) - } - return - } - if isUpdated(req.StateValue, req.PlanValue) { - d.addDiags(&resp.Diagnostics, req.Path, req.StateValue) - } - if !IsKnown(req.PlanValue) { - resp.PlanValue = req.StateValue - } -} - -func (d *createOnlyAttributePlanModifierWithBoolDefault) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { - if isCreate(&req.State) { - return - } - if isUpdated(req.StateValue, req.PlanValue) { - d.addDiags(&resp.Diagnostics, req.Path, req.StateValue) - } - if !IsKnown(req.PlanValue) { - resp.PlanValue = req.StateValue - } -} - -func isUpdated(state, plan attr.Value) bool { - if !IsKnown(plan) { - return false - } - return !state.Equal(plan) -} - -func (d *createOnlyAttributePlanModifierWithBoolDefault) addDiags(diags *diag.Diagnostics, attrPath path.Path, stateValue attr.Value) { - message := fmt.Sprintf("%s cannot be updated or set after import, remove it from the configuration or use the state value (see below).", attrPath) - detail := fmt.Sprintf("The current state value is %s", stateValue) - diags.AddError(message, detail) -} diff --git a/internal/common/customplanmodifier/create_only_bool.go b/internal/common/customplanmodifier/create_only_bool.go new file mode 100644 index 0000000000..ad740aa6f5 --- /dev/null +++ b/internal/common/customplanmodifier/create_only_bool.go @@ -0,0 +1,84 @@ +package customplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + planmodifier "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// CreateOnlyBool creates a plan modifier that prevents updates to boolean attributes. +// This is useful for attributes only supported in create and not in update. +// It shows a helpful error message helping the user to update their config to match the state. +// Never use a schema.Default for create only attributes, instead use `WithDefault`, the default will lead to plan changes that are not expected after import. +// If the attribute is not in the API Response implement CopyFromPlan behavior when converting API Model to TF Model. +func CreateOnlyBool() planmodifier.Bool { + return &createOnlyBoolPlanModifier{} +} + +// CreateOnlyBoolWithDefault sets a default value on create operation that will show in the plan. +// This avoids any custom logic in the resource "Create" handler. +// On update the default has no impact and the UseStateForUnknown behavior is observed instead. +// Always use Optional+Computed when using a default value. +// If the attribute is not in the API Response implement CopyFromPlan behavior when converting API Model to TF Model. +func CreateOnlyBoolWithDefault(b bool) planmodifier.Bool { + return &createOnlyBoolPlanModifier{defaultBool: &b} +} + +type createOnlyBoolPlanModifier struct { + defaultBool *bool +} + +func (d *createOnlyBoolPlanModifier) Description(ctx context.Context) string { + return d.MarkdownDescription(ctx) +} + +func (d *createOnlyBoolPlanModifier) MarkdownDescription(ctx context.Context) string { + return "Ensures the update operation fails when updating an attribute. If the read after import doesn't equal the configuration value it will also raise an error." +} + +// isCreate uses the full state to check if this is a create operation +func isCreate(t *tfsdk.State) bool { + return t.Raw.IsNull() +} + +func (d *createOnlyBoolPlanModifier) UseDefault() bool { + return d.defaultBool != nil +} + +func (d *createOnlyBoolPlanModifier) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { + if isCreate(&req.State) { + if !IsKnown(req.PlanValue) && d.UseDefault() { + resp.PlanValue = types.BoolPointerValue(d.defaultBool) + } + return + } + if isUpdated(req.StateValue, req.PlanValue) { + d.addDiags(&resp.Diagnostics, req.Path, req.StateValue) + } + if !IsKnown(req.PlanValue) { + resp.PlanValue = req.StateValue + } +} + +// isUpdated checks if the attribute was updated. +// Special case when the attribute is removed/set to null in the plan: +// Computed Attribute: returns false (unknown in the plan) +// Optional Attribute: returns true if the state has a value +func isUpdated(state, plan attr.Value) bool { + if !IsKnown(plan) { + return false + } + return !state.Equal(plan) +} + +func (d *createOnlyBoolPlanModifier) addDiags(diags *diag.Diagnostics, attrPath path.Path, stateValue attr.Value) { + message := fmt.Sprintf("%s cannot be updated or set after import, remove it from the configuration or use the state value (see below).", attrPath) + detail := fmt.Sprintf("The current state value is %s", stateValue) + diags.AddError(message, detail) +} diff --git a/internal/common/customplanmodifier/create_only_string.go b/internal/common/customplanmodifier/create_only_string.go new file mode 100644 index 0000000000..99c58f4f76 --- /dev/null +++ b/internal/common/customplanmodifier/create_only_string.go @@ -0,0 +1,49 @@ +package customplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + planmodifier "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// CreateOnlyString creates a plan modifier that prevents updates to string attributes. +// This is useful for attributes only supported in create and not in update. +// It shows a helpful error message helping the user to update their config to match the state. +// Never use a schema.Default for create only attributes, instead use `WithDefault`, the default will lead to plan changes that are not expected after import. +// No default value implemented for string until we have a use case. +// If the attribute is not in the API Response implement CopyFromPlan behavior when converting API Model to TF Model. +func CreateOnlyString() planmodifier.String { + return &createOnlyStringPlanModifier{} +} + +type createOnlyStringPlanModifier struct{} + +func (d *createOnlyStringPlanModifier) Description(ctx context.Context) string { + return d.MarkdownDescription(ctx) +} + +func (d *createOnlyStringPlanModifier) MarkdownDescription(ctx context.Context) string { + 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." +} + +func (d *createOnlyStringPlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + if isCreate(&req.State) { + return + } + if isUpdated(req.StateValue, req.PlanValue) { + d.addDiags(&resp.Diagnostics, req.Path, req.StateValue) + } + if !IsKnown(req.PlanValue) { + resp.PlanValue = req.StateValue + } +} + +func (d *createOnlyStringPlanModifier) addDiags(diags *diag.Diagnostics, attrPath path.Path, stateValue attr.Value) { + message := fmt.Sprintf("%s cannot be updated or set after import, remove it from the configuration or use the state value (see below).", attrPath) + detail := fmt.Sprintf("The current state value is %s", stateValue) + diags.AddError(message, detail) +} diff --git a/internal/service/encryptionatrestprivateendpoint/resource_schema.go b/internal/service/encryptionatrestprivateendpoint/resource_schema.go index e04b0ee1fa..e9b25e8d70 100644 --- a/internal/service/encryptionatrestprivateendpoint/resource_schema.go +++ b/internal/service/encryptionatrestprivateendpoint/resource_schema.go @@ -48,7 +48,7 @@ func ResourceSchema(ctx context.Context) schema.Schema { "delete_on_create_timeout": schema.BoolAttribute{ Optional: true, PlanModifiers: []planmodifier.Bool{ - customplanmodifier.CreateOnlyBoolPlanModifier(), + customplanmodifier.CreateOnlyBool(), }, 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`.", }, diff --git a/internal/service/flexcluster/resource.go b/internal/service/flexcluster/resource.go index f81318e60b..c82e36bbfb 100644 --- a/internal/service/flexcluster/resource.go +++ b/internal/service/flexcluster/resource.go @@ -80,8 +80,7 @@ func (r *rs) Create(ctx context.Context, req resource.CreateRequest, resp *resou flexClusterResp, err := CreateFlexCluster(ctx, projectID, clusterName, flexClusterReq, connV2.FlexClustersApi, &createTimeout) // Handle timeout with cleanup logic - deleteOnCreateTimeout := cleanup.ResolveDeleteOnCreateTimeout(tfModel.DeleteOnCreateTimeout) - err = cleanup.HandleCreateTimeout(deleteOnCreateTimeout, err, func(ctxCleanup context.Context) error { + err = cleanup.HandleCreateTimeout(tfModel.DeleteOnCreateTimeout.ValueBool(), err, func(ctxCleanup context.Context) error { cleanResp, cleanErr := r.Client.AtlasV2.FlexClustersApi.DeleteFlexCluster(ctxCleanup, projectID, clusterName).Execute() if validate.StatusNotFound(cleanResp) { return nil diff --git a/internal/service/flexcluster/resource_schema.go b/internal/service/flexcluster/resource_schema.go index a382145ca2..c8d9c0d4cb 100644 --- a/internal/service/flexcluster/resource_schema.go +++ b/internal/service/flexcluster/resource_schema.go @@ -22,14 +22,14 @@ func ResourceSchema(ctx context.Context) schema.Schema { "project_id": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ - customplanmodifier.CreateOnlyStringPlanModifier(), + customplanmodifier.CreateOnlyString(), }, MarkdownDescription: "Unique 24-hexadecimal character string that identifies the project.", }, "name": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ - customplanmodifier.CreateOnlyStringPlanModifier(), + customplanmodifier.CreateOnlyString(), }, MarkdownDescription: "Human-readable label that identifies the instance.", }, @@ -38,7 +38,7 @@ func ResourceSchema(ctx context.Context) schema.Schema { "backing_provider_name": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ - customplanmodifier.CreateOnlyStringPlanModifier(), + customplanmodifier.CreateOnlyString(), }, MarkdownDescription: "Cloud service provider on which MongoDB Cloud provisioned the flex cluster.", }, @@ -59,7 +59,7 @@ func ResourceSchema(ctx context.Context) schema.Schema { "region_name": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ - customplanmodifier.CreateOnlyStringPlanModifier(), + customplanmodifier.CreateOnlyString(), }, 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/).", }, @@ -148,8 +148,9 @@ func ResourceSchema(ctx context.Context) schema.Schema { }, "delete_on_create_timeout": schema.BoolAttribute{ Optional: true, + Computed: true, PlanModifiers: []planmodifier.Bool{ - customplanmodifier.CreateOnlyBoolPlanModifier(), + customplanmodifier.CreateOnlyBoolWithDefault(true), }, 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`.", }, diff --git a/internal/service/flexcluster/resource_test.go b/internal/service/flexcluster/resource_test.go index 2cef4ca1cc..b787ada201 100644 --- a/internal/service/flexcluster/resource_test.go +++ b/internal/service/flexcluster/resource_test.go @@ -104,10 +104,11 @@ func basicTestCase(t *testing.T) *resource.TestCase { Check: checksFlexCluster(projectID, clusterName, false, true), }, { - ResourceName: resourceName, - ImportStateIdFunc: acc.ImportStateIDFuncProjectIDClusterName(resourceName, "project_id", "name"), - ImportState: true, - ImportStateVerify: true, + ResourceName: resourceName, + ImportStateIdFunc: acc.ImportStateIDFuncProjectIDClusterName(resourceName, "project_id", "name"), + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"delete_on_create_timeout"}, }, }, } diff --git a/internal/service/project/resource_project_schema.go b/internal/service/project/resource_project_schema.go index c62f576731..4f643dc729 100644 --- a/internal/service/project/resource_project_schema.go +++ b/internal/service/project/resource_project_schema.go @@ -53,7 +53,7 @@ func ResourceSchema(ctx context.Context) schema.Schema { "project_owner_id": schema.StringAttribute{ Optional: true, PlanModifiers: []planmodifier.String{ - customplanmodifier.CreateOnlyStringPlanModifier(), + customplanmodifier.CreateOnlyString(), }, }, "with_default_alerts_settings": schema.BoolAttribute{ @@ -61,7 +61,7 @@ func ResourceSchema(ctx context.Context) schema.Schema { // Provider produced invalid plan: planned an invalid value for a non-computed attribute. Optional: true, Computed: true, - PlanModifiers: []planmodifier.Bool{customplanmodifier.CreateOnlyAttributePlanModifierWithBoolDefault(true)}, + PlanModifiers: []planmodifier.Bool{customplanmodifier.CreateOnlyBoolWithDefault(true)}, }, "is_collect_database_specifics_statistics_enabled": schema.BoolAttribute{ Computed: true, diff --git a/internal/service/pushbasedlogexport/resource_schema.go b/internal/service/pushbasedlogexport/resource_schema.go index f809a80b7d..e8bc665bda 100644 --- a/internal/service/pushbasedlogexport/resource_schema.go +++ b/internal/service/pushbasedlogexport/resource_schema.go @@ -59,7 +59,7 @@ func ResourceSchema(ctx context.Context) schema.Schema { "delete_on_create_timeout": schema.BoolAttribute{ Optional: true, PlanModifiers: []planmodifier.Bool{ - customplanmodifier.CreateOnlyBoolPlanModifier(), + customplanmodifier.CreateOnlyBool(), }, 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`.", }, diff --git a/internal/service/streamprocessor/resource_schema.go b/internal/service/streamprocessor/resource_schema.go index a180be509a..de88830542 100644 --- a/internal/service/streamprocessor/resource_schema.go +++ b/internal/service/streamprocessor/resource_schema.go @@ -81,7 +81,7 @@ func ResourceSchema(ctx context.Context) schema.Schema { "delete_on_create_timeout": schema.BoolAttribute{ Optional: true, PlanModifiers: []planmodifier.Bool{ - customplanmodifier.CreateOnlyBoolPlanModifier(), + customplanmodifier.CreateOnlyBool(), }, 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`.", },