Skip to content

Commit 002163b

Browse files
oarbusiCopilot
andauthored
feat: Long-running operation improvements for mongodbatlas_encryption_at_rest_private_endpoint resource (#3561)
* implement timeout for create and delete * implement delete_on_create_timeout * implement test * data sources fix * docs and changelog * fix unit test * test fixes * fix test * use shared resources to avoid CANNOT_DISABLE_ENCRYPTION_AT_REST_DUE_TO_PRIVATE_ENDPOINTS * non parallel test * try new project for ear * create project * change description * use configured project * use correct projectn * clean up shared resources * skip test * Update .changelog/3561.txt Co-authored-by: Copilot <[email protected]> * changes from dev --------- Co-authored-by: Copilot <[email protected]>
1 parent 6eeaeb0 commit 002163b

File tree

16 files changed

+262
-45
lines changed

16 files changed

+262
-45
lines changed

.changelog/3561.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
```release-note:enhancement
2+
resource/mongodbatlas_encryption_at_rest_private_endpoint: Adds `timeouts` attribute for create and delete operations
3+
```
4+
5+
```release-note:enhancement
6+
resource/mongodbatlas_encryption_at_rest_private_endpoint: Adds `delete_on_create_timeout` attribute to indicate whether to delete the resource if its creation times out
7+
```

docs/guides/2.0.0-upgrade-guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The Terraform MongoDB Atlas Provider version 2.0.0 has the following new feature
1616
- `mongodbatlas_advanced_cluster`
1717
- `mongodbatlas_cloud_backup_snapshot`
1818
- `mongodbatlas_cluster_outage_simulation`
19+
- `mongodbatlas_encryption_at_rest_private_endpoint`
1920
- `mongodbatlas_flex_cluster`
2021
- `mongodbatlas_network_peering`
2122
- `mongodbatlas_online_archive`

docs/resources/encryption_at_rest_private_endpoint.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,26 @@ resource "mongodbatlas_encryption_at_rest_private_endpoint" "endpoint" {
9999
- `project_id` (String) Unique 24-hexadecimal digit string that identifies your project.
100100
- `region_name` (String) Cloud provider region in which the Encryption At Rest private endpoint is located.
101101

102+
### Optional
103+
104+
- `delete_on_create_timeout` (Boolean) 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`.
105+
- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts))
106+
102107
### Read-Only
103108

104109
- `error_message` (String) Error message for failures associated with the Encryption At Rest private endpoint.
105110
- `id` (String) Unique 24-hexadecimal digit string that identifies the Private Endpoint Service.
106111
- `private_endpoint_connection_name` (String) Connection name of the Azure Private Endpoint.
107112
- `status` (String) State of the Encryption At Rest private endpoint.
108113

114+
<a id="nestedatt--timeouts"></a>
115+
### Nested Schema for `timeouts`
116+
117+
Optional:
118+
119+
- `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).
120+
- `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.
121+
109122
## Import
110123
Encryption At Rest Private Endpoint resource can be imported using the project ID, cloud provider, and private endpoint ID. The format must be `{project_id}-{cloud_provider}-{private_endpoint_id}` e.g.
111124

internal/service/encryptionatrestprivateendpoint/data_source.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func (d *encryptionAtRestPrivateEndpointDS) Schema(ctx context.Context, req data
3131
}
3232

3333
func (d *encryptionAtRestPrivateEndpointDS) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
34-
var earPrivateEndpointConfig TFEarPrivateEndpointModel
34+
var earPrivateEndpointConfig TFEarPrivateEndpointModelDS
3535
resp.Diagnostics.Append(req.Config.Get(ctx, &earPrivateEndpointConfig)...)
3636
if resp.Diagnostics.HasError() {
3737
return
@@ -48,5 +48,5 @@ func (d *encryptionAtRestPrivateEndpointDS) Read(ctx context.Context, req dataso
4848
return
4949
}
5050

51-
resp.Diagnostics.Append(resp.State.Set(ctx, NewTFEarPrivateEndpoint(*endpointModel, projectID))...)
51+
resp.Diagnostics.Append(resp.State.Set(ctx, NewTFEarPrivateEndpointDS(*endpointModel, projectID))...)
5252
}

internal/service/encryptionatrestprivateendpoint/data_source_schema.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,19 @@ func DSAttributes(withArguments bool) map[string]schema.Attribute {
4141
}
4242
}
4343

44+
// TFEarPrivateEndpointModelDS represents the model for data sources (without timeout fields)
45+
type TFEarPrivateEndpointModelDS struct {
46+
CloudProvider types.String `tfsdk:"cloud_provider"`
47+
ErrorMessage types.String `tfsdk:"error_message"`
48+
ProjectID types.String `tfsdk:"project_id"`
49+
ID types.String `tfsdk:"id"`
50+
PrivateEndpointConnectionName types.String `tfsdk:"private_endpoint_connection_name"`
51+
RegionName types.String `tfsdk:"region_name"`
52+
Status types.String `tfsdk:"status"`
53+
}
54+
4455
type TFEncryptionAtRestPrivateEndpointsDSModel struct {
45-
CloudProvider types.String `tfsdk:"cloud_provider"`
46-
ProjectID types.String `tfsdk:"project_id"`
47-
Results []TFEarPrivateEndpointModel `tfsdk:"results"`
56+
CloudProvider types.String `tfsdk:"cloud_provider"`
57+
ProjectID types.String `tfsdk:"project_id"`
58+
Results []TFEarPrivateEndpointModelDS `tfsdk:"results"`
4859
}

internal/service/encryptionatrestprivateendpoint/model.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ func NewEarPrivateEndpointReq(tfPlan *TFEarPrivateEndpointModel) *admin.EARPriva
3131
}
3232

3333
func NewTFEarPrivateEndpoints(projectID, cloudProvider string, sdkResults []admin.EARPrivateEndpoint) *TFEncryptionAtRestPrivateEndpointsDSModel {
34-
results := make([]TFEarPrivateEndpointModel, len(sdkResults))
34+
results := make([]TFEarPrivateEndpointModelDS, len(sdkResults))
3535
for i := range sdkResults {
36-
result := NewTFEarPrivateEndpoint(sdkResults[i], projectID)
36+
result := NewTFEarPrivateEndpointDS(sdkResults[i], projectID)
3737
results[i] = result
3838
}
3939
return &TFEncryptionAtRestPrivateEndpointsDSModel{
@@ -42,3 +42,16 @@ func NewTFEarPrivateEndpoints(projectID, cloudProvider string, sdkResults []admi
4242
Results: results,
4343
}
4444
}
45+
46+
// NewTFEarPrivateEndpointDS creates a new data source model without timeout fields
47+
func NewTFEarPrivateEndpointDS(apiResp admin.EARPrivateEndpoint, projectID string) TFEarPrivateEndpointModelDS {
48+
return TFEarPrivateEndpointModelDS{
49+
ProjectID: types.StringValue(projectID),
50+
CloudProvider: conversion.StringNullIfEmpty(apiResp.GetCloudProvider()),
51+
ErrorMessage: conversion.StringNullIfEmpty(apiResp.GetErrorMessage()),
52+
ID: conversion.StringNullIfEmpty(apiResp.GetId()),
53+
RegionName: conversion.StringNullIfEmpty(apiResp.GetRegionName()),
54+
Status: conversion.StringNullIfEmpty(apiResp.GetStatus()),
55+
PrivateEndpointConnectionName: conversion.StringNullIfEmpty(apiResp.GetPrivateEndpointConnectionName()),
56+
}
57+
}

internal/service/encryptionatrestprivateendpoint/model_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ func TestEncryptionAtRestPrivateEndpointPluralDSSDKToTFModel(t *testing.T) {
174174
expectedTFModel: &encryptionatrestprivateendpoint.TFEncryptionAtRestPrivateEndpointsDSModel{
175175
CloudProvider: types.StringValue(testCloudProvider),
176176
ProjectID: types.StringValue(testProjectID),
177-
Results: []encryptionatrestprivateendpoint.TFEarPrivateEndpointModel{
177+
Results: []encryptionatrestprivateendpoint.TFEarPrivateEndpointModelDS{
178178
{
179179
CloudProvider: types.StringValue(testCloudProvider),
180180
ErrorMessage: types.StringNull(),

internal/service/encryptionatrestprivateendpoint/resource.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/hashicorp/terraform-plugin-framework/path"
1212
"github.com/hashicorp/terraform-plugin-framework/resource"
1313

14+
"github.com/mongodb/terraform-provider-mongodbatlas/internal/common/cleanup"
1415
"github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion"
1516
"github.com/mongodb/terraform-provider-mongodbatlas/internal/common/retrystrategy"
1617
"github.com/mongodb/terraform-provider-mongodbatlas/internal/common/validate"
@@ -63,13 +64,28 @@ func (r *encryptionAtRestPrivateEndpointRS) Create(ctx context.Context, req reso
6364
return
6465
}
6566

66-
finalResp, err := waitStateTransition(ctx, projectID, cloudProvider, createResp.GetId(), connV2.EncryptionAtRestUsingCustomerKeyManagementApi)
67+
createTimeout := cleanup.ResolveTimeout(ctx, &earPrivateEndpointPlan.Timeouts, cleanup.OperationCreate, &resp.Diagnostics)
68+
if resp.Diagnostics.HasError() {
69+
return
70+
}
71+
72+
finalResp, err := waitStateTransition(ctx, projectID, cloudProvider, createResp.GetId(), connV2.EncryptionAtRestUsingCustomerKeyManagementApi, createTimeout)
73+
err = cleanup.HandleCreateTimeout(cleanup.ResolveDeleteOnCreateTimeout(earPrivateEndpointPlan.DeleteOnCreateTimeout), err, func(ctxCleanup context.Context) error {
74+
cleanResp, cleanErr := connV2.EncryptionAtRestUsingCustomerKeyManagementApi.RequestEncryptionAtRestPrivateEndpointDeletion(ctxCleanup, projectID, cloudProvider, createResp.GetId()).Execute()
75+
if validate.StatusNotFound(cleanResp) {
76+
return nil
77+
}
78+
return cleanErr
79+
})
80+
6781
if err != nil {
6882
resp.Diagnostics.AddError("error when waiting for status transition in creation", err.Error())
6983
return
7084
}
7185

7286
privateEndpointModel := NewTFEarPrivateEndpoint(*finalResp, projectID)
87+
privateEndpointModel.Timeouts = earPrivateEndpointPlan.Timeouts
88+
privateEndpointModel.DeleteOnCreateTimeout = earPrivateEndpointPlan.DeleteOnCreateTimeout
7389
resp.Diagnostics.Append(resp.State.Set(ctx, privateEndpointModel)...)
7490

7591
diags := CheckErrorMessageAndStatus(finalResp)
@@ -98,7 +114,10 @@ func (r *encryptionAtRestPrivateEndpointRS) Read(ctx context.Context, req resour
98114
return
99115
}
100116

101-
resp.Diagnostics.Append(resp.State.Set(ctx, NewTFEarPrivateEndpoint(*endpointModel, projectID))...)
117+
privateEndpointModel := NewTFEarPrivateEndpoint(*endpointModel, projectID)
118+
privateEndpointModel.Timeouts = earPrivateEndpointState.Timeouts
119+
privateEndpointModel.DeleteOnCreateTimeout = earPrivateEndpointState.DeleteOnCreateTimeout
120+
resp.Diagnostics.Append(resp.State.Set(ctx, privateEndpointModel)...)
102121

103122
diags := CheckErrorMessageAndStatus(endpointModel)
104123
resp.Diagnostics.Append(diags...)
@@ -124,7 +143,12 @@ func (r *encryptionAtRestPrivateEndpointRS) Delete(ctx context.Context, req reso
124143
return
125144
}
126145

127-
model, err := WaitDeleteStateTransition(ctx, projectID, cloudProvider, endpointID, connV2.EncryptionAtRestUsingCustomerKeyManagementApi)
146+
deleteTimeout := cleanup.ResolveTimeout(ctx, &earPrivateEndpointState.Timeouts, cleanup.OperationDelete, &resp.Diagnostics)
147+
if resp.Diagnostics.HasError() {
148+
return
149+
}
150+
151+
model, err := WaitDeleteStateTransition(ctx, projectID, cloudProvider, endpointID, connV2.EncryptionAtRestUsingCustomerKeyManagementApi, deleteTimeout)
128152
if err != nil {
129153
resp.Diagnostics.AddError("error when waiting for status transition in delete", err.Error())
130154
return

internal/service/encryptionatrestprivateendpoint/resource_schema.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ package encryptionatrestprivateendpoint
33
import (
44
"context"
55

6+
"github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts"
67
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
8+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
79
"github.com/hashicorp/terraform-plugin-framework/types"
10+
"github.com/mongodb/terraform-provider-mongodbatlas/internal/common/customplanmodifier"
811
)
912

1013
func ResourceSchema(ctx context.Context) schema.Schema {
@@ -38,16 +41,29 @@ func ResourceSchema(ctx context.Context) schema.Schema {
3841
Computed: true,
3942
MarkdownDescription: "State of the Encryption At Rest private endpoint.",
4043
},
44+
"timeouts": timeouts.Attributes(ctx, timeouts.Opts{
45+
Create: true,
46+
Delete: true,
47+
}),
48+
"delete_on_create_timeout": schema.BoolAttribute{
49+
Optional: true,
50+
PlanModifiers: []planmodifier.Bool{
51+
customplanmodifier.CreateOnlyBoolPlanModifier(),
52+
},
53+
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`.",
54+
},
4155
},
4256
}
4357
}
4458

