Skip to content

feat: Long-running operation improvements for mongodbatlas_flex_cluster resource #3525

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

Merged
merged 19 commits into from
Aug 1, 2025
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
7 changes: 7 additions & 0 deletions .changelog/3525.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
```release-note:enhancement
resource/mongodbatlas_flex_cluster: Adds `timeouts` attribute for create, update and delete operations
```

```release-note:enhancement
resource/mongodbatlas_flex_cluster: Adds `delete_on_create_timeout` attribute to indicate whether to delete the resource if its creation times out
```
12 changes: 12 additions & 0 deletions docs/resources/flex_cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ output "mongodbatlas_flex_clusters_names" {

### Optional

- `delete_on_create_timeout` (Boolean) Indicates whether to delete the resource if creation times out. Default is `true`. When Terraform apply fails, it returns immediately without waiting for cleanup to complete. If you suspect a transient error, wait before retrying to allow resource deletion to finish.
- `tags` (Map of String) Map that contains key-value pairs between 1 to 255 characters in length for tagging and categorizing the instance.
- `termination_protection_enabled` (Boolean) Flag that indicates whether termination protection is enabled on the cluster. If set to `true`, MongoDB Cloud won't delete the cluster. If set to `false`, MongoDB Cloud will delete the cluster.
- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts))

### Read-Only

Expand All @@ -74,6 +76,16 @@ Read-Only:
- `provider_name` (String) Human-readable label that identifies the cloud service provider.


<a id="nestedatt--timeouts"></a>
### Nested Schema for `timeouts`

Optional:

- `create` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours).
- `delete` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Setting a timeout for a Delete operation is only applicable if changes are saved into state before the destroy operation occurs.
- `update` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours).


<a id="nestedatt--backup_settings"></a>
### Nested Schema for `backup_settings`

Expand Down
30 changes: 30 additions & 0 deletions internal/common/cleanup/handle_timeout.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import (
"strings"
"time"

"github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry"
"github.com/mongodb/terraform-provider-mongodbatlas/internal/common/constant"
)

const (
Expand Down Expand Up @@ -69,3 +71,31 @@ func ReplaceContextDeadlineExceededDiags(diags *diag.Diagnostics, duration time.
}
}
}

const (
OperationCreate = "create"
OperationUpdate = "update"
OperationDelete = "delete"
)

// ResolveTimeout extracts the appropriate timeout duration from the model for the given operation
func ResolveTimeout(ctx context.Context, t *timeouts.Value, operationName string, diags *diag.Diagnostics) time.Duration {
var (
timeoutDuration time.Duration
localDiags diag.Diagnostics
)
switch operationName {
case OperationCreate:
timeoutDuration, localDiags = t.Create(ctx, constant.DefaultTimeout)
diags.Append(localDiags...)
case OperationUpdate:
timeoutDuration, localDiags = t.Update(ctx, constant.DefaultTimeout)
diags.Append(localDiags...)
case OperationDelete:
timeoutDuration, localDiags = t.Delete(ctx, constant.DefaultTimeout)
diags.Append(localDiags...)
default:
timeoutDuration = constant.DefaultTimeout
}
return timeoutDuration
}
4 changes: 2 additions & 2 deletions internal/common/conversion/schema_generation.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,10 @@ var convertNestedMappings = map[string]reflect.Type{
}

func convertAttrs(rsAttrs map[string]schema.Attribute, requiredFields []string) map[string]dsschema.Attribute {
const ignoreField = "timeouts"
ignoreFields := []string{"timeouts", "delete_on_create_timeout"}
dsAttrs := make(map[string]dsschema.Attribute, len(rsAttrs))
for name, attr := range rsAttrs {
if name == ignoreField {
if slices.Contains(ignoreFields, name) {
continue
}
dsAttrs[name] = convertElement(name, attr, requiredFields).(dsschema.Attribute)
Expand Down
51 changes: 51 additions & 0 deletions internal/common/customplanmodifier/create_only.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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"
)

// 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),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Would improve the details by showing the plan & stateValue.

fmt.Sprintf("%s cannot be updated", attrPath),
)
}
}
36 changes: 0 additions & 36 deletions internal/common/customplanmodifier/non_updatable.go

This file was deleted.

10 changes: 6 additions & 4 deletions internal/service/advancedcluster/resource_advanced_cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ func resourceCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.

if isFlex {
flexClusterReq := advancedclustertpf.NewFlexCreateReq(clusterName, d.Get("termination_protection_enabled").(bool), conversion.ExpandTagsFromSetSchema(d), replicationSpecs)
flexClusterResp, err := flexcluster.CreateFlexCluster(ctx, projectID, clusterName, flexClusterReq, connV2.FlexClustersApi)
flexClusterResp, err := flexcluster.CreateFlexCluster(ctx, projectID, clusterName, flexClusterReq, connV2.FlexClustersApi, &timeout)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we passing timeout only here and not for delete and update?

Also this method is called from advanced_cluster. Are we changing something also on that resource?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, fixed in 56b6dc8.
We are doing changes in advanced cluster to support both timeout and delete_on_create_timeout for flex clusters in advanced cluster resource

if err != nil {
return diag.FromErr(fmt.Errorf(flexcluster.ErrorCreateFlex, err))
}
Expand Down Expand Up @@ -1326,9 +1326,10 @@ func resourceDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.
}

replicationSpecs := expandAdvancedReplicationSpecs(d.Get("replication_specs").([]any), nil)
timeout := d.Timeout(schema.TimeoutDelete)

if advancedclustertpf.IsFlex(replicationSpecs) {
err := flexcluster.DeleteFlexCluster(ctx, projectID, clusterName, connV2.FlexClustersApi)
err := flexcluster.DeleteFlexCluster(ctx, projectID, clusterName, connV2.FlexClustersApi, timeout)
if err != nil {
return diag.FromErr(fmt.Errorf(flexcluster.ErrorDeleteFlex, clusterName, err))
}
Expand Down Expand Up @@ -1433,7 +1434,7 @@ func waitStateTransitionFlexUpgrade(ctx context.Context, client admin.FlexCluste
GroupId: projectID,
Name: name,
}
flexClusterResp, err := flexcluster.WaitStateTransition(ctx, flexClusterParams, client, []string{retrystrategy.RetryStrategyUpdatingState}, []string{retrystrategy.RetryStrategyIdleState}, true, &timeout)
flexClusterResp, err := flexcluster.WaitStateTransition(ctx, flexClusterParams, client, []string{retrystrategy.RetryStrategyUpdatingState}, []string{retrystrategy.RetryStrategyIdleState}, true, timeout)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1539,8 +1540,9 @@ func resourceUpdateFlexCluster(ctx context.Context, flexUpdateRequest *admin.Fle
ids := conversion.DecodeStateID(d.Id())
projectID := ids["project_id"]
clusterName := ids["cluster_name"]
timeout := d.Timeout(schema.TimeoutUpdate)

_, err := flexcluster.UpdateFlexCluster(ctx, projectID, clusterName, flexUpdateRequest, connV2.FlexClustersApi)
_, err := flexcluster.UpdateFlexCluster(ctx, projectID, clusterName, flexUpdateRequest, connV2.FlexClustersApi, timeout)
if err != nil {
return diag.FromErr(fmt.Errorf(flexcluster.ErrorUpdateFlex, err))
}
Expand Down
Loading