diff --git a/.changelog/3561.txt b/.changelog/3561.txt new file mode 100644 index 0000000000..a9827c16f5 --- /dev/null +++ b/.changelog/3561.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +resource/mongodbatlas_encryption_at_rest_private_endpoint: Adds `timeouts` attribute for create and delete operations +``` + +```release-note:enhancement +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 +``` diff --git a/docs/guides/2.0.0-upgrade-guide.md b/docs/guides/2.0.0-upgrade-guide.md index a6410dc426..3414de3b95 100644 --- a/docs/guides/2.0.0-upgrade-guide.md +++ b/docs/guides/2.0.0-upgrade-guide.md @@ -16,6 +16,7 @@ The Terraform MongoDB Atlas Provider version 2.0.0 has the following new feature - `mongodbatlas_advanced_cluster` - `mongodbatlas_cloud_backup_snapshot` - `mongodbatlas_cluster_outage_simulation` + - `mongodbatlas_encryption_at_rest_private_endpoint` - `mongodbatlas_flex_cluster` - `mongodbatlas_network_peering` - `mongodbatlas_online_archive` diff --git a/docs/resources/encryption_at_rest_private_endpoint.md b/docs/resources/encryption_at_rest_private_endpoint.md index 0accc86d81..0e6636e334 100644 --- a/docs/resources/encryption_at_rest_private_endpoint.md +++ b/docs/resources/encryption_at_rest_private_endpoint.md @@ -99,6 +99,11 @@ resource "mongodbatlas_encryption_at_rest_private_endpoint" "endpoint" { - `project_id` (String) Unique 24-hexadecimal digit string that identifies your project. - `region_name` (String) Cloud provider region in which the Encryption At Rest private endpoint is located. +### Optional + +- `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`. +- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts)) + ### Read-Only - `error_message` (String) Error message for failures associated with the Encryption At Rest private endpoint. @@ -106,6 +111,14 @@ resource "mongodbatlas_encryption_at_rest_private_endpoint" "endpoint" { - `private_endpoint_connection_name` (String) Connection name of the Azure Private Endpoint. - `status` (String) State of the Encryption At Rest private endpoint. + +### 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. + ## Import 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. diff --git a/internal/service/encryptionatrestprivateendpoint/data_source.go b/internal/service/encryptionatrestprivateendpoint/data_source.go index d7116fc134..cde16dee69 100644 --- a/internal/service/encryptionatrestprivateendpoint/data_source.go +++ b/internal/service/encryptionatrestprivateendpoint/data_source.go @@ -31,7 +31,7 @@ func (d *encryptionAtRestPrivateEndpointDS) Schema(ctx context.Context, req data } func (d *encryptionAtRestPrivateEndpointDS) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var earPrivateEndpointConfig TFEarPrivateEndpointModel + var earPrivateEndpointConfig TFEarPrivateEndpointModelDS resp.Diagnostics.Append(req.Config.Get(ctx, &earPrivateEndpointConfig)...) if resp.Diagnostics.HasError() { return @@ -48,5 +48,5 @@ func (d *encryptionAtRestPrivateEndpointDS) Read(ctx context.Context, req dataso return } - resp.Diagnostics.Append(resp.State.Set(ctx, NewTFEarPrivateEndpoint(*endpointModel, projectID))...) + resp.Diagnostics.Append(resp.State.Set(ctx, NewTFEarPrivateEndpointDS(*endpointModel, projectID))...) } diff --git a/internal/service/encryptionatrestprivateendpoint/data_source_schema.go b/internal/service/encryptionatrestprivateendpoint/data_source_schema.go index 841373c896..77222b4355 100644 --- a/internal/service/encryptionatrestprivateendpoint/data_source_schema.go +++ b/internal/service/encryptionatrestprivateendpoint/data_source_schema.go @@ -41,8 +41,19 @@ func DSAttributes(withArguments bool) map[string]schema.Attribute { } } +// TFEarPrivateEndpointModelDS represents the model for data sources (without timeout fields) +type TFEarPrivateEndpointModelDS struct { + CloudProvider types.String `tfsdk:"cloud_provider"` + ErrorMessage types.String `tfsdk:"error_message"` + ProjectID types.String `tfsdk:"project_id"` + ID types.String `tfsdk:"id"` + PrivateEndpointConnectionName types.String `tfsdk:"private_endpoint_connection_name"` + RegionName types.String `tfsdk:"region_name"` + Status types.String `tfsdk:"status"` +} + type TFEncryptionAtRestPrivateEndpointsDSModel struct { - CloudProvider types.String `tfsdk:"cloud_provider"` - ProjectID types.String `tfsdk:"project_id"` - Results []TFEarPrivateEndpointModel `tfsdk:"results"` + CloudProvider types.String `tfsdk:"cloud_provider"` + ProjectID types.String `tfsdk:"project_id"` + Results []TFEarPrivateEndpointModelDS `tfsdk:"results"` } diff --git a/internal/service/encryptionatrestprivateendpoint/model.go b/internal/service/encryptionatrestprivateendpoint/model.go index 792298e164..8c4aee4a4a 100644 --- a/internal/service/encryptionatrestprivateendpoint/model.go +++ b/internal/service/encryptionatrestprivateendpoint/model.go @@ -31,9 +31,9 @@ func NewEarPrivateEndpointReq(tfPlan *TFEarPrivateEndpointModel) *admin.EARPriva } func NewTFEarPrivateEndpoints(projectID, cloudProvider string, sdkResults []admin.EARPrivateEndpoint) *TFEncryptionAtRestPrivateEndpointsDSModel { - results := make([]TFEarPrivateEndpointModel, len(sdkResults)) + results := make([]TFEarPrivateEndpointModelDS, len(sdkResults)) for i := range sdkResults { - result := NewTFEarPrivateEndpoint(sdkResults[i], projectID) + result := NewTFEarPrivateEndpointDS(sdkResults[i], projectID) results[i] = result } return &TFEncryptionAtRestPrivateEndpointsDSModel{ @@ -42,3 +42,16 @@ func NewTFEarPrivateEndpoints(projectID, cloudProvider string, sdkResults []admi Results: results, } } + +// NewTFEarPrivateEndpointDS creates a new data source model without timeout fields +func NewTFEarPrivateEndpointDS(apiResp admin.EARPrivateEndpoint, projectID string) TFEarPrivateEndpointModelDS { + return TFEarPrivateEndpointModelDS{ + ProjectID: types.StringValue(projectID), + CloudProvider: conversion.StringNullIfEmpty(apiResp.GetCloudProvider()), + ErrorMessage: conversion.StringNullIfEmpty(apiResp.GetErrorMessage()), + ID: conversion.StringNullIfEmpty(apiResp.GetId()), + RegionName: conversion.StringNullIfEmpty(apiResp.GetRegionName()), + Status: conversion.StringNullIfEmpty(apiResp.GetStatus()), + PrivateEndpointConnectionName: conversion.StringNullIfEmpty(apiResp.GetPrivateEndpointConnectionName()), + } +} diff --git a/internal/service/encryptionatrestprivateendpoint/model_test.go b/internal/service/encryptionatrestprivateendpoint/model_test.go index 8757d72d41..3fd42c56a8 100644 --- a/internal/service/encryptionatrestprivateendpoint/model_test.go +++ b/internal/service/encryptionatrestprivateendpoint/model_test.go @@ -174,7 +174,7 @@ func TestEncryptionAtRestPrivateEndpointPluralDSSDKToTFModel(t *testing.T) { expectedTFModel: &encryptionatrestprivateendpoint.TFEncryptionAtRestPrivateEndpointsDSModel{ CloudProvider: types.StringValue(testCloudProvider), ProjectID: types.StringValue(testProjectID), - Results: []encryptionatrestprivateendpoint.TFEarPrivateEndpointModel{ + Results: []encryptionatrestprivateendpoint.TFEarPrivateEndpointModelDS{ { CloudProvider: types.StringValue(testCloudProvider), ErrorMessage: types.StringNull(), diff --git a/internal/service/encryptionatrestprivateendpoint/resource.go b/internal/service/encryptionatrestprivateendpoint/resource.go index ca6c2cc46b..2212e7c46a 100644 --- a/internal/service/encryptionatrestprivateendpoint/resource.go +++ b/internal/service/encryptionatrestprivateendpoint/resource.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/cleanup" "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/retrystrategy" "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/validate" @@ -63,13 +64,28 @@ func (r *encryptionAtRestPrivateEndpointRS) Create(ctx context.Context, req reso return } - finalResp, err := waitStateTransition(ctx, projectID, cloudProvider, createResp.GetId(), connV2.EncryptionAtRestUsingCustomerKeyManagementApi) + createTimeout := cleanup.ResolveTimeout(ctx, &earPrivateEndpointPlan.Timeouts, cleanup.OperationCreate, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + finalResp, err := waitStateTransition(ctx, projectID, cloudProvider, createResp.GetId(), connV2.EncryptionAtRestUsingCustomerKeyManagementApi, createTimeout) + err = cleanup.HandleCreateTimeout(cleanup.ResolveDeleteOnCreateTimeout(earPrivateEndpointPlan.DeleteOnCreateTimeout), err, func(ctxCleanup context.Context) error { + cleanResp, cleanErr := connV2.EncryptionAtRestUsingCustomerKeyManagementApi.RequestEncryptionAtRestPrivateEndpointDeletion(ctxCleanup, projectID, cloudProvider, createResp.GetId()).Execute() + if validate.StatusNotFound(cleanResp) { + return nil + } + return cleanErr + }) + if err != nil { resp.Diagnostics.AddError("error when waiting for status transition in creation", err.Error()) return } privateEndpointModel := NewTFEarPrivateEndpoint(*finalResp, projectID) + privateEndpointModel.Timeouts = earPrivateEndpointPlan.Timeouts + privateEndpointModel.DeleteOnCreateTimeout = earPrivateEndpointPlan.DeleteOnCreateTimeout resp.Diagnostics.Append(resp.State.Set(ctx, privateEndpointModel)...) diags := CheckErrorMessageAndStatus(finalResp) @@ -98,7 +114,10 @@ func (r *encryptionAtRestPrivateEndpointRS) Read(ctx context.Context, req resour return } - resp.Diagnostics.Append(resp.State.Set(ctx, NewTFEarPrivateEndpoint(*endpointModel, projectID))...) + privateEndpointModel := NewTFEarPrivateEndpoint(*endpointModel, projectID) + privateEndpointModel.Timeouts = earPrivateEndpointState.Timeouts + privateEndpointModel.DeleteOnCreateTimeout = earPrivateEndpointState.DeleteOnCreateTimeout + resp.Diagnostics.Append(resp.State.Set(ctx, privateEndpointModel)...) diags := CheckErrorMessageAndStatus(endpointModel) resp.Diagnostics.Append(diags...) @@ -124,7 +143,12 @@ func (r *encryptionAtRestPrivateEndpointRS) Delete(ctx context.Context, req reso return } - model, err := WaitDeleteStateTransition(ctx, projectID, cloudProvider, endpointID, connV2.EncryptionAtRestUsingCustomerKeyManagementApi) + deleteTimeout := cleanup.ResolveTimeout(ctx, &earPrivateEndpointState.Timeouts, cleanup.OperationDelete, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + model, err := WaitDeleteStateTransition(ctx, projectID, cloudProvider, endpointID, connV2.EncryptionAtRestUsingCustomerKeyManagementApi, deleteTimeout) if err != nil { resp.Diagnostics.AddError("error when waiting for status transition in delete", err.Error()) return diff --git a/internal/service/encryptionatrestprivateendpoint/resource_schema.go b/internal/service/encryptionatrestprivateendpoint/resource_schema.go index fe8ad6fdc6..e04b0ee1fa 100644 --- a/internal/service/encryptionatrestprivateendpoint/resource_schema.go +++ b/internal/service/encryptionatrestprivateendpoint/resource_schema.go @@ -3,8 +3,11 @@ package encryptionatrestprivateendpoint import ( "context" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/customplanmodifier" ) func ResourceSchema(ctx context.Context) schema.Schema { @@ -38,16 +41,29 @@ func ResourceSchema(ctx context.Context) schema.Schema { Computed: true, MarkdownDescription: "State of the Encryption At Rest private endpoint.", }, + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Create: true, + Delete: true, + }), + "delete_on_create_timeout": schema.BoolAttribute{ + Optional: true, + PlanModifiers: []planmodifier.Bool{ + customplanmodifier.CreateOnlyBoolPlanModifier(), + }, + 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`.", + }, }, } } type TFEarPrivateEndpointModel struct { - CloudProvider types.String `tfsdk:"cloud_provider"` - ErrorMessage types.String `tfsdk:"error_message"` - ProjectID types.String `tfsdk:"project_id"` - ID types.String `tfsdk:"id"` - PrivateEndpointConnectionName types.String `tfsdk:"private_endpoint_connection_name"` - RegionName types.String `tfsdk:"region_name"` - Status types.String `tfsdk:"status"` + CloudProvider types.String `tfsdk:"cloud_provider"` + ErrorMessage types.String `tfsdk:"error_message"` + ProjectID types.String `tfsdk:"project_id"` + ID types.String `tfsdk:"id"` + PrivateEndpointConnectionName types.String `tfsdk:"private_endpoint_connection_name"` + RegionName types.String `tfsdk:"region_name"` + Status types.String `tfsdk:"status"` + Timeouts timeouts.Value `tfsdk:"timeouts"` + DeleteOnCreateTimeout types.Bool `tfsdk:"delete_on_create_timeout"` } diff --git a/internal/service/encryptionatrestprivateendpoint/resource_test.go b/internal/service/encryptionatrestprivateendpoint/resource_test.go index 3c3e351dd6..ee36f56a3c 100644 --- a/internal/service/encryptionatrestprivateendpoint/resource_test.go +++ b/internal/service/encryptionatrestprivateendpoint/resource_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "regexp" "testing" "time" @@ -32,6 +33,34 @@ func TestAccEncryptionAtRestPrivateEndpoint_Azure_basic(t *testing.T) { resource.Test(t, *basicTestCaseAzure(t)) } +func TestAccEncryptionAtRestPrivateEndpoint_createTimeoutWithDeleteOnCreate(t *testing.T) { + // This test is skipped because it creates a race condition with other tests: + // 1. This test creates an encryption at rest private endpoint with a 1s timeout, causing it to fail and trigger cleanup + // 2. The private endpoint deletion doesn't complete immediately + // 3. Other tests share the same project and attempt to disable encryption at rest during cleanup + // 4. MongoDB Atlas returns "CANNOT_DISABLE_ENCRYPTION_AT_REST_REQUIRE_PRIVATE_NETWORKING_WHILE_PRIVATE_ENDPOINTS_EXIST" + // because the private endpoint from this test is still being deleted + // This race condition occurs even when tests don't run in parallel due to the async nature of private endpoint deletion. + acc.SkipTestForCI(t) + var ( + createTimeout = "1s" + deleteOnCreateTimeout = true + region = conversion.AWSRegionToMongoDBRegion(os.Getenv("AWS_REGION")) + // Create encryption at rest configuration outside of test configuration to avoid cleanup issues + projectID = acc.EncryptionAtRestExecution(t) + ) + resource.Test(t, resource.TestCase{ + PreCheck: func() { acc.PreCheckEncryptionAtRestEnvAWS(t) }, + ProtoV6ProviderFactories: acc.TestAccProviderV6Factories, + Steps: []resource.TestStep{ + { + Config: configEARPrivateEndpointWithTimeout(projectID, region, acc.TimeoutConfig(&createTimeout, nil, nil), &deleteOnCreateTimeout), + ExpectError: regexp.MustCompile("will run cleanup because delete_on_create_timeout is true"), + }, + }, + }) +} + func basicTestCaseAzure(tb testing.TB) *resource.TestCase { tb.Helper() var ( @@ -316,7 +345,19 @@ func checkBasic(projectID, cloudProvider, region string, expectApproved bool) re } func configAWSBasic(projectID string, awsKms *admin.AWSKMSConfiguration, region string) string { + return configAWSBasicWithTimeout(projectID, awsKms, region, "", nil) +} + +func configAWSBasicWithTimeout(projectID string, awsKms *admin.AWSKMSConfiguration, region, timeoutConfig string, deleteOnCreateTimeout *bool) string { encryptionAtRestConfig := acc.ConfigAwsKms(projectID, awsKms, false, true, false) + + deleteOnCreateTimeoutConfig := "" + if deleteOnCreateTimeout != nil { + deleteOnCreateTimeoutConfig = fmt.Sprintf(` + delete_on_create_timeout = %[1]t + `, *deleteOnCreateTimeout) + } + config := fmt.Sprintf(` %[1]s @@ -324,11 +365,37 @@ func configAWSBasic(projectID string, awsKms *admin.AWSKMSConfiguration, region project_id = mongodbatlas_encryption_at_rest.test.project_id cloud_provider = "AWS" region_name = %[2]q + %[3]s + %[4]s } - %[3]s + %[5]s - `, encryptionAtRestConfig, region, configDS()) + `, encryptionAtRestConfig, region, deleteOnCreateTimeoutConfig, timeoutConfig, configDS()) + + return config +} + +func configEARPrivateEndpointWithTimeout(projectID, region, timeoutConfig string, deleteOnCreateTimeout *bool) string { + deleteOnCreateTimeoutConfig := "" + if deleteOnCreateTimeout != nil { + deleteOnCreateTimeoutConfig = fmt.Sprintf(` + delete_on_create_timeout = %[1]t + `, *deleteOnCreateTimeout) + } + + config := fmt.Sprintf(` + resource "mongodbatlas_encryption_at_rest_private_endpoint" "test" { + project_id = %[1]q + cloud_provider = "AWS" + region_name = %[2]q + %[3]s + %[4]s + } + + %[5]s + + `, projectID, region, deleteOnCreateTimeoutConfig, timeoutConfig, configDS()) return config } diff --git a/internal/service/encryptionatrestprivateendpoint/state_transition.go b/internal/service/encryptionatrestprivateendpoint/state_transition.go index 1199b8c9ca..57f3263737 100644 --- a/internal/service/encryptionatrestprivateendpoint/state_transition.go +++ b/internal/service/encryptionatrestprivateendpoint/state_transition.go @@ -18,36 +18,36 @@ const ( defaultMinTimeout = 30 * time.Second // Smallest time to wait before refreshes ) -func waitStateTransition(ctx context.Context, projectID, cloudProvider, endpointID string, client admin.EncryptionAtRestUsingCustomerKeyManagementApi) (*admin.EARPrivateEndpoint, error) { - return WaitStateTransitionWithMinTimeout(ctx, defaultMinTimeout, projectID, cloudProvider, endpointID, client) +func waitStateTransition(ctx context.Context, projectID, cloudProvider, endpointID string, client admin.EncryptionAtRestUsingCustomerKeyManagementApi, timeout time.Duration) (*admin.EARPrivateEndpoint, error) { + return WaitStateTransitionWithMinTimeoutAndTimeout(ctx, defaultMinTimeout, timeout, projectID, cloudProvider, endpointID, client) } -func WaitStateTransitionWithMinTimeout(ctx context.Context, minTimeout time.Duration, projectID, cloudProvider, endpointID string, client admin.EncryptionAtRestUsingCustomerKeyManagementApi) (*admin.EARPrivateEndpoint, error) { +func WaitStateTransitionWithMinTimeoutAndTimeout(ctx context.Context, minTimeout, timeout time.Duration, projectID, cloudProvider, endpointID string, client admin.EncryptionAtRestUsingCustomerKeyManagementApi) (*admin.EARPrivateEndpoint, error) { return waitStateTransitionForStates( ctx, []string{retrystrategy.RetryStrategyInitiatingState}, []string{retrystrategy.RetryStrategyPendingAcceptanceState, retrystrategy.RetryStrategyActiveState, retrystrategy.RetryStrategyFailedState}, - minTimeout, projectID, cloudProvider, endpointID, client) + minTimeout, timeout, projectID, cloudProvider, endpointID, client) } -func WaitDeleteStateTransition(ctx context.Context, projectID, cloudProvider, endpointID string, client admin.EncryptionAtRestUsingCustomerKeyManagementApi) (*admin.EARPrivateEndpoint, error) { - return WaitDeleteStateTransitionWithMinTimeout(ctx, defaultMinTimeout, projectID, cloudProvider, endpointID, client) +func WaitDeleteStateTransition(ctx context.Context, projectID, cloudProvider, endpointID string, client admin.EncryptionAtRestUsingCustomerKeyManagementApi, timeout time.Duration) (*admin.EARPrivateEndpoint, error) { + return WaitDeleteStateTransitionWithMinTimeoutAndTimeout(ctx, defaultMinTimeout, timeout, projectID, cloudProvider, endpointID, client) } -func WaitDeleteStateTransitionWithMinTimeout(ctx context.Context, minTimeout time.Duration, projectID, cloudProvider, endpointID string, client admin.EncryptionAtRestUsingCustomerKeyManagementApi) (*admin.EARPrivateEndpoint, error) { +func WaitDeleteStateTransitionWithMinTimeoutAndTimeout(ctx context.Context, minTimeout, timeout time.Duration, projectID, cloudProvider, endpointID string, client admin.EncryptionAtRestUsingCustomerKeyManagementApi) (*admin.EARPrivateEndpoint, error) { return waitStateTransitionForStates( ctx, []string{retrystrategy.RetryStrategyDeletingState}, []string{retrystrategy.RetryStrategyDeletedState, retrystrategy.RetryStrategyFailedState}, - minTimeout, projectID, cloudProvider, endpointID, client) + minTimeout, timeout, projectID, cloudProvider, endpointID, client) } -func waitStateTransitionForStates(ctx context.Context, pending, target []string, minTimeout time.Duration, projectID, cloudProvider, endpointID string, client admin.EncryptionAtRestUsingCustomerKeyManagementApi) (*admin.EARPrivateEndpoint, error) { +func waitStateTransitionForStates(ctx context.Context, pending, target []string, minTimeout, timeout time.Duration, projectID, cloudProvider, endpointID string, client admin.EncryptionAtRestUsingCustomerKeyManagementApi) (*admin.EARPrivateEndpoint, error) { stateConf := &retry.StateChangeConf{ Pending: pending, Target: target, Refresh: refreshFunc(ctx, projectID, cloudProvider, endpointID, client), - Timeout: defaultTimeout, + Timeout: timeout, MinTimeout: minTimeout, Delay: 0, } diff --git a/internal/service/encryptionatrestprivateendpoint/state_transition_test.go b/internal/service/encryptionatrestprivateendpoint/state_transition_test.go index 5ad1e5b40a..6abfc5bb43 100644 --- a/internal/service/encryptionatrestprivateendpoint/state_transition_test.go +++ b/internal/service/encryptionatrestprivateendpoint/state_transition_test.go @@ -67,7 +67,7 @@ func TestStateTransition(t *testing.T) { modelResp, httpResp, err := resp.get() m.EXPECT().GetEncryptionAtRestPrivateEndpointExecute(mock.Anything).Return(modelResp, httpResp, err).Once() } - resp, err := encryptionatrestprivateendpoint.WaitStateTransitionWithMinTimeout(t.Context(), 1*time.Second, "project-id", "cloud-provider", "endpoint-id", m) + resp, err := encryptionatrestprivateendpoint.WaitStateTransitionWithMinTimeoutAndTimeout(t.Context(), 1*time.Second, 20*time.Minute, "project-id", "cloud-provider", "endpoint-id", m) assert.Equal(t, tc.expectedError, err != nil) if resp != nil { assert.Equal(t, tc.expectedState, resp.Status) @@ -111,7 +111,7 @@ func TestDeleteStateTransition(t *testing.T) { modelResp, httpResp, err := resp.get() m.EXPECT().GetEncryptionAtRestPrivateEndpointExecute(mock.Anything).Return(modelResp, httpResp, err).Once() } - resp, err := encryptionatrestprivateendpoint.WaitDeleteStateTransitionWithMinTimeout(t.Context(), 1*time.Second, "project-id", "cloud-provider", "endpoint-id", m) + resp, err := encryptionatrestprivateendpoint.WaitDeleteStateTransitionWithMinTimeoutAndTimeout(t.Context(), 1*time.Second, 20*time.Minute, "project-id", "cloud-provider", "endpoint-id", m) assert.Equal(t, tc.expectedError, err != nil) if resp != nil { assert.Equal(t, tc.expectedState, resp.Status) diff --git a/internal/service/flexcluster/resource.go b/internal/service/flexcluster/resource.go index 11c6841023..17ee5dea7e 100644 --- a/internal/service/flexcluster/resource.go +++ b/internal/service/flexcluster/resource.go @@ -80,7 +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 := resolveDeleteOnCreateTimeout(tfModel.DeleteOnCreateTimeout) + deleteOnCreateTimeout := cleanup.ResolveDeleteOnCreateTimeout(tfModel.DeleteOnCreateTimeout) err = cleanup.HandleCreateTimeout(deleteOnCreateTimeout, err, func(ctxCleanup context.Context) error { cleanResp, cleanErr := r.Client.AtlasV2.FlexClustersApi.DeleteFlexCluster(ctxCleanup, projectID, clusterName).Execute() if validate.StatusNotFound(cleanResp) { @@ -241,17 +241,6 @@ func splitFlexClusterImportID(id string) (projectID, clusterName *string, err er return } -// resolveDeleteOnCreateTimeout returns true if delete_on_create_timeout should be enabled. -// Default behavior is true when not explicitly set to false. -func resolveDeleteOnCreateTimeout(deleteOnCreateTimeout types.Bool) bool { - // If null or unknown, default to true - if deleteOnCreateTimeout.IsNull() || deleteOnCreateTimeout.IsUnknown() { - return true - } - // Otherwise use the explicit value - return deleteOnCreateTimeout.ValueBool() -} - func CreateFlexCluster(ctx context.Context, projectID, clusterName string, flexClusterReq *admin.FlexClusterDescriptionCreate20241113, client admin.FlexClustersApi, timeout *time.Duration) (*admin.FlexClusterDescription20241113, error) { _, _, err := client.CreateFlexCluster(ctx, projectID, flexClusterReq).Execute() if err != nil { diff --git a/internal/service/flexcluster/resource_test.go b/internal/service/flexcluster/resource_test.go index 7bbdbbd6ae..2cef4ca1cc 100644 --- a/internal/service/flexcluster/resource_test.go +++ b/internal/service/flexcluster/resource_test.go @@ -49,6 +49,7 @@ func TestAccFlexClusterRS_createTimeoutWithDeleteOnCreateFlex(t *testing.T) { } func TestAccFlexClusterRS_updateDeleteTimeout(t *testing.T) { + acc.SkipTestForCI(t) // Update is consistently too fast and it does not time out, making the test flaky var ( projectID = acc.ProjectIDExecution(t) clusterName = acc.RandomName() diff --git a/internal/testutil/acc/encryption_at_rest.go b/internal/testutil/acc/encryption_at_rest.go index 46e55b93d6..c1a1ebcb47 100644 --- a/internal/testutil/acc/encryption_at_rest.go +++ b/internal/testutil/acc/encryption_at_rest.go @@ -3,12 +3,16 @@ package acc import ( "context" "fmt" + "os" "strconv" + "testing" "go.mongodb.org/atlas-sdk/v20250312006/admin" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" + "github.com/stretchr/testify/require" ) func ConfigEARAzureKeyVault(projectID string, azure *admin.AzureKeyVault, useRequirePrivateNetworking, useDatasource bool) string { @@ -153,3 +157,65 @@ func EARImportStateIDFunc(resourceName string) resource.ImportStateIdFunc { return rs.Primary.ID, nil } } + +// EncryptionAtRestExecution creates an encryption at rest configuration for test execution. +func EncryptionAtRestExecution(tb testing.TB) string { + tb.Helper() + SkipInUnitTest(tb) + require.True(tb, sharedInfo.init, "SetupSharedResources must called from TestMain test package") + + projectID := os.Getenv("MONGODB_ATLAS_PROJECT_EAR_PE_AWS_ID") + + sharedInfo.mu.Lock() + defer sharedInfo.mu.Unlock() + + // lazy creation so it's only done if really needed + if !sharedInfo.encryptionAtRestEnabled { + tb.Logf("Creating execution encryption at rest configuration for project: %s\n", projectID) + + // Create encryption at rest configuration using environment variables + awsKms := &admin.AWSKMSConfiguration{ + Enabled: conversion.Pointer(true), + CustomerMasterKeyID: conversion.StringPtr(os.Getenv("AWS_CUSTOMER_MASTER_KEY_ID")), + Region: conversion.StringPtr(conversion.AWSRegionToMongoDBRegion(os.Getenv("AWS_REGION"))), + RoleId: conversion.StringPtr(os.Getenv("AWS_EAR_ROLE_ID")), + RequirePrivateNetworking: conversion.Pointer(true), + } + + createEncryptionAtRest(tb, projectID, awsKms) + sharedInfo.encryptionAtRestEnabled = true + } + + return projectID +} + +func createEncryptionAtRest(tb testing.TB, projectID string, aws *admin.AWSKMSConfiguration) { + tb.Helper() + + encryptionAtRestReq := &admin.EncryptionAtRest{ + AwsKms: aws, + } + + _, _, err := ConnV2().EncryptionAtRestUsingCustomerKeyManagementApi.UpdateEncryptionAtRest(tb.Context(), projectID, encryptionAtRestReq).Execute() + require.NoError(tb, err, "Failed to create encryption at rest configuration for project: %s", projectID) +} + +func deleteEncryptionAtRest(projectID string) { + // Disable encryption at rest by setting all providers to disabled + encryptionAtRestReq := &admin.EncryptionAtRest{ + AwsKms: &admin.AWSKMSConfiguration{ + Enabled: conversion.Pointer(false), + }, + AzureKeyVault: &admin.AzureKeyVault{ + Enabled: conversion.Pointer(false), + }, + GoogleCloudKms: &admin.GoogleCloudKMS{ + Enabled: conversion.Pointer(false), + }, + } + + _, _, err := ConnV2().EncryptionAtRestUsingCustomerKeyManagementApi.UpdateEncryptionAtRest(context.Background(), projectID, encryptionAtRestReq).Execute() + if err != nil { + fmt.Printf("Failed to delete encryption at rest for project %s: %s\n", projectID, err) + } +} diff --git a/internal/testutil/acc/shared_resource.go b/internal/testutil/acc/shared_resource.go index 9f5c9dc4a8..5778d27904 100644 --- a/internal/testutil/acc/shared_resource.go +++ b/internal/testutil/acc/shared_resource.go @@ -49,6 +49,14 @@ func cleanupSharedResources() { fmt.Printf("Deleting execution private link endpoint: %s, project id: %s, provider: %s\n", sharedInfo.privateLinkEndpointID, projectID, sharedInfo.privateLinkProviderName) deletePrivateLinkEndpoint(projectID, sharedInfo.privateLinkProviderName, sharedInfo.privateLinkEndpointID) } + if sharedInfo.encryptionAtRestEnabled { + projectID := sharedInfo.projectID + if projectID == "" { + projectID = projectIDLocal() + } + fmt.Printf("Deleting execution encryption at rest: project id: %s\n", projectID) + deleteEncryptionAtRest(projectID) + } if sharedInfo.projectID != "" { fmt.Printf("Deleting execution project: %s, id: %s\n", sharedInfo.projectName, sharedInfo.projectID) deleteProject(sharedInfo.projectID) @@ -205,15 +213,16 @@ type projectInfo struct { } var sharedInfo = struct { - projectID string projectName string clusterName string streamInstanceName string privateLinkEndpointID string privateLinkProviderName string + projectID string projects []projectInfo mu sync.Mutex muSleep sync.Mutex + encryptionAtRestEnabled bool init bool }{ projects: []projectInfo{},