4559
type TFEarPrivateEndpointModel struct {
46-
CloudProvider types.String `tfsdk:"cloud_provider"`
47-
ErrorMessage types.String `tfsdk:"error_message"`
48-
ProjectID types.String `tfsdk:"project_id"`
49-
ID types.String `tfsdk:"id"`
50-
PrivateEndpointConnectionName types.String `tfsdk:"private_endpoint_connection_name"`
51-
RegionName types.String `tfsdk:"region_name"`
52-
Status types.String `tfsdk:"status"`
60+
CloudProvider types.String `tfsdk:"cloud_provider"`
61+
ErrorMessage types.String `tfsdk:"error_message"`
62+
ProjectID types.String `tfsdk:"project_id"`
63+
ID types.String `tfsdk:"id"`
64+
PrivateEndpointConnectionName types.String `tfsdk:"private_endpoint_connection_name"`
65+
RegionName types.String `tfsdk:"region_name"`
66+
Status types.String `tfsdk:"status"`
67+
Timeouts timeouts.Value `tfsdk:"timeouts"`
68+
DeleteOnCreateTimeout types.Bool `tfsdk:"delete_on_create_timeout"`
5369
}

internal/service/encryptionatrestprivateendpoint/resource_test.go

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"regexp"
78
"testing"
89
"time"
910

