From 978dd91a5a1ffbfffcd9853a788269c97b9cdcdd Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 27 Aug 2025 12:07:44 +0100 Subject: [PATCH 01/14] refactor: Use createOnly with default for delete_on_create_timeout for a flexcluster --- .../create_only_with_default.go | 91 +++++++++++++++++++ .../common/customplanmodifier/is_known.go | 8 ++ internal/service/flexcluster/resource.go | 3 +- .../service/flexcluster/resource_schema.go | 3 +- 4 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 internal/common/customplanmodifier/create_only_with_default.go create mode 100644 internal/common/customplanmodifier/is_known.go diff --git a/internal/common/customplanmodifier/create_only_with_default.go b/internal/common/customplanmodifier/create_only_with_default.go new file mode 100644 index 0000000000..e63afd00a8 --- /dev/null +++ b/internal/common/customplanmodifier/create_only_with_default.go @@ -0,0 +1,91 @@ +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" +) + +type CreateOnlyModifierWithDefault 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() CreateOnlyModifierWithDefault { + return &createOnlyAttributePlanModifierWithDefault{} +} + +func CreateOnlyAttributePlanModifierWithBoolDefault(b bool) CreateOnlyModifierWithDefault { + return &createOnlyAttributePlanModifierWithDefault{defaultBool: &b} +} + +type createOnlyAttributePlanModifierWithDefault struct { + defaultBool *bool +} + +func (d *createOnlyAttributePlanModifierWithDefault) Description(ctx context.Context) string { + return d.MarkdownDescription(ctx) +} + +func (d *createOnlyAttributePlanModifierWithDefault) 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 *createOnlyAttributePlanModifierWithDefault) UseDefault() bool { + return d.defaultBool != nil +} + +func (d *createOnlyAttributePlanModifierWithDefault) 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 *createOnlyAttributePlanModifierWithDefault) 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 *createOnlyAttributePlanModifierWithDefault) 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/is_known.go b/internal/common/customplanmodifier/is_known.go new file mode 100644 index 0000000000..8eedd62889 --- /dev/null +++ b/internal/common/customplanmodifier/is_known.go @@ -0,0 +1,8 @@ +package customplanmodifier + +import "github.com/hashicorp/terraform-plugin-framework/attr" + +// IsKnown returns true if the attribute is known (not null or unknown). Note that !IsKnown is not the same as IsUnknown because null is !IsKnown but not IsUnknown. +func IsKnown(attribute attr.Value) bool { + return !attribute.IsNull() && !attribute.IsUnknown() +} diff --git a/internal/service/flexcluster/resource.go b/internal/service/flexcluster/resource.go index 17ee5dea7e..0fc0bc4fc9 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..9b35de974d 100644 --- a/internal/service/flexcluster/resource_schema.go +++ b/internal/service/flexcluster/resource_schema.go @@ -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.CreateOnlyAttributePlanModifierWithBoolDefault(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`.", }, From 5f79c13d10e0bd69930700ac3837581fa5462d70 Mon Sep 17 00:00:00 2001 From: Oriol Date: Wed, 3 Sep 2025 09:39:52 +0200 Subject: [PATCH 02/14] feat: Adds support for `role_id` in `google_cloud_kms_config` on `mongodbatlas_encryption_at_rest` resource and data source (#3636) * implement role_id * docs and changelog * fix unit test * refactor checks * fix inconsistent result after apply null/empty string * docs notice * pr suggestion missing period --- .changelog/3636.txt | 7 +++ docs/data-sources/encryption_at_rest.md | 3 + docs/resources/encryption_at_rest.md | 3 + .../encryptionatrest/data_source_schema.go | 4 ++ internal/service/encryptionatrest/model.go | 2 + .../service/encryptionatrest/model_test.go | 2 + internal/service/encryptionatrest/resource.go | 5 ++ .../service/encryptionatrest/resource_test.go | 61 +++++++++++++++++++ internal/testutil/acc/pre_check.go | 7 +++ .../data-sources/encryption_at_rest.md.tmpl | 2 + .../resources/encryption_at_rest.md.tmpl | 2 + 11 files changed, 98 insertions(+) create mode 100644 .changelog/3636.txt diff --git a/.changelog/3636.txt b/.changelog/3636.txt new file mode 100644 index 0000000000..34aae725f0 --- /dev/null +++ b/.changelog/3636.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +resource/mongodbatlas_encryption_at_rest: Supports role_id in google_cloud_kms_config +``` + +```release-note:enhancement +data-source/mongodbatlas_encryption_at_rest: Supports role_id in google_cloud_kms_config +``` diff --git a/docs/data-sources/encryption_at_rest.md b/docs/data-sources/encryption_at_rest.md index d22a232563..d9d06ca514 100644 --- a/docs/data-sources/encryption_at_rest.md +++ b/docs/data-sources/encryption_at_rest.md @@ -106,6 +106,8 @@ output "is_azure_encryption_at_rest_valid" { -> **NOTE:** It is possible to configure Atlas Encryption at Rest to communicate with Customer Managed Keys (Azure Key Vault or AWS KMS) over private network interfaces (Azure Private Link or AWS PrivateLink). This requires enabling the `azure_key_vault_config.require_private_networking` or the `aws_kms_config.require_private_networking` attribute, together with the configuration of the `mongodbatlas_encryption_at_rest_private_endpoint` resource. Please review the `mongodbatlas_encryption_at_rest_private_endpoint` resource for details. ### Configuring encryption at rest using customer key management in GCP +For authentication, you must provide either serviceAccountKey (static credentials) or roleId (service-account–based authentication). Once roleId is configured, serviceAccountKey is no longer supported. + ```terraform resource "mongodbatlas_encryption_at_rest" "test" { project_id = var.atlas_project_id @@ -181,6 +183,7 @@ Read-Only: - `enabled` (Boolean) Flag that indicates whether someone enabled encryption at rest for the specified project. To disable encryption at rest using customer key management and remove the configuration details, pass only this parameter with a value of `false`. - `key_version_resource_id` (String, Sensitive) Resource path that displays the key version resource ID for your Google Cloud KMS. +- `role_id` (String) Unique 24-hexadecimal digit string that identifies the Google Cloud Provider Access Role that MongoDB Cloud uses to access the Google Cloud KMS. - `service_account_key` (String, Sensitive) JavaScript Object Notation (JSON) object that contains the Google Cloud Key Management Service (KMS). Format the JSON as a string and not as an object. - `valid` (Boolean) Flag that indicates whether the Google Cloud Key Management Service (KMS) encryption key can encrypt and decrypt data. diff --git a/docs/resources/encryption_at_rest.md b/docs/resources/encryption_at_rest.md index 98c77466ef..30c96a3a3f 100644 --- a/docs/resources/encryption_at_rest.md +++ b/docs/resources/encryption_at_rest.md @@ -134,6 +134,8 @@ Please review the [`mongodbatlas_encryption_at_rest_private_endpoint` resource d ### Configuring encryption at rest using customer key management in GCP +For authentication, you must provide either serviceAccountKey (static credentials) or roleId (service-account–based authentication). Once roleId is configured, serviceAccountKey is no longer supported. + ```terraform resource "mongodbatlas_encryption_at_rest" "test" { project_id = var.atlas_project_id @@ -210,6 +212,7 @@ Optional: - `enabled` (Boolean) Flag that indicates whether someone enabled encryption at rest for the specified project. To disable encryption at rest using customer key management and remove the configuration details, pass only this parameter with a value of `false`. - `key_version_resource_id` (String, Sensitive) Resource path that displays the key version resource ID for your Google Cloud KMS. +- `role_id` (String) Unique 24-hexadecimal digit string that identifies the Google Cloud Provider Access Role that MongoDB Cloud uses to access the Google Cloud KMS. - `service_account_key` (String, Sensitive) JavaScript Object Notation (JSON) object that contains the Google Cloud Key Management Service (KMS). Format the JSON as a string and not as an object. Read-Only: diff --git a/internal/service/encryptionatrest/data_source_schema.go b/internal/service/encryptionatrest/data_source_schema.go index 258ff8c5bf..ead8063c3e 100644 --- a/internal/service/encryptionatrest/data_source_schema.go +++ b/internal/service/encryptionatrest/data_source_schema.go @@ -128,6 +128,10 @@ func DataSourceSchema(ctx context.Context) schema.Schema { Computed: true, MarkdownDescription: "Flag that indicates whether the Google Cloud Key Management Service (KMS) encryption key can encrypt and decrypt data.", }, + "role_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique 24-hexadecimal digit string that identifies the Google Cloud Provider Access Role that MongoDB Cloud uses to access the Google Cloud KMS.", + }, }, Computed: true, MarkdownDescription: "Details that define the configuration of Encryption at Rest using Google Cloud Key Management Service (KMS).", diff --git a/internal/service/encryptionatrest/model.go b/internal/service/encryptionatrest/model.go index 50ffa5a388..ecd3357ab6 100644 --- a/internal/service/encryptionatrest/model.go +++ b/internal/service/encryptionatrest/model.go @@ -102,6 +102,7 @@ func NewTFGcpKmsConfigItem(gcpKms *admin.GoogleCloudKMS) *TFGcpKmsConfigModel { KeyVersionResourceID: types.StringValue(gcpKms.GetKeyVersionResourceID()), ServiceAccountKey: conversion.StringNullIfEmpty(gcpKms.GetServiceAccountKey()), Valid: types.BoolPointerValue(gcpKms.Valid), + RoleID: conversion.StringNullIfEmpty(gcpKms.GetRoleId()), } } @@ -134,6 +135,7 @@ func NewAtlasGcpKms(tfGcpKmsConfigSlice []TFGcpKmsConfigModel) *admin.GoogleClou Enabled: v.Enabled.ValueBoolPointer(), ServiceAccountKey: v.ServiceAccountKey.ValueStringPointer(), KeyVersionResourceID: v.KeyVersionResourceID.ValueStringPointer(), + RoleId: v.RoleID.ValueStringPointer(), } } diff --git a/internal/service/encryptionatrest/model_test.go b/internal/service/encryptionatrest/model_test.go index 2c07646ebd..84d08f1d60 100644 --- a/internal/service/encryptionatrest/model_test.go +++ b/internal/service/encryptionatrest/model_test.go @@ -76,11 +76,13 @@ var ( Enabled: &enabled, KeyVersionResourceID: &keyVersionResourceID, ServiceAccountKey: &serviceAccountKey, + RoleId: &roleID, } TfGcpKmsConfigModel = encryptionatrest.TFGcpKmsConfigModel{ Enabled: types.BoolValue(enabled), KeyVersionResourceID: types.StringValue(keyVersionResourceID), ServiceAccountKey: types.StringValue(serviceAccountKey), + RoleID: types.StringValue(roleID), } EncryptionAtRest = &admin.EncryptionAtRest{ AwsKms: AWSKMSConfiguration, diff --git a/internal/service/encryptionatrest/resource.go b/internal/service/encryptionatrest/resource.go index 6923b6be12..63674658b1 100644 --- a/internal/service/encryptionatrest/resource.go +++ b/internal/service/encryptionatrest/resource.go @@ -87,6 +87,7 @@ type TFAzureKeyVaultConfigModel struct { type TFGcpKmsConfigModel struct { ServiceAccountKey types.String `tfsdk:"service_account_key"` KeyVersionResourceID types.String `tfsdk:"key_version_resource_id"` + RoleID types.String `tfsdk:"role_id"` Enabled types.Bool `tfsdk:"enabled"` Valid types.Bool `tfsdk:"valid"` } @@ -259,6 +260,10 @@ func (r *encryptionAtRestRS) Schema(ctx context.Context, req resource.SchemaRequ Computed: true, MarkdownDescription: "Flag that indicates whether the Google Cloud Key Management Service (KMS) encryption key can encrypt and decrypt data.", }, + "role_id": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Unique 24-hexadecimal digit string that identifies the Google Cloud Provider Access Role that MongoDB Cloud uses to access the Google Cloud KMS.", + }, }, }, }, diff --git a/internal/service/encryptionatrest/resource_test.go b/internal/service/encryptionatrest/resource_test.go index be2fa573b1..5889b2abda 100644 --- a/internal/service/encryptionatrest/resource_test.go +++ b/internal/service/encryptionatrest/resource_test.go @@ -215,6 +215,32 @@ func TestAccEncryptionAtRest_basicGCP(t *testing.T) { }) } +func TestAccEncryptionAtRest_basicGCPWithRole(t *testing.T) { + acc.SkipTestForCI(t) // needs GCP configuration + + var ( + projectID = os.Getenv("MONGODB_ATLAS_PROJECT_ID") + + googleCloudKms = admin.GoogleCloudKMS{ + Enabled: conversion.Pointer(true), + RoleId: conversion.StringPtr(os.Getenv("GCP_ROLE_ID")), + KeyVersionResourceID: conversion.StringPtr(os.Getenv("GCP_KEY_VERSION_RESOURCE_ID")), + } + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acc.PreCheck(t); acc.PreCheckGCPEnvWithRole(t) }, + ProtoV6ProviderFactories: acc.TestAccProviderV6Factories, + CheckDestroy: acc.EARDestroy, + Steps: []resource.TestStep{ + { + Config: configGoogleCloudKmsWithRole(projectID, &googleCloudKms, true), + Check: checkEARResourceGCPWithRole(projectID), + }, + }, + }) +} + func TestAccEncryptionAtRestWithRole_basicAWS(t *testing.T) { acc.SkipTestForCI(t) // needs AWS configuration. This test case is similar to TestAccEncryptionAtRest_basicAWS except that it creates it's own AWS resources such as IAM roles, cloud provider access, etc so we don't need to run this in CI but may be used for local testing. @@ -516,6 +542,25 @@ func configGoogleCloudKms(projectID string, google *admin.GoogleCloudKMS, useDat return config } +func configGoogleCloudKmsWithRole(projectID string, google *admin.GoogleCloudKMS, useDatasource bool) string { + config := fmt.Sprintf(` + resource "mongodbatlas_encryption_at_rest" "test" { + project_id = "%s" + + google_cloud_kms_config { + enabled = %t + role_id = "%s" + key_version_resource_id = "%s" + } + } + `, projectID, *google.Enabled, google.GetRoleId(), google.GetKeyVersionResourceID()) + + if useDatasource { + return fmt.Sprintf(`%s %s`, config, acc.EARDatasourceConfig()) + } + return config +} + func testAccMongoDBAtlasEncryptionAtRestConfigAwsKmsWithRole(projectID, awsIAMRoleName, awsIAMRolePolicyName, awsKeyName string, awsEar *admin.AWSKMSConfiguration) string { test := fmt.Sprintf(` locals { @@ -620,3 +665,19 @@ func checkEARResourceAWS(projectID string, enabledForSearchNodes bool, awsKmsAtt acc.EARCheckResourceAttr(datasourceName, "aws_kms_config.", awsKmsAttrMap), ) } + +func checkEARResourceGCPWithRole(projectID string) resource.TestCheckFunc { + return resource.ComposeAggregateTestCheckFunc( + acc.CheckEARExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "project_id", projectID), + resource.TestCheckResourceAttr(resourceName, "google_cloud_kms_config.0.role_id", os.Getenv("GCP_ROLE_ID")), + resource.TestCheckResourceAttr(resourceName, "google_cloud_kms_config.0.enabled", "true"), + resource.TestCheckResourceAttr(resourceName, "google_cloud_kms_config.0.valid", "true"), + resource.TestCheckResourceAttrSet(resourceName, "google_cloud_kms_config.0.key_version_resource_id"), + + resource.TestCheckResourceAttr(datasourceName, "project_id", projectID), + resource.TestCheckResourceAttr(datasourceName, "google_cloud_kms_config.enabled", "true"), + resource.TestCheckResourceAttr(datasourceName, "google_cloud_kms_config.valid", "true"), + resource.TestCheckResourceAttrSet(datasourceName, "google_cloud_kms_config.key_version_resource_id"), + ) +} diff --git a/internal/testutil/acc/pre_check.go b/internal/testutil/acc/pre_check.go index e07e0039d3..dea0d0fdf5 100644 --- a/internal/testutil/acc/pre_check.go +++ b/internal/testutil/acc/pre_check.go @@ -155,6 +155,13 @@ func PreCheckGPCEnv(tb testing.TB) { } } +func PreCheckGCPEnvWithRole(tb testing.TB) { + tb.Helper() + if os.Getenv("GCP_ROLE_ID") == "" || os.Getenv("GCP_KEY_VERSION_RESOURCE_ID") == "" { + tb.Fatal("`GCP_ROLE_ID` and `GCP_KEY_VERSION_RESOURCE_ID` must be set for acceptance testing") + } +} + func PreCheckPeeringEnvAWS(tb testing.TB) { tb.Helper() if os.Getenv("AWS_ACCOUNT_ID") == "" || diff --git a/templates/data-sources/encryption_at_rest.md.tmpl b/templates/data-sources/encryption_at_rest.md.tmpl index d2cb32135a..1b2100bad5 100644 --- a/templates/data-sources/encryption_at_rest.md.tmpl +++ b/templates/data-sources/encryption_at_rest.md.tmpl @@ -25,6 +25,8 @@ -> **NOTE:** It is possible to configure Atlas Encryption at Rest to communicate with Customer Managed Keys (Azure Key Vault or AWS KMS) over private network interfaces (Azure Private Link or AWS PrivateLink). This requires enabling the `azure_key_vault_config.require_private_networking` or the `aws_kms_config.require_private_networking` attribute, together with the configuration of the `mongodbatlas_encryption_at_rest_private_endpoint` resource. Please review the `mongodbatlas_encryption_at_rest_private_endpoint` resource for details. ### Configuring encryption at rest using customer key management in GCP +For authentication, you must provide either serviceAccountKey (static credentials) or roleId (service-account–based authentication). Once roleId is configured, serviceAccountKey is no longer supported. + ```terraform resource "mongodbatlas_encryption_at_rest" "test" { project_id = var.atlas_project_id diff --git a/templates/resources/encryption_at_rest.md.tmpl b/templates/resources/encryption_at_rest.md.tmpl index 4d3193abac..8fc07be142 100644 --- a/templates/resources/encryption_at_rest.md.tmpl +++ b/templates/resources/encryption_at_rest.md.tmpl @@ -53,6 +53,8 @@ Please review the [`mongodbatlas_encryption_at_rest_private_endpoint` resource d ### Configuring encryption at rest using customer key management in GCP +For authentication, you must provide either serviceAccountKey (static credentials) or roleId (service-account–based authentication). Once roleId is configured, serviceAccountKey is no longer supported. + ```terraform resource "mongodbatlas_encryption_at_rest" "test" { project_id = var.atlas_project_id From b31e7c2ae00908bac8d17bc3856a78cb79ca82d0 Mon Sep 17 00:00:00 2001 From: svc-apix-bot Date: Wed, 3 Sep 2025 07:42:09 +0000 Subject: [PATCH 03/14] chore: Updates CHANGELOG.md for #3636 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dd53006a6..eda47a3f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## (Unreleased) +ENHANCEMENTS: + +* data-source/mongodbatlas_encryption_at_rest: Supports role_id in google_cloud_kms_config ([#3636](https://github.com/mongodb/terraform-provider-mongodbatlas/pull/3636)) +* resource/mongodbatlas_encryption_at_rest: Supports role_id in google_cloud_kms_config ([#3636](https://github.com/mongodb/terraform-provider-mongodbatlas/pull/3636)) + ## 1.40.0 (August 21, 2025) ENHANCEMENTS: From 995e614d93149e71e4e33267fe06599483c11449 Mon Sep 17 00:00:00 2001 From: Oriol Date: Wed, 3 Sep 2025 10:08:08 +0200 Subject: [PATCH 04/14] feat: Supports GCP for `mongodbatlas_cloud_provider_access_authorization` resource (#3639) * gcp for authorization resource * cloud provider * gcp is computed * remove max items * set gcp * remove gcp from if in update * add missing azure attribute in docs * explain gcp update --- .changelog/3639.txt | 3 +++ docs/resources/cloud_provider_access.md | 7 ++++- ...rce_cloud_provider_access_authorization.go | 27 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 .changelog/3639.txt diff --git a/.changelog/3639.txt b/.changelog/3639.txt new file mode 100644 index 0000000000..09a03ae453 --- /dev/null +++ b/.changelog/3639.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +resource/mongodbatlas_cloud_provider_access_authorization: Supports GCP cloud provider +``` diff --git a/docs/resources/cloud_provider_access.md b/docs/resources/cloud_provider_access.md index 539cf0568f..0027432d60 100644 --- a/docs/resources/cloud_provider_access.md +++ b/docs/resources/cloud_provider_access.md @@ -135,13 +135,18 @@ resource "mongodbatlas_cloud_provider_access_authorization" "auth_role" { Conditional * `aws` * `iam_assumed_role_arn` - (Required) ARN of the IAM Role that Atlas assumes when accessing resources in your AWS account. This value is required after the creation (register of the role) as part of [Set Up Unified AWS Access](https://docs.atlas.mongodb.com/security/set-up-unified-aws-access/#set-up-unified-aws-access). - +* `azure` + * `atlas_azure_app_id` - (Required) Azure Active Directory Application ID of Atlas. + * `service_principal_id` - (Required) UUID string that identifies the Azure Service Principal. + * `tenant_id` - (Required) UUID String that identifies the Azure Active Directory Tenant ID. ## Attributes Reference * `id` - Unique identifier used by terraform for internal management. * `authorized_date` - Date on which this role was authorized. * `feature_usages` - Atlas features this AWS IAM role is linked to. +* `gcp` + * `service_account_for_atlas` - Email address for the Google Service Account created by Atlas. diff --git a/internal/service/cloudprovideraccess/resource_cloud_provider_access_authorization.go b/internal/service/cloudprovideraccess/resource_cloud_provider_access_authorization.go index 5b468c5531..baad3da9da 100644 --- a/internal/service/cloudprovideraccess/resource_cloud_provider_access_authorization.go +++ b/internal/service/cloudprovideraccess/resource_cloud_provider_access_authorization.go @@ -70,6 +70,18 @@ func ResourceAuthorization() *schema.Resource { }, }, }, + "gcp": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "service_account_for_atlas": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, "feature_usages": { Type: schema.TypeList, Elem: featureUsagesSchema(), @@ -168,6 +180,10 @@ func resourceCloudProviderAccessAuthorizationUpdate(ctx context.Context, d *sche } if d.HasChange("aws") || d.HasChange("azure") { + // Re-authorize the role with updated AWS or Azure configuration. + // GCP authorization only requires a role ID and has no additional configuration to update. + // Therefore, "updating" a GCP role would effectively be creating a new authorization, + // which should be handled by creating a new resource rather than updating an existing one. return authorizeRole(ctx, conn, d, projectID, targetRole) } @@ -186,6 +202,7 @@ func roleToSchemaAuthorization(role *admin.CloudProviderAccessRole) map[string]a "iam_assumed_role_arn": role.GetIamAssumedRoleArn(), }}, "authorized_date": conversion.TimeToString(role.GetAuthorizedDate()), + "gcp": []any{map[string]any{}}, } if role.ProviderName == "AZURE" { @@ -197,6 +214,15 @@ func roleToSchemaAuthorization(role *admin.CloudProviderAccessRole) map[string]a "tenant_id": role.GetTenantId(), }}, "authorized_date": conversion.TimeToString(role.GetAuthorizedDate()), + "gcp": []any{map[string]any{}}, + } + } + if role.ProviderName == "GCP" { + out = map[string]any{ + "role_id": role.GetRoleId(), + "gcp": []any{map[string]any{ + "service_account_for_atlas": role.GetGcpServiceAccountForAtlas(), + }}, } } @@ -281,6 +307,7 @@ func authorizeRole(ctx context.Context, client *admin.APIClient, d *schema.Resou req.SetServicePrincipalId(targetRole.GetServicePrincipalId()) roleID = targetRole.GetId() } + // No specific GCP config is needed, only providerName and roleID are needed var role *admin.CloudProviderAccessRole var err error From 3ec1bc6f4e05d3ca55760e165df73e3db7b91bb0 Mon Sep 17 00:00:00 2001 From: svc-apix-bot Date: Wed, 3 Sep 2025 08:10:31 +0000 Subject: [PATCH 05/14] chore: Updates CHANGELOG.md for #3639 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eda47a3f8e..da4aabf04a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ENHANCEMENTS: * data-source/mongodbatlas_encryption_at_rest: Supports role_id in google_cloud_kms_config ([#3636](https://github.com/mongodb/terraform-provider-mongodbatlas/pull/3636)) +* resource/mongodbatlas_cloud_provider_access_authorization: Supports GCP cloud provider ([#3639](https://github.com/mongodb/terraform-provider-mongodbatlas/pull/3639)) * resource/mongodbatlas_encryption_at_rest: Supports role_id in google_cloud_kms_config ([#3636](https://github.com/mongodb/terraform-provider-mongodbatlas/pull/3636)) ## 1.40.0 (August 21, 2025) From ed27cba0091b18a9f26db78d7e2e77d620ccb137 Mon Sep 17 00:00:00 2001 From: Mar Cabrera <102970376+marcabreracast@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:11:51 +0200 Subject: [PATCH 06/14] feat: Adds support for `service_account_for_atlas` and `status` fields on `mongodbatlas_cloud_provider_access_setup` resource and data source (#3637) * Add GCP cloud provider access to mongodbatlas_cloud_provider_access_setup * Fix typo for function name * Add GCP configuration for schema on data source * Change optional field to computed as required per provider * Refactor function to accomodate for Cloud Provider Access - GCP * Add documentation for resource * Add GCP Cloud provider documentation for data source * Add GCP provider for data source validation * Add resource and resouce acceptance test for GCP Cloud Provider Access setup * Fix format * Add changelog * Fix issue where role ID was missing for Azure config * Fix format * Fix issues for refactor on GCP * Address PR comments * Address further PR comments * Refactor setup function to handle different cloud providers, and return error if provider used is not correct Add error handling for cloud provider access * Refactor multiple if/else statements into switch in order to avoid lint errors * Address nit comments * Substitute cloud provider string names with constants --- .changelog/3637.txt | 7 ++ .../cloud_provider_access_setup.md | 28 ++++-- docs/resources/cloud_provider_access.md | 16 +++- ...data_source_cloud_provider_access_setup.go | 28 ++++-- .../resource_cloud_provider_access_setup.go | 88 +++++++++++++------ ...source_cloud_provider_access_setup_test.go | 42 +++++++++ .../resource_migration_test.go | 2 +- .../service/encryptionatrest/resource_test.go | 2 +- internal/testutil/acc/pre_check.go | 2 +- 9 files changed, 172 insertions(+), 43 deletions(-) create mode 100644 .changelog/3637.txt diff --git a/.changelog/3637.txt b/.changelog/3637.txt new file mode 100644 index 0000000000..4a64f5b992 --- /dev/null +++ b/.changelog/3637.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +resource/mongodbatlas_cloud_provider_access_setup: Adds support for GCP as a Cloud Provider. +``` + +```release-note:enhancement +data-source/mongodbatlas_cloud_provider_access_setup: Adds support for GCP as a Cloud Provider. +``` diff --git a/docs/data-sources/cloud_provider_access_setup.md b/docs/data-sources/cloud_provider_access_setup.md index 03f0cc315a..983fb177a2 100644 --- a/docs/data-sources/cloud_provider_access_setup.md +++ b/docs/data-sources/cloud_provider_access_setup.md @@ -1,6 +1,6 @@ # Data Source: mongodbatlas_cloud_provider_access_setup -`mongodbatlas_cloud_provider_access_setup` allows you to get a single role for a provider access role setup, currently only AWS and Azure are supported. +`mongodbatlas_cloud_provider_access_setup` allows you to get a single role for a provider access role setup. Supported providers: AWS, AZURE and GCP. -> **NOTE:** Groups and projects are synonymous terms. You may find `groupId` in the official documentation. @@ -36,20 +36,33 @@ data "mongodbatlas_cloud_provider_access_setup" "single_setup" { role_id = mongodbatlas_cloud_provider_access_setup.test_role.role_id } ``` + +## Example Usage with GCP + +```terraform +resource "mongodbatlas_cloud_provider_access_setup" "test_role" { + project_id = "64259ee860c43338194b0f8e" + provider_name = "GCP" +} + +data "mongodbatlas_cloud_provider_access_setup" "single_setup" { + project_id = mongodbatlas_cloud_provider_access_setup.test_role.project_id + provider_name = mongodbatlas_cloud_provider_access_setup.test_role.provider_name + role_id = mongodbatlas_cloud_provider_access_setup.test_role.role_id +} +``` + ## Argument Reference * `project_id` - (Required) The unique ID for the project to get all Cloud Provider Access -* `provider_name` - (Required) cloud provider name, currently only AWS is supported -* `role_id` - (Required) unique role id among all the aws roles provided by mongodb atlas +* `provider_name` - (Required) cloud provider name. Supported values: `AWS`, `AZURE`, and `GCP`. +* `role_id` - (Required) unique role id among all the roles provided by MongoDB Atlas. ## Attributes Reference In addition to all arguments above, the following attributes are exported: * `id` - Autogenerated Unique ID for this data source. -* `aws` - aws related role information - * `atlas_assumed_role_external_id` - Unique external ID Atlas uses when assuming the IAM role in your AWS account. - * `atlas_aws_account_arn` - ARN associated with the Atlas AWS account used to assume IAM roles in your AWS account. * `aws_config` - aws related role information * `atlas_assumed_role_external_id` - Unique external ID Atlas uses when assuming the IAM role in your AWS account. * `atlas_aws_account_arn` - ARN associated with the Atlas AWS account used to assume IAM roles in your AWS account. @@ -57,6 +70,9 @@ In addition to all arguments above, the following attributes are exported: * `atlas_azure_app_id` - Azure Active Directory Application ID of Atlas. * `service_principal_id`- UUID string that identifies the Azure Service Principal. * `tenant_id` - UUID String that identifies the Azure Active Directory Tenant ID. + * `gcp_config` - gcp related configurations + * `status` - The status of the GCP cloud provider access setup. See [MongoDB Atlas API](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-getgroupcloudprovideraccess#operation-getgroupcloudprovideraccess-200-body-application-vnd-atlas-2023-01-01-json-gcp-object-status). + * `service_account_for_atlas` - The GCP service account email that Atlas uses. * `created_date` - Date on which this role was created. * `last_updated_date` - Date and time when this Azure Service Principal was last updated. This parameter expresses its value in the ISO 8601 timestamp format in UTC. diff --git a/docs/resources/cloud_provider_access.md b/docs/resources/cloud_provider_access.md index 0027432d60..2baadff4bf 100644 --- a/docs/resources/cloud_provider_access.md +++ b/docs/resources/cloud_provider_access.md @@ -44,10 +44,21 @@ resource "mongodbatlas_cloud_provider_access_setup" "test_role" { ``` +## Example Usage with GCP + +```terraform + +resource "mongodbatlas_cloud_provider_access_setup" "test_role" { + project_id = "64259ee860c43338194b0f8e" + provider_name = "GCP" +} + +``` + ## Argument Reference * `project_id` - (Required) The unique ID for the project -* `provider_name` - (Required) The cloud provider for which to create a new role. Currently only AWS and AZURE are supported. **WARNING** Changing the `provider_name` will result in destruction of the existing resource and the creation of a new resource. +* `provider_name` - (Required) The cloud provider for which to create a new role. Currently, AWS, AZURE and GCP are supported. **WARNING** Changing the `provider_name` will result in destruction of the existing resource and the creation of a new resource. * `azure_config` - azure related configurations * `atlas_azure_app_id` - Azure Active Directory Application ID of Atlas. This property is required when `provider_name = "AZURE".` * `service_principal_id`- UUID string that identifies the Azure Service Principal. This property is required when `provider_name = "AZURE".` @@ -59,6 +70,9 @@ resource "mongodbatlas_cloud_provider_access_setup" "test_role" { * `aws_config` - aws related arn roles * `atlas_assumed_role_external_id` - Unique external ID Atlas uses when assuming the IAM role in your AWS account. * `atlas_aws_account_arn` - ARN associated with the Atlas AWS account used to assume IAM roles in your AWS account. +* `gcp_config` - gcp related configuration + * `status` - The status of the GCP cloud provider access setup. See [MongoDB Atlas API](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-getgroupcloudprovideraccess#operation-getgroupcloudprovideraccess-200-body-application-vnd-atlas-2023-01-01-json-gcp-object-status). + * `service_account_for_atlas` - The GCP service account email that Atlas uses. * `created_date` - Date on which this role was created. * `last_updated_date` - Date and time when this Azure Service Principal was last updated. This parameter expresses its value in the ISO 8601 timestamp format in UTC. * `role_id` - Unique ID of this role. diff --git a/internal/service/cloudprovideraccess/data_source_cloud_provider_access_setup.go b/internal/service/cloudprovideraccess/data_source_cloud_provider_access_setup.go index 33f7892fe3..61e3418688 100644 --- a/internal/service/cloudprovideraccess/data_source_cloud_provider_access_setup.go +++ b/internal/service/cloudprovideraccess/data_source_cloud_provider_access_setup.go @@ -7,7 +7,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/id" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/mongodb/terraform-provider-mongodbatlas/internal/config" ) @@ -20,9 +19,8 @@ func DataSourceSetup() *schema.Resource { Required: true, }, "provider_name": { - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringInSlice([]string{"AWS", "AZURE"}, false), + Type: schema.TypeString, + Required: true, }, "role_id": { Type: schema.TypeString, @@ -71,6 +69,22 @@ func DataSourceSetup() *schema.Resource { }, }, }, + "gcp_config": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "status": { + Type: schema.TypeString, + Computed: true, + }, + "service_account_for_atlas": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, "created_date": { Type: schema.TypeString, Computed: true, @@ -93,7 +107,11 @@ func dataSourceMongoDBAtlasCloudProviderAccessSetupRead(ctx context.Context, d * return diag.FromErr(fmt.Errorf(ErrorCloudProviderGetRead, err)) } - roleSchema := roleToSchemaSetup(role) + roleSchema, err := roleToSchemaSetup(role) + if err != nil { + return diag.FromErr(fmt.Errorf(ErrorCloudProviderGetRead, err)) + } + for key, val := range roleSchema { if err := d.Set(key, val); err != nil { return diag.FromErr(fmt.Errorf(ErrorCloudProviderGetRead, err)) diff --git a/internal/service/cloudprovideraccess/resource_cloud_provider_access_setup.go b/internal/service/cloudprovideraccess/resource_cloud_provider_access_setup.go index 29897845a5..4640118e24 100644 --- a/internal/service/cloudprovideraccess/resource_cloud_provider_access_setup.go +++ b/internal/service/cloudprovideraccess/resource_cloud_provider_access_setup.go @@ -9,7 +9,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/constant" "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/validate" @@ -46,10 +45,9 @@ func ResourceSetup() *schema.Resource { Required: true, }, "provider_name": { - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringInSlice([]string{constant.AWS, constant.AZURE}, false), - ForceNew: true, + Type: schema.TypeString, + Required: true, + ForceNew: true, }, "aws_config": { Type: schema.TypeList, @@ -87,6 +85,22 @@ func ResourceSetup() *schema.Resource { }, }, }, + "gcp_config": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "status": { + Type: schema.TypeString, + Computed: true, + }, + "service_account_for_atlas": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, "created_date": { Type: schema.TypeString, Computed: true, @@ -119,7 +133,10 @@ func resourceCloudProviderAccessSetupRead(ctx context.Context, d *schema.Resourc return diag.FromErr(fmt.Errorf(ErrorCloudProviderGetRead, err)) } - roleSchema := roleToSchemaSetup(role) + roleSchema, err := roleToSchemaSetup(role) + if err != nil { + return diag.Errorf(errorCloudProviderAccessCreate, err) + } for key, val := range roleSchema { if err := d.Set(key, val); err != nil { return diag.FromErr(fmt.Errorf(ErrorCloudProviderGetRead, err)) @@ -156,7 +173,10 @@ func resourceCloudProviderAccessSetupCreate(ctx context.Context, d *schema.Resou } // once multiple providers enable here do a switch, select for provider type - roleSchema := roleToSchemaSetup(role) + roleSchema, err := roleToSchemaSetup(role) + if err != nil { + return diag.Errorf(errorCloudProviderAccessCreate, err) + } resourceID := role.GetRoleId() if role.ProviderName == constant.AZURE { @@ -197,39 +217,51 @@ func resourceCloudProviderAccessSetupDelete(ctx context.Context, d *schema.Resou return diag.FromErr(fmt.Errorf(errorCloudProviderAccessDelete, err)) } - d.SetId("") d.SetId("") return nil } -func roleToSchemaSetup(role *admin.CloudProviderAccessRole) map[string]any { - if role.ProviderName == "AWS" { - out := map[string]any{ +func roleToSchemaSetup(role *admin.CloudProviderAccessRole) (map[string]any, error) { + switch role.ProviderName { + case constant.AWS: + return map[string]any{ "provider_name": role.GetProviderName(), "aws_config": []any{map[string]any{ "atlas_aws_account_arn": role.GetAtlasAWSAccountArn(), "atlas_assumed_role_external_id": role.GetAtlasAssumedRoleExternalId(), }}, + "gcp_config": []any{map[string]any{}}, "created_date": conversion.TimeToString(role.GetCreatedDate()), "role_id": role.GetRoleId(), - } - return out - } - - out := map[string]any{ - "provider_name": role.ProviderName, - "azure_config": []any{map[string]any{ - "atlas_azure_app_id": role.GetAtlasAzureAppId(), - "service_principal_id": role.GetServicePrincipalId(), - "tenant_id": role.GetTenantId(), - }}, - "aws_config": []any{map[string]any{}}, - "created_date": conversion.TimeToString(role.GetCreatedDate()), - "last_updated_date": conversion.TimeToString(role.GetLastUpdatedDate()), - "role_id": role.GetId(), + }, nil + case constant.AZURE: + return map[string]any{ + "provider_name": role.ProviderName, + "azure_config": []any{map[string]any{ + "atlas_azure_app_id": role.GetAtlasAzureAppId(), + "service_principal_id": role.GetServicePrincipalId(), + "tenant_id": role.GetTenantId(), + }}, + "aws_config": []any{map[string]any{}}, + "gcp_config": []any{map[string]any{}}, + "created_date": conversion.TimeToString(role.GetCreatedDate()), + "last_updated_date": conversion.TimeToString(role.GetLastUpdatedDate()), + "role_id": role.GetId(), + }, nil + case constant.GCP: + return map[string]any{ + "provider_name": role.GetProviderName(), + "gcp_config": []any{map[string]any{ + "status": role.GetStatus(), + "service_account_for_atlas": role.GetGcpServiceAccountForAtlas(), + }}, + "aws_config": []any{map[string]any{}}, + "role_id": role.GetId(), + "created_date": conversion.TimeToString(role.GetCreatedDate()), + }, nil + default: + return nil, fmt.Errorf("unsupported provider: %s", role.GetProviderName()) } - - return out } func resourceCloudProviderAccessSetupImportState(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { diff --git a/internal/service/cloudprovideraccess/resource_cloud_provider_access_setup_test.go b/internal/service/cloudprovideraccess/resource_cloud_provider_access_setup_test.go index 824cdcad55..8c34f39dfd 100644 --- a/internal/service/cloudprovideraccess/resource_cloud_provider_access_setup_test.go +++ b/internal/service/cloudprovideraccess/resource_cloud_provider_access_setup_test.go @@ -62,6 +62,33 @@ func TestAccCloudProviderAccessSetupAzure_basic(t *testing.T) { }, ) } +func TestAccCloudProviderAccessSetupGCP_basic(t *testing.T) { + acc.SkipTestForCI(t) // Code needs to support long running operations for successful test: CLOUDP-341440 + var ( + resourceName = "mongodbatlas_cloud_provider_access_setup.test" + dataSourceName = "data.mongodbatlas_cloud_provider_access_setup.test" + projectID = acc.ProjectIDExecution(t) + ) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acc.PreCheckGCPEnv(t) }, + ProtoV6ProviderFactories: acc.TestAccProviderV6Factories, + Steps: []resource.TestStep{ + { + Config: configSetupGCP(projectID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "role_id"), + resource.TestCheckResourceAttrSet(resourceName, "gcp_config.0.service_account_for_atlas"), + resource.TestCheckResourceAttr(resourceName, "gcp_config.0.status", "COMPLETE"), + + resource.TestCheckResourceAttrSet(dataSourceName, "role_id"), + resource.TestCheckResourceAttrSet(dataSourceName, "gcp_config.0.service_account_for_atlas"), + resource.TestCheckResourceAttr(dataSourceName, "gcp_config.0.status", "COMPLETE"), + ), + }, + }, + }) +} func basicSetupTestCase(tb testing.TB) *resource.TestCase { tb.Helper() @@ -113,6 +140,21 @@ func configSetupAWS(projectID string) string { `, projectID) } +func configSetupGCP(projectID string) string { + return fmt.Sprintf(` + resource "mongodbatlas_cloud_provider_access_setup" "test" { + project_id = %[1]q + provider_name = "GCP" + } + + data "mongodbatlas_cloud_provider_access_setup" "test" { + project_id = mongodbatlas_cloud_provider_access_setup.test.project_id + provider_name = mongodbatlas_cloud_provider_access_setup.test.provider_name + role_id = mongodbatlas_cloud_provider_access_setup.test.role_id + } + `, projectID) +} + func checkExists(resourceName string) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[resourceName] diff --git a/internal/service/encryptionatrest/resource_migration_test.go b/internal/service/encryptionatrest/resource_migration_test.go index bc69b5fdcd..9b7ffabaac 100644 --- a/internal/service/encryptionatrest/resource_migration_test.go +++ b/internal/service/encryptionatrest/resource_migration_test.go @@ -119,7 +119,7 @@ func TestMigEncryptionAtRest_basicGCP(t *testing.T) { ) resource.Test(t, resource.TestCase{ - PreCheck: func() { mig.PreCheck(t); acc.PreCheckGPCEnv(t) }, + PreCheck: func() { mig.PreCheck(t); acc.PreCheckGCPEnv(t) }, CheckDestroy: acc.EARDestroy, Steps: []resource.TestStep{ { diff --git a/internal/service/encryptionatrest/resource_test.go b/internal/service/encryptionatrest/resource_test.go index 5889b2abda..ddbe083457 100644 --- a/internal/service/encryptionatrest/resource_test.go +++ b/internal/service/encryptionatrest/resource_test.go @@ -169,7 +169,7 @@ func TestAccEncryptionAtRest_basicGCP(t *testing.T) { ) resource.Test(t, resource.TestCase{ - PreCheck: func() { acc.PreCheck(t); acc.PreCheckGPCEnv(t) }, + PreCheck: func() { acc.PreCheck(t); acc.PreCheckGCPEnv(t) }, ProtoV6ProviderFactories: acc.TestAccProviderV6Factories, CheckDestroy: acc.EARDestroy, Steps: []resource.TestStep{ diff --git a/internal/testutil/acc/pre_check.go b/internal/testutil/acc/pre_check.go index dea0d0fdf5..98402972fa 100644 --- a/internal/testutil/acc/pre_check.go +++ b/internal/testutil/acc/pre_check.go @@ -148,7 +148,7 @@ func PreCheckPublicKey2(tb testing.TB) { } } -func PreCheckGPCEnv(tb testing.TB) { +func PreCheckGCPEnv(tb testing.TB) { tb.Helper() if os.Getenv("GCP_SERVICE_ACCOUNT_KEY") == "" || os.Getenv("GCP_KEY_VERSION_RESOURCE_ID") == "" { tb.Fatal("`GCP_SERVICE_ACCOUNT_KEY` and `GCP_KEY_VERSION_RESOURCE_ID` must be set for acceptance testing") From eeee5db55b8cccc1c360f18facb3cd4fbc17a334 Mon Sep 17 00:00:00 2001 From: svc-apix-bot Date: Wed, 3 Sep 2025 08:14:22 +0000 Subject: [PATCH 07/14] chore: Updates CHANGELOG.md for #3637 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da4aabf04a..7d0e8f634f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,10 @@ ENHANCEMENTS: +* data-source/mongodbatlas_cloud_provider_access_setup: Adds support for GCP as a Cloud Provider. ([#3637](https://github.com/mongodb/terraform-provider-mongodbatlas/pull/3637)) * data-source/mongodbatlas_encryption_at_rest: Supports role_id in google_cloud_kms_config ([#3636](https://github.com/mongodb/terraform-provider-mongodbatlas/pull/3636)) * resource/mongodbatlas_cloud_provider_access_authorization: Supports GCP cloud provider ([#3639](https://github.com/mongodb/terraform-provider-mongodbatlas/pull/3639)) +* resource/mongodbatlas_cloud_provider_access_setup: Adds support for GCP as a Cloud Provider. ([#3637](https://github.com/mongodb/terraform-provider-mongodbatlas/pull/3637)) * resource/mongodbatlas_encryption_at_rest: Supports role_id in google_cloud_kms_config ([#3636](https://github.com/mongodb/terraform-provider-mongodbatlas/pull/3636)) ## 1.40.0 (August 21, 2025) From b56c808f5b6120fb72b1b17b12aa65ed4cbeea85 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:42:57 +0200 Subject: [PATCH 08/14] chore: Bump amannn/action-semantic-pull-request from 6.1.0 to 6.1.1 (#3620) Bumps [amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request) from 6.1.0 to 6.1.1. - [Release notes](https://github.com/amannn/action-semantic-pull-request/releases) - [Changelog](https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md) - [Commits](https://github.com/amannn/action-semantic-pull-request/compare/7f33ba792281b034f64e96f4c0b5496782dd3b37...48f256284bd46cdaab1048c3721360e808335d50) --- updated-dependencies: - dependency-name: amannn/action-semantic-pull-request dependency-version: 6.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pull-request-lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-request-lint.yml b/.github/workflows/pull-request-lint.yml index cbafa4efe2..57b9e8cc23 100644 --- a/.github/workflows/pull-request-lint.yml +++ b/.github/workflows/pull-request-lint.yml @@ -17,7 +17,7 @@ jobs: permissions: pull-requests: write # Needed by sticky-pull-request-comment steps: - - uses: amannn/action-semantic-pull-request@7f33ba792281b034f64e96f4c0b5496782dd3b37 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 id: lint_pr_title env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 394cf897a3a7293a304c53ce89cb39f6abadec2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:44:43 +0200 Subject: [PATCH 09/14] chore: Bump github.com/pb33f/libopenapi from 0.25.3 to 0.25.6 (#3621) Bumps [github.com/pb33f/libopenapi](https://github.com/pb33f/libopenapi) from 0.25.3 to 0.25.6. - [Release notes](https://github.com/pb33f/libopenapi/releases) - [Commits](https://github.com/pb33f/libopenapi/compare/v0.25.3...v0.25.6) --- updated-dependencies: - dependency-name: github.com/pb33f/libopenapi dependency-version: 0.25.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 52dad8c189..d7a8c65a21 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/jarcoal/httpmock v1.4.1 github.com/mongodb-forks/digest v1.1.0 github.com/mongodb/atlas-sdk-go v1.0.1-0.20250825084037-c95a65f18752 - github.com/pb33f/libopenapi v0.25.3 + github.com/pb33f/libopenapi v0.25.8 github.com/sebdah/goldie/v2 v2.7.1 github.com/spf13/cast v1.9.2 github.com/stretchr/testify v1.11.0 diff --git a/go.sum b/go.sum index add92b6f40..49f76ee621 100644 --- a/go.sum +++ b/go.sum @@ -1215,8 +1215,8 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/openlyinc/pointy v1.1.2 h1:LywVV2BWC5Sp5v7FoP4bUD+2Yn5k0VNeRbU5vq9jUMY= github.com/openlyinc/pointy v1.1.2/go.mod h1:w2Sytx+0FVuMKn37xpXIAyBNhFNBIJGR/v2m7ik1WtM= -github.com/pb33f/libopenapi v0.25.3 h1:B0rf9Reo63tAx54gpoP9778Y84gk3JQoFtj7yg8vKfo= -github.com/pb33f/libopenapi v0.25.3/go.mod h1:IefJDi7uJpflLs2wEnkiier/Y21w+dEbOrMCP5LB2aw= +github.com/pb33f/libopenapi v0.25.8 h1:vA6NZAu6YClmpf4oceqdHowUkeOp79CODXGZAukhxQQ= +github.com/pb33f/libopenapi v0.25.8/go.mod h1:3MKMFLcYAnTgOuueDd2HIidMphtHHAhPdspgjKVVFq8= github.com/pb33f/ordered-map/v2 v2.2.0 h1:+6D6e0nkcEjVPh6kF48ynz2Cb+D/ECH/Q3AOunHtj7E= github.com/pb33f/ordered-map/v2 v2.2.0/go.mod h1:rAwLzJPAha8J3pY5otLGRbGH2L077wij3W/ftbgPwNs= github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= From a16827e93aac78342ba5cbc80288b1d5f8c9b6fe Mon Sep 17 00:00:00 2001 From: Leo Antoli <430982+lantoli@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:21:22 +0200 Subject: [PATCH 10/14] chore: Update Go to 1.25.0 (#3641) --- .github/workflows/code-health.yml | 2 +- Makefile | 2 +- go.mod | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/code-health.yml b/.github/workflows/code-health.yml index b052ae64eb..613817b463 100644 --- a/.github/workflows/code-health.yml +++ b/.github/workflows/code-health.yml @@ -57,7 +57,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 with: - version: v2.3.1 # Also update GOLANGCI_VERSION variable in GNUmakefile when updating this version + version: v2.4.0 # Also update GOLANGCI_VERSION variable in GNUmakefile when updating this version - name: actionlint run: | make tools diff --git a/Makefile b/Makefile index 2b5fc45883..60a609023b 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ GITTAG=$(shell git describe --always --tags) VERSION=$(GITTAG:v%=%) LINKER_FLAGS=-s -w -X 'github.com/mongodb/terraform-provider-mongodbatlas/version.ProviderVersion=${VERSION}' -GOLANGCI_VERSION=v2.3.1 # Also update golangci-lint GH action in code-health.yml when updating this version +GOLANGCI_VERSION=v2.4.0 # Also update golangci-lint GH action in code-health.yml when updating this version export PATH := $(shell go env GOPATH)/bin:$(PATH) export SHELL := env PATH=$(PATH) /bin/bash diff --git a/go.mod b/go.mod index d7a8c65a21..c95deefb37 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/mongodb/terraform-provider-mongodbatlas -go 1.24.6 +go 1.25.0 require ( github.com/andygrunwald/go-jira/v2 v2.0.0-20240116150243-50d59fe116d6 From 308f6820f5e9405e0341069066b78cb19f60c5e8 Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 3 Sep 2025 14:47:26 +0200 Subject: [PATCH 11/14] test: Ensures project `withDefaultAlertsSettings` works with import and introduce create_only plan modifier (#3105) * fix: Sets default value for WithDefaultAlertsSettings during state import * chore: Adds release note * fix: Removes 'with_default_alerts_settings' from ImportStateVerifyIgnore in project tests when it is not set to `false` in earlier steps * doc: update changelog message * doc: Add back reference based on comments * revert changes of old implementation * refactor: Introduce Modifier interface and enhance non-updatable attribute handling to support multiple planmodifiers * refactor: Mark with_default_alerts_settings as NonUpdateable * refactor:Rename NonUpdatableAttributePlanModifier with CreateOnlyAttributePlanModifier in resource schemas * feat: Implement CreateOnlyAttributePlanModifier with default boolean support and refactor IsKnown utility function * chore: small fix to planModifier using state value when Unknown in the plan * test: Support testing plan error after importing * doc: Update error message in addDiags to clarify import restrictions * chore: revert old changes for advancedclustertpf * test: remove migration test util in favor of acc test step for empty plan check * revert old changes * doc: Updates docs with latest API docs * refactor: remove the withDefaultAlertSettings override for plan * refactor: Add create only attribute plan modifier for project_owner_id * chore: update related docs * refactor: Update error message to match original message * docs: Update description for 'with_default_alerts_settings' to clarify default behavior * refactor: Improve test function names and enhance default alert settings migration test cases * test: Update alert settings handling in TestAccProject_withFalseDefaultSettings * refactor: Rename Modifier interface to CreateOnlyModifier for clarity * docs: Add comments to clarify CreateOnlyAttributePlanModifierWithBoolDefault behavior --- docs/resources/project.md | 2 +- .../common/customplanmodifier/create_only.go | 95 +++++++++++++++++++ .../common/customplanmodifier/is_known.go | 8 ++ .../customplanmodifier/non_updatable.go | 36 ------- .../service/flexcluster/resource_schema.go | 8 +- internal/service/project/resource_project.go | 1 - .../resource_project_migration_test.go | 19 +++- .../project/resource_project_schema.go | 13 ++- .../service/project/resource_project_test.go | 44 +++++++-- internal/testutil/acc/project.go | 1 - 10 files changed, 164 insertions(+), 63 deletions(-) create mode 100644 internal/common/customplanmodifier/create_only.go create mode 100644 internal/common/customplanmodifier/is_known.go delete mode 100644 internal/common/customplanmodifier/non_updatable.go diff --git a/docs/resources/project.md b/docs/resources/project.md index fcec8a465c..542d65d639 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -51,7 +51,7 @@ resource "mongodbatlas_project" "test" { * `org_id` - (Required) The ID of the organization you want to create the project within. * `project_owner_id` - (Optional) Unique 24-hexadecimal digit string that identifies the Atlas user account to be granted the [Project Owner](https://docs.atlas.mongodb.com/reference/user-roles/#mongodb-authrole-Project-Owner) role on the specified project. If you set this parameter, it overrides the default value of the oldest [Organization Owner](https://docs.atlas.mongodb.com/reference/user-roles/#mongodb-authrole-Organization-Owner). * `tags` - (Optional) Map that contains key-value pairs between 1 to 255 characters in length for tagging and categorizing the project. See [below](#tags). -* `with_default_alerts_settings` - (Optional) It allows users to disable the creation of the default alert settings. By default, this flag is set to true. +* `with_default_alerts_settings` - (Optional) Flag that indicates whether to create the project with default alert settings. This setting cannot be updated after project creation. By default, this flag is set to true. * `is_collect_database_specifics_statistics_enabled` - (Optional) Flag that indicates whether to enable statistics in [cluster metrics](https://www.mongodb.com/docs/atlas/monitor-cluster-metrics/) collection for the project. By default, this flag is set to true. * `is_data_explorer_enabled` - (Optional) Flag that indicates whether to enable Data Explorer for the project. If enabled, you can query your database with an easy to use interface. When Data Explorer is disabled, you cannot terminate slow operations from the [Real-Time Performance Panel](https://www.mongodb.com/docs/atlas/real-time-performance-panel/#std-label-real-time-metrics-status-tab) or create indexes from the [Performance Advisor](https://www.mongodb.com/docs/atlas/performance-advisor/#std-label-performance-advisor). You can still view Performance Advisor recommendations, but you must create those indexes from [mongosh](https://www.mongodb.com/docs/mongodb-shell/#mongodb-binary-bin.mongosh). By default, this flag is set to true. * `is_extended_storage_sizes_enabled` - (Optional) Flag that indicates whether to enable extended storage sizes for the specified project. Clusters with extended storage sizes must be on AWS or GCP, and cannot span multiple regions. When extending storage size, initial syncs and cross-project snapshot restores will be slow. This setting should only be used as a measure of temporary relief; consider sharding if more storage is required. diff --git a/internal/common/customplanmodifier/create_only.go b/internal/common/customplanmodifier/create_only.go new file mode 100644 index 0000000000..5754a6cbc7 --- /dev/null +++ b/internal/common/customplanmodifier/create_only.go @@ -0,0 +1,95 @@ +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" +) + +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 &createOnlyAttributePlanModifier{defaultBool: &b} +} + +type createOnlyAttributePlanModifier struct { + defaultBool *bool +} + +func (d *createOnlyAttributePlanModifier) Description(ctx context.Context) string { + return d.MarkdownDescription(ctx) +} + +func (d *createOnlyAttributePlanModifier) 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 *createOnlyAttributePlanModifier) UseDefault() bool { + return d.defaultBool != nil +} + +func (d *createOnlyAttributePlanModifier) 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 *createOnlyAttributePlanModifier) 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 *createOnlyAttributePlanModifier) 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/is_known.go b/internal/common/customplanmodifier/is_known.go new file mode 100644 index 0000000000..8eedd62889 --- /dev/null +++ b/internal/common/customplanmodifier/is_known.go @@ -0,0 +1,8 @@ +package customplanmodifier + +import "github.com/hashicorp/terraform-plugin-framework/attr" + +// IsKnown returns true if the attribute is known (not null or unknown). Note that !IsKnown is not the same as IsUnknown because null is !IsKnown but not IsUnknown. +func IsKnown(attribute attr.Value) bool { + return !attribute.IsNull() && !attribute.IsUnknown() +} diff --git a/internal/common/customplanmodifier/non_updatable.go b/internal/common/customplanmodifier/non_updatable.go deleted file mode 100644 index 7f47282bb1..0000000000 --- a/internal/common/customplanmodifier/non_updatable.go +++ /dev/null @@ -1,36 +0,0 @@ -package customplanmodifier - -import ( - "context" - "fmt" - - planmodifier "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" -) - -func NonUpdatableStringAttributePlanModifier() planmodifier.String { - return &nonUpdatableStringAttributePlanModifier{} -} - -type nonUpdatableStringAttributePlanModifier struct { -} - -func (d *nonUpdatableStringAttributePlanModifier) Description(ctx context.Context) string { - return d.MarkdownDescription(ctx) -} - -func (d *nonUpdatableStringAttributePlanModifier) MarkdownDescription(ctx context.Context) string { - return "Ensures that update operations fails when updating an attribute." -} - -func (d *nonUpdatableStringAttributePlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { - planAttributeValue := req.PlanValue - stateAttributeValue := req.StateValue - - if !stateAttributeValue.IsNull() && stateAttributeValue.ValueString() != planAttributeValue.ValueString() { - resp.Diagnostics.AddError( - fmt.Sprintf("%s cannot be updated", req.Path), - fmt.Sprintf("%s cannot be updated", req.Path), - ) - return - } -} diff --git a/internal/service/flexcluster/resource_schema.go b/internal/service/flexcluster/resource_schema.go index 08f5a9f471..fc3afe6430 100644 --- a/internal/service/flexcluster/resource_schema.go +++ b/internal/service/flexcluster/resource_schema.go @@ -21,14 +21,14 @@ func ResourceSchema(ctx context.Context) schema.Schema { "project_id": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ - customplanmodifier.NonUpdatableStringAttributePlanModifier(), + customplanmodifier.CreateOnlyAttributePlanModifier(), }, MarkdownDescription: "Unique 24-hexadecimal character string that identifies the project.", }, "name": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ - customplanmodifier.NonUpdatableStringAttributePlanModifier(), + customplanmodifier.CreateOnlyAttributePlanModifier(), }, MarkdownDescription: "Human-readable label that identifies the instance.", }, @@ -37,7 +37,7 @@ func ResourceSchema(ctx context.Context) schema.Schema { "backing_provider_name": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ - customplanmodifier.NonUpdatableStringAttributePlanModifier(), + customplanmodifier.CreateOnlyAttributePlanModifier(), }, MarkdownDescription: "Cloud service provider on which MongoDB Cloud provisioned the flex cluster.", }, @@ -58,7 +58,7 @@ func ResourceSchema(ctx context.Context) schema.Schema { "region_name": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ - customplanmodifier.NonUpdatableStringAttributePlanModifier(), + customplanmodifier.CreateOnlyAttributePlanModifier(), }, 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/).", }, diff --git a/internal/service/project/resource_project.go b/internal/service/project/resource_project.go index e7492f4884..f9b937eec5 100644 --- a/internal/service/project/resource_project.go +++ b/internal/service/project/resource_project.go @@ -335,7 +335,6 @@ func (r *projectRS) ImportState(ctx context.Context, req resource.ImportStateReq func updatePlanFromConfig(projectPlanNewPtr, projectPlan *TFProjectRSModel) { // we need to reset defaults from what was previously in the state: // https://discuss.hashicorp.com/t/boolean-optional-default-value-migration-to-framework/55932 - projectPlanNewPtr.WithDefaultAlertsSettings = projectPlan.WithDefaultAlertsSettings projectPlanNewPtr.ProjectOwnerID = projectPlan.ProjectOwnerID if projectPlan.Tags.IsNull() && len(projectPlanNewPtr.Tags.Elements()) == 0 { projectPlanNewPtr.Tags = types.MapNull(types.StringType) diff --git a/internal/service/project/resource_project_migration_test.go b/internal/service/project/resource_project_migration_test.go index d3e3f765e9..cec2d453bf 100644 --- a/internal/service/project/resource_project_migration_test.go +++ b/internal/service/project/resource_project_migration_test.go @@ -79,15 +79,24 @@ func TestMigProject_withTeams(t *testing.T) { }) } -func TestMigProject_withFalseDefaultSettings(t *testing.T) { +// empty is tested by the TestMigProject_basic +func TestMigProject_withFalseDefaultAlertSettings(t *testing.T) { + resource.ParallelTest(t, *defaultAlertSettingsTestCase(t, false)) +} + +func TestMigProject_withTrueDefaultAlertSettings(t *testing.T) { + resource.ParallelTest(t, *defaultAlertSettingsTestCase(t, true)) +} + +func defaultAlertSettingsTestCase(t *testing.T, withDefaultAlertSettings bool) *resource.TestCase { + t.Helper() var ( orgID = os.Getenv("MONGODB_ATLAS_ORG_ID") projectOwnerID = os.Getenv("MONGODB_ATLAS_PROJECT_OWNER_ID") projectName = acc.RandomProjectName() - config = configWithFalseDefaultSettings(orgID, projectName, projectOwnerID) + config = configWithDefaultAlertSettings(orgID, projectName, projectOwnerID, withDefaultAlertSettings) ) - - resource.Test(t, resource.TestCase{ + return &resource.TestCase{ PreCheck: func() { mig.PreCheckBasicOwnerID(t) }, CheckDestroy: acc.CheckDestroyProject, Steps: []resource.TestStep{ @@ -102,7 +111,7 @@ func TestMigProject_withFalseDefaultSettings(t *testing.T) { }, mig.TestStepCheckEmptyPlan(config), }, - }) + } } func TestMigProject_withLimits(t *testing.T) { diff --git a/internal/service/project/resource_project_schema.go b/internal/service/project/resource_project_schema.go index d51822045e..670dc2e3c1 100644 --- a/internal/service/project/resource_project_schema.go +++ b/internal/service/project/resource_project_schema.go @@ -6,7 +6,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" @@ -15,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/constant" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/customplanmodifier" "go.mongodb.org/atlas-sdk/v20250312006/admin" ) @@ -50,13 +50,16 @@ func ResourceSchema(ctx context.Context) schema.Schema { }, "project_owner_id": schema.StringAttribute{ Optional: true, + PlanModifiers: []planmodifier.String{ + customplanmodifier.CreateOnlyAttributePlanModifier(), + }, }, "with_default_alerts_settings": schema.BoolAttribute{ // Default values also must be Computed otherwise Terraform throws error: - // Schema Using Attribute Default For Non-Computed Attribute - Optional: true, - Computed: true, - Default: booldefault.StaticBool(true), + // Provider produced invalid plan: planned an invalid value for a non-computed attribute. + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{customplanmodifier.CreateOnlyAttributePlanModifierWithBoolDefault(true)}, }, "is_collect_database_specifics_statistics_enabled": schema.BoolAttribute{ Computed: true, diff --git a/internal/service/project/resource_project_test.go b/internal/service/project/resource_project_test.go index 08b4e146de..f1ba24a359 100644 --- a/internal/service/project/resource_project_test.go +++ b/internal/service/project/resource_project_test.go @@ -647,9 +647,21 @@ func TestAccGovProject_withProjectOwner(t *testing.T) { func TestAccProject_withFalseDefaultSettings(t *testing.T) { var ( - orgID = os.Getenv("MONGODB_ATLAS_ORG_ID") - projectOwnerID = os.Getenv("MONGODB_ATLAS_PROJECT_OWNER_ID") - projectName = acc.RandomProjectName() + orgID = os.Getenv("MONGODB_ATLAS_ORG_ID") + projectOwnerID = os.Getenv("MONGODB_ATLAS_PROJECT_OWNER_ID") + projectName = acc.RandomProjectName() + importResourceName = resourceName + "2" + alertSettingsFalse = configWithDefaultAlertSettings(orgID, projectName, projectOwnerID, false) + alertSettingsTrue = configWithDefaultAlertSettings(orgID, projectName, projectOwnerID, true) + alertSettingsAbsent = configBasic(orgID, projectName, "", false, nil, nil) + // To test plan behavior after import it is necessary to use a different resource name, otherwise we get: + // Terraform is already managing a remote object for mongodbatlas_project.test. To import to this address you must first remove the existing object from the state. + // This happens because `ImportStatePersist` uses the previous WorkingDirectory where the state from previous steps are saved + // resource "mongodbatlas_project" "test" --> resource "mongodbatlas_project" "test2" + alertSettingsFalseImport = strings.Replace(alertSettingsFalse, "test", "test2", 1) + // Need BOTH mongodbatlas_project.test and mongodbatlas_project.test2, otherwise we get: + // expected empty plan, but mongodbatlas_project.test has planned action(s): [delete] + alertSettingsAbsentImport = alertSettingsFalse + strings.Replace(alertSettingsAbsent, "test", "test2", 1) ) resource.ParallelTest(t, resource.TestCase{ @@ -658,13 +670,25 @@ func TestAccProject_withFalseDefaultSettings(t *testing.T) { CheckDestroy: acc.CheckDestroyProject, Steps: []resource.TestStep{ { - Config: configWithFalseDefaultSettings(orgID, projectName, projectOwnerID), + Config: alertSettingsFalse, Check: resource.ComposeAggregateTestCheckFunc( checkExists(resourceName), resource.TestCheckResourceAttr(resourceName, "name", projectName), resource.TestCheckResourceAttr(resourceName, "org_id", orgID), ), }, + { + Config: alertSettingsTrue, + ExpectError: regexp.MustCompile("with_default_alerts_settings cannot be updated or set after import, remove it from the configuration or use the state value"), + }, + { + Config: alertSettingsFalseImport, + ResourceName: importResourceName, + ImportStateIdFunc: acc.ImportStateProjectIDFunc(resourceName), + ImportState: true, + ImportStatePersist: true, // save the state to use it in the next plan + }, + acc.TestStepCheckEmptyPlan(alertSettingsAbsentImport), }, }) } @@ -688,7 +712,7 @@ func TestAccProject_withUpdatedSettings(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "name", projectName), resource.TestCheckResourceAttr(resourceName, "org_id", orgID), resource.TestCheckResourceAttr(resourceName, "project_owner_id", projectOwnerID), - resource.TestCheckResourceAttr(resourceName, "with_default_alerts_settings", "false"), + resource.TestCheckResourceAttr(resourceName, "with_default_alerts_settings", "true"), // uses default value resource.TestCheckResourceAttr(resourceName, "is_collect_database_specifics_statistics_enabled", "false"), resource.TestCheckResourceAttr(resourceName, "is_data_explorer_enabled", "false"), resource.TestCheckResourceAttr(resourceName, "is_extended_storage_sizes_enabled", "false"), @@ -701,7 +725,7 @@ func TestAccProject_withUpdatedSettings(t *testing.T) { Config: acc.ConfigProjectWithSettings(projectName, orgID, projectOwnerID, true), Check: resource.ComposeAggregateTestCheckFunc( checkExists(resourceName), - resource.TestCheckResourceAttr(resourceName, "with_default_alerts_settings", "true"), + resource.TestCheckResourceAttr(resourceName, "with_default_alerts_settings", "true"), // uses default value resource.TestCheckResourceAttr(resourceName, "is_collect_database_specifics_statistics_enabled", "true"), resource.TestCheckResourceAttr(resourceName, "is_data_explorer_enabled", "true"), resource.TestCheckResourceAttr(resourceName, "is_extended_storage_sizes_enabled", "true"), @@ -714,7 +738,7 @@ func TestAccProject_withUpdatedSettings(t *testing.T) { Config: acc.ConfigProjectWithSettings(projectName, orgID, projectOwnerID, false), Check: resource.ComposeAggregateTestCheckFunc( checkExists(resourceName), - resource.TestCheckResourceAttr(resourceName, "with_default_alerts_settings", "false"), + resource.TestCheckResourceAttr(resourceName, "with_default_alerts_settings", "true"), // uses default value resource.TestCheckResourceAttr(resourceName, "is_collect_database_specifics_statistics_enabled", "false"), resource.TestCheckResourceAttr(resourceName, "is_data_explorer_enabled", "false"), resource.TestCheckResourceAttr(resourceName, "is_extended_storage_sizes_enabled", "false"), @@ -1232,15 +1256,15 @@ func configGovWithOwner(orgID, projectName, projectOwnerID string) string { `, orgID, projectName, projectOwnerID) } -func configWithFalseDefaultSettings(orgID, projectName, projectOwnerID string) string { +func configWithDefaultAlertSettings(orgID, projectName, projectOwnerID string, withDefaultAlertsSettings bool) string { return fmt.Sprintf(` resource "mongodbatlas_project" "test" { org_id = %[1]q name = %[2]q project_owner_id = %[3]q - with_default_alerts_settings = false + with_default_alerts_settings = %[4]t } - `, orgID, projectName, projectOwnerID) + `, orgID, projectName, projectOwnerID, withDefaultAlertsSettings) } func configWithLimits(orgID, projectName string, limits []*admin.DataFederationLimit) string { diff --git a/internal/testutil/acc/project.go b/internal/testutil/acc/project.go index e893d7cd9b..e338c6b1f0 100644 --- a/internal/testutil/acc/project.go +++ b/internal/testutil/acc/project.go @@ -36,7 +36,6 @@ func ConfigProjectWithSettings(projectName, orgID, projectOwnerID string, value name = %[1]q org_id = %[2]q project_owner_id = %[3]q - with_default_alerts_settings = %[4]t is_collect_database_specifics_statistics_enabled = %[4]t is_data_explorer_enabled = %[4]t is_extended_storage_sizes_enabled = %[4]t From 221ad5e24623fbb55305b86f30242876e23b247d Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 3 Sep 2025 15:52:14 +0200 Subject: [PATCH 12/14] refactor: Remove references to old plan modifier --- .../create_only_with_default.go | 91 ------------------- .../resource_schema.go | 2 +- .../pushbasedlogexport/resource_schema.go | 2 +- .../streamprocessor/resource_schema.go | 2 +- 4 files changed, 3 insertions(+), 94 deletions(-) delete mode 100644 internal/common/customplanmodifier/create_only_with_default.go diff --git a/internal/common/customplanmodifier/create_only_with_default.go b/internal/common/customplanmodifier/create_only_with_default.go deleted file mode 100644 index e63afd00a8..0000000000 --- a/internal/common/customplanmodifier/create_only_with_default.go +++ /dev/null @@ -1,91 +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" -) - -type CreateOnlyModifierWithDefault 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() CreateOnlyModifierWithDefault { - return &createOnlyAttributePlanModifierWithDefault{} -} - -func CreateOnlyAttributePlanModifierWithBoolDefault(b bool) CreateOnlyModifierWithDefault { - return &createOnlyAttributePlanModifierWithDefault{defaultBool: &b} -} - -type createOnlyAttributePlanModifierWithDefault struct { - defaultBool *bool -} - -func (d *createOnlyAttributePlanModifierWithDefault) Description(ctx context.Context) string { - return d.MarkdownDescription(ctx) -} - -func (d *createOnlyAttributePlanModifierWithDefault) 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 *createOnlyAttributePlanModifierWithDefault) UseDefault() bool { - return d.defaultBool != nil -} - -func (d *createOnlyAttributePlanModifierWithDefault) 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 *createOnlyAttributePlanModifierWithDefault) 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 *createOnlyAttributePlanModifierWithDefault) 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..42184d3eae 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.CreateOnlyAttributePlanModifier(), }, 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/pushbasedlogexport/resource_schema.go b/internal/service/pushbasedlogexport/resource_schema.go index f809a80b7d..728a7e5836 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.CreateOnlyAttributePlanModifier(), }, 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..729403b219 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.CreateOnlyAttributePlanModifier(), }, 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`.", }, From 4b184ddb3c1719a710aef3cb73a8d7b5fed7f2fd Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Thu, 4 Sep 2025 15:03:10 +0100 Subject: [PATCH 13/14] test: Add ImportStateVerifyIgnore for delete_on_create_timeout in basicTestCase, it is kept as is (UseStateForUnknown) --- internal/service/flexcluster/resource_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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"}, }, }, } From bd92500cd897843dd83ef009e8e802d57ba80fab Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Fri, 5 Sep 2025 06:15:41 +0100 Subject: [PATCH 14/14] Update docs/resources/project.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Enrique Sánchez --- docs/resources/project.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/resources/project.md b/docs/resources/project.md index 537ccd08c1..372d93c68b 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -58,7 +58,7 @@ resource "mongodbatlas_project" "test" { * `org_id` - (Required) The ID of the organization you want to create the project within. * `project_owner_id` - (Optional) Unique 24-hexadecimal digit string that identifies the Atlas user account to be granted the [Project Owner](https://docs.atlas.mongodb.com/reference/user-roles/#mongodb-authrole-Project-Owner) role on the specified project. If you set this parameter, it overrides the default value of the oldest [Organization Owner](https://docs.atlas.mongodb.com/reference/user-roles/#mongodb-authrole-Organization-Owner). * `tags` - (Optional) Map that contains key-value pairs between 1 to 255 characters in length for tagging and categorizing the project. See [below](#tags). -* `with_default_alerts_settings` - (Optional) Flag that indicates whether to create the project with default alert settings. This setting cannot be updated after project creation. By default, this flag is set to true. +* `with_default_alerts_settings` - (Optional) Flag that indicates whether the project is created with default alert settings. This setting cannot be updated after project creation. By default, this flag is set to true. * `is_collect_database_specifics_statistics_enabled` - (Optional) Flag that indicates whether to enable statistics in [cluster metrics](https://www.mongodb.com/docs/atlas/monitor-cluster-metrics/) collection for the project. By default, this flag is set to true. * `is_data_explorer_enabled` - (Optional) Flag that indicates whether to enable Data Explorer for the project. If enabled, you can query your database with an easy to use interface. When Data Explorer is disabled, you cannot terminate slow operations from the [Real-Time Performance Panel](https://www.mongodb.com/docs/atlas/real-time-performance-panel/#std-label-real-time-metrics-status-tab) or create indexes from the [Performance Advisor](https://www.mongodb.com/docs/atlas/performance-advisor/#std-label-performance-advisor). You can still view Performance Advisor recommendations, but you must create those indexes from [mongosh](https://www.mongodb.com/docs/mongodb-shell/#mongodb-binary-bin.mongosh). By default, this flag is set to true. * `is_extended_storage_sizes_enabled` - (Optional) Flag that indicates whether to enable extended storage sizes for the specified project. Clusters with extended storage sizes must be on AWS or GCP, and cannot span multiple regions. When extending storage size, initial syncs and cross-project snapshot restores will be slow. This setting should only be used as a measure of temporary relief; consider sharding if more storage is required.