@@ -32,6 +33,34 @@ func TestAccEncryptionAtRestPrivateEndpoint_Azure_basic(t *testing.T) {
3233
resource.Test(t, *basicTestCaseAzure(t))
3334
}
3435

36+
func TestAccEncryptionAtRestPrivateEndpoint_createTimeoutWithDeleteOnCreate(t *testing.T) {
37+
// This test is skipped because it creates a race condition with other tests:
38+
// 1. This test creates an encryption at rest private endpoint with a 1s timeout, causing it to fail and trigger cleanup
39+
// 2. The private endpoint deletion doesn't complete immediately
40+
// 3. Other tests share the same project and attempt to disable encryption at rest during cleanup
41+
// 4. MongoDB Atlas returns "CANNOT_DISABLE_ENCRYPTION_AT_REST_REQUIRE_PRIVATE_NETWORKING_WHILE_PRIVATE_ENDPOINTS_EXIST"
42+
// because the private endpoint from this test is still being deleted
43+
// This race condition occurs even when tests don't run in parallel due to the async nature of private endpoint deletion.
44+
acc.SkipTestForCI(t)
45+
var (
46+
createTimeout = "1s"
47+
deleteOnCreateTimeout = true
48+
region = conversion.AWSRegionToMongoDBRegion(os.Getenv("AWS_REGION"))
49+
// Create encryption at rest configuration outside of test configuration to avoid cleanup issues
50+
projectID = acc.EncryptionAtRestExecution(t)
51+
)
52+
resource.Test(t, resource.TestCase{
53+
PreCheck: func() { acc.PreCheckEncryptionAtRestEnvAWS(t) },
54+
ProtoV6ProviderFactories: acc.TestAccProviderV6Factories,
55+
Steps: []resource.TestStep{
56+
{
57+
Config: configEARPrivateEndpointWithTimeout(projectID, region, acc.TimeoutConfig(&createTimeout, nil, nil), &deleteOnCreateTimeout),
58+
ExpectError: regexp.MustCompile("will run cleanup because delete_on_create_timeout is true"),
59+
},
60+
},
61+
})
62+
}
63+
3564
func basicTestCaseAzure(tb testing.TB) *resource.TestCase {
3665
tb.Helper()
3766
var (
@@ -316,19 +345,57 @@ func checkBasic(projectID, cloudProvider, region string, expectApproved bool) re
316345
}
317346

318347
func configAWSBasic(projectID string, awsKms *admin.AWSKMSConfiguration, region string) string {
348+
return configAWSBasicWithTimeout(projectID, awsKms, region, "", nil)
349+
}
350+
351+
func configAWSBasicWithTimeout(projectID string, awsKms *admin.AWSKMSConfiguration, region, timeoutConfig string, deleteOnCreateTimeout *bool) string {
319352
encryptionAtRestConfig := acc.ConfigAwsKms(projectID, awsKms, false, true, false)
353+
354+
deleteOnCreateTimeoutConfig := ""
355+
if deleteOnCreateTimeout != nil {
356+
deleteOnCreateTimeoutConfig = fmt.Sprintf(`
357+
delete_on_create_timeout = %[1]t
358+
`, *deleteOnCreateTimeout)
359+
}
360+
320361
config := fmt.Sprintf(`
321362
%[1]s
322363
323364
resource "mongodbatlas_encryption_at_rest_private_endpoint" "test" {
324365
project_id = mongodbatlas_encryption_at_rest.test.project_id
325366
cloud_provider = "AWS"
326367
region_name = %[2]q
368+
%[3]s
369+
%[4]s
327370
}
328371
329-
%[3]s
372+
%[5]s
330373
331-
`, encryptionAtRestConfig, region, configDS())
374+
`, encryptionAtRestConfig, region, deleteOnCreateTimeoutConfig, timeoutConfig, configDS())
375+
376+
return config
377+
}
378+
379+
func configEARPrivateEndpointWithTimeout(projectID, region, timeoutConfig string, deleteOnCreateTimeout *bool) string {
380+
deleteOnCreateTimeoutConfig := ""
381+
if deleteOnCreateTimeout != nil {
382+
deleteOnCreateTimeoutConfig = fmt.Sprintf(`
383+
delete_on_create_timeout = %[1]t
384+
`, *deleteOnCreateTimeout)
385+
}
386+
387+
config := fmt.Sprintf(`
388+
resource "mongodbatlas_encryption_at_rest_private_endpoint" "test" {
389+
project_id = %[1]q
390+
cloud_provider = "AWS"
391+
region_name = %[2]q
392+
%[3]s
393+
%[4]s
394+
}
395+
396+
%[5]s
397+
398+
`, projectID, region, deleteOnCreateTimeoutConfig, timeoutConfig, configDS())
332399

333400
return config
334401
}

0 commit comments

Comments
 (0)