diff --git a/.changelog/3569.txt b/.changelog/3569.txt new file mode 100644 index 0000000000..d3e1358468 --- /dev/null +++ b/.changelog/3569.txt @@ -0,0 +1,3 @@ +```release-note:new-datasource +data-source/mongodbatlas_cloud_user_project_assignment +``` diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a6fda8ac4d..e4591282c4 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -463,6 +463,7 @@ func (p *MongodbtlasProvider) DataSources(context.Context) []func() datasource.D resourcepolicy.DataSource, resourcepolicy.PluralDataSource, clouduserorgassignment.DataSource, + clouduserprojectassignment.DataSource, clouduserteamassignment.DataSource, teamprojectassignment.DataSource, apikeyprojectassignment.DataSource, diff --git a/internal/service/clouduserprojectassignment/data_source.go b/internal/service/clouduserprojectassignment/data_source.go new file mode 100644 index 0000000000..890055f1a2 --- /dev/null +++ b/internal/service/clouduserprojectassignment/data_source.go @@ -0,0 +1,61 @@ +package clouduserprojectassignment + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/config" +) + +var _ datasource.DataSource = &cloudUserProjectAssignmentDS{} +var _ datasource.DataSourceWithConfigure = &cloudUserProjectAssignmentDS{} + +func DataSource() datasource.DataSource { + return &cloudUserProjectAssignmentDS{ + DSCommon: config.DSCommon{ + DataSourceName: resourceName, + }, + } +} + +type cloudUserProjectAssignmentDS struct { + config.DSCommon +} + +func (d *cloudUserProjectAssignmentDS) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = dataSourceSchema() +} + +func (d *cloudUserProjectAssignmentDS) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var state TFModel + resp.Diagnostics.Append(req.Config.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + connV2 := d.Client.AtlasV2 + projectID := state.ProjectId.ValueString() + userID := state.UserId.ValueString() + username := state.Username.ValueString() + + if username == "" && userID == "" { + resp.Diagnostics.AddError("invalid configuration", "either username or user_id must be provided") + return + } + userResp, err := fetchProjectUser(ctx, connV2, projectID, userID, username) + if err != nil { + resp.Diagnostics.AddError(errorReadingUser, err.Error()) + return + } + if userResp == nil { + resp.Diagnostics.AddError("resource not found", "no user found with the specified identifier") + return + } + + newCloudUserProjectAssignmentModel, diags := NewTFModel(ctx, projectID, userResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, newCloudUserProjectAssignmentModel)...) +} diff --git a/internal/service/clouduserprojectassignment/model.go b/internal/service/clouduserprojectassignment/model.go index db19104c13..9b764b8b11 100644 --- a/internal/service/clouduserprojectassignment/model.go +++ b/internal/service/clouduserprojectassignment/model.go @@ -24,7 +24,7 @@ func NewTFModel(ctx context.Context, projectID string, apiResp *admin.GroupUserR CreatedAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.CreatedAt)), FirstName: types.StringPointerValue(apiResp.FirstName), ProjectId: types.StringValue(projectID), - UserId: types.StringValue(apiResp.GetId()), + UserId: types.StringValue(apiResp.Id), InvitationCreatedAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.InvitationCreatedAt)), InvitationExpiresAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.InvitationExpiresAt)), InviterUsername: types.StringPointerValue(apiResp.InviterUsername), diff --git a/internal/service/clouduserprojectassignment/resource.go b/internal/service/clouduserprojectassignment/resource.go index eb99e3f389..5b342a044d 100644 --- a/internal/service/clouduserprojectassignment/resource.go +++ b/internal/service/clouduserprojectassignment/resource.go @@ -17,8 +17,11 @@ import ( ) const ( - resourceName = "cloud_user_project_assignment" - errorReadingUser = "Error retrieving project users" + resourceName = "cloud_user_project_assignment" + errorReadingByUserID = "Error getting project users by user_id" + errorReadingByUsername = "Error getting project users by username" + invalidImportID = "Invalid import ID format" + errorReadingUser = "Error retrieving project users" ) var _ resource.ResourceWithConfigure = &rs{} @@ -37,7 +40,7 @@ type rs struct { } func (r *rs) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = resourceSchema(ctx) + resp.Schema = resourceSchema() conversion.UpdateSchemaDescription(&resp.Schema) } @@ -232,7 +235,7 @@ func (r *rs) ImportState(ctx context.Context, req resource.ImportStateRequest, r importID := req.ID ok, parts := conversion.ImportSplit(req.ID, 2) if !ok { - resp.Diagnostics.AddError("invalid import ID format", "expected 'project_id/user_id' or 'project_id/username', got: "+importID) + resp.Diagnostics.AddError(invalidImportID, "expected 'project_id/user_id' or 'project_id/username', got: "+importID) return } projectID, user := parts[0], parts[1] diff --git a/internal/service/clouduserprojectassignment/resource_test.go b/internal/service/clouduserprojectassignment/resource_test.go index b017c452ce..19c6848ce9 100644 --- a/internal/service/clouduserprojectassignment/resource_test.go +++ b/internal/service/clouduserprojectassignment/resource_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "regexp" "strings" "testing" @@ -14,11 +15,17 @@ import ( var resourceNamePending = "mongodbatlas_cloud_user_project_assignment.test_pending" var resourceNameActive = "mongodbatlas_cloud_user_project_assignment.test_active" +var DSNameUsername = "data.mongodbatlas_cloud_user_project_assignment.test_username" +var DSNameUserID = "data.mongodbatlas_cloud_user_project_assignment.test_user_id" -func TestAccCloudUserProjectAssignmentRS_basic(t *testing.T) { +func TestAccCloudUserProjectAssignment_basic(t *testing.T) { resource.ParallelTest(t, *basicTestCase(t)) } +func TestAccCloudUserProjectAssignmentDS_error(t *testing.T) { + resource.ParallelTest(t, *errorTestCase(t)) +} + func basicTestCase(t *testing.T) *resource.TestCase { t.Helper() @@ -26,7 +33,7 @@ func basicTestCase(t *testing.T) *resource.TestCase { // The default MONGODB_ATLAS_USERNAME (Org Owner) is auto-assigned if no ProjectOwner is set. activeUsername := os.Getenv("MONGODB_ATLAS_USERNAME_2") pendingUsername := acc.RandomEmail() - projectName := acc.RandomName() + projectID := acc.ProjectIDExecution(t) orgID := os.Getenv("MONGODB_ATLAS_ORG_ID") roles := []string{"GROUP_OWNER", "GROUP_CLUSTER_MANAGER"} updatedRoles := []string{"GROUP_OWNER", "GROUP_SEARCH_INDEX_EDITOR", "GROUP_READ_ONLY"} @@ -37,12 +44,12 @@ func basicTestCase(t *testing.T) *resource.TestCase { CheckDestroy: checkDestroy, Steps: []resource.TestStep{ { - Config: configBasic(orgID, pendingUsername, activeUsername, projectName, roles), - Check: checks(pendingUsername, activeUsername, projectName, roles), + Config: configBasic(orgID, pendingUsername, activeUsername, projectID, roles), + Check: checks(pendingUsername, activeUsername, projectID, roles), }, { - Config: configBasic(orgID, pendingUsername, activeUsername, projectName, updatedRoles), - Check: checks(pendingUsername, activeUsername, projectName, updatedRoles), + Config: configBasic(orgID, pendingUsername, activeUsername, projectID, updatedRoles), + Check: checks(pendingUsername, activeUsername, projectID, updatedRoles), }, { ResourceName: resourceNamePending, @@ -96,7 +103,31 @@ func basicTestCase(t *testing.T) *resource.TestCase { } } -func configBasic(orgID, pendingUsername, activeUsername, projectName string, roles []string) string { +func errorTestCase(t *testing.T) *resource.TestCase { + t.Helper() + orgID := os.Getenv("MONGODB_ATLAS_ORG_ID") + projectID := acc.ProjectIDExecution(t) + return &resource.TestCase{ + PreCheck: func() { acc.PreCheckBasic(t) }, + ProtoV6ProviderFactories: acc.TestAccProviderV6Factories, + Steps: []resource.TestStep{ + { + Config: configError(orgID, projectID), + ExpectError: regexp.MustCompile("either username or user_id must be provided"), + }, + }, + } +} + +func configError(orgID, projectID string) string { + return fmt.Sprintf(` + data "mongodbatlas_cloud_user_project_assignment" "test" { + project_id = %[1]q + } + `, projectID, orgID) +} + +func configBasic(orgID, pendingUsername, activeUsername, projectID string, roles []string) string { rolesStr := `"` + strings.Join(roles, `", "`) + `"` return fmt.Sprintf(` resource "mongodbatlas_project" "test" { @@ -106,26 +137,41 @@ func configBasic(orgID, pendingUsername, activeUsername, projectName string, rol resource "mongodbatlas_cloud_user_project_assignment" "test_pending" { username = %[3]q - project_id = mongodbatlas_project.test.id + project_id = %[1]q roles = [%[5]s] } resource "mongodbatlas_cloud_user_project_assignment" "test_active" { username = %[4]q - project_id = mongodbatlas_project.test.id + project_id = %[1]q roles = [%[5]s] + } + + data "mongodbatlas_cloud_user_project_assignment" "test_username" { + project_id = %[1]q + username = mongodbatlas_cloud_user_project_assignment.test_pending.username + } + + data "mongodbatlas_cloud_user_project_assignment" "test_user_id" { + project_id = %[1]q + user_id = mongodbatlas_cloud_user_project_assignment.test_pending.user_id }`, - projectName, orgID, pendingUsername, activeUsername, rolesStr) + projectID, orgID, pendingUsername, activeUsername, rolesStr) } -func checks(pendingUsername, activeUsername, projectName string, roles []string) resource.TestCheckFunc { +func checks(pendingUsername, activeUsername, projectID string, roles []string) resource.TestCheckFunc { checkFuncs := []resource.TestCheckFunc{ resource.TestCheckResourceAttr(resourceNamePending, "username", pendingUsername), - resource.TestCheckResourceAttrSet(resourceNamePending, "project_id"), + resource.TestCheckResourceAttr(resourceNamePending, "project_id", projectID), resource.TestCheckResourceAttr(resourceNamePending, "roles.#", fmt.Sprintf("%d", len(roles))), resource.TestCheckResourceAttr(resourceNameActive, "username", activeUsername), - resource.TestCheckResourceAttrSet(resourceNameActive, "project_id"), + resource.TestCheckResourceAttr(resourceNameActive, "project_id", projectID), resource.TestCheckResourceAttr(resourceNameActive, "roles.#", fmt.Sprintf("%d", len(roles))), + resource.TestCheckResourceAttr(DSNameUserID, "username", pendingUsername), + resource.TestCheckResourceAttrPair(DSNameUserID, "username", resourceNamePending, "username"), + resource.TestCheckResourceAttrPair(DSNameUsername, "user_id", DSNameUserID, "user_id"), + resource.TestCheckResourceAttrPair(DSNameUsername, "project_id", DSNameUserID, "project_id"), + resource.TestCheckResourceAttrPair(DSNameUsername, "roles.#", DSNameUserID, "roles.#"), } for _, role := range roles { @@ -134,6 +180,7 @@ func checks(pendingUsername, activeUsername, projectName string, roles []string) resource.TestCheckTypeSetElemAttr(resourceNameActive, "roles.*", role), ) } + return resource.ComposeAggregateTestCheckFunc(checkFuncs...) } diff --git a/internal/service/clouduserprojectassignment/resource_schema.go b/internal/service/clouduserprojectassignment/schema.go similarity index 86% rename from internal/service/clouduserprojectassignment/resource_schema.go rename to internal/service/clouduserprojectassignment/schema.go index f3fbb3eb01..a230962fa6 100644 --- a/internal/service/clouduserprojectassignment/resource_schema.go +++ b/internal/service/clouduserprojectassignment/schema.go @@ -1,18 +1,18 @@ package clouduserprojectassignment import ( - "context" - "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + dsschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" "github.com/hashicorp/terraform-plugin-framework/resource/schema" ) -func resourceSchema(ctx context.Context) schema.Schema { +func resourceSchema() schema.Schema { return schema.Schema{ Attributes: map[string]schema.Attribute{ "country": schema.StringAttribute{ @@ -106,6 +106,26 @@ func resourceSchema(ctx context.Context) schema.Schema { } } +func dataSourceSchema() dsschema.Schema { + return conversion.DataSourceSchemaFromResource(resourceSchema(), &conversion.DataSourceSchemaRequest{ + RequiredFields: []string{"project_id"}, + OverridenFields: dataSourceOverridenFields(), + }) +} + +func dataSourceOverridenFields() map[string]dsschema.Attribute { + return map[string]dsschema.Attribute{ + "user_id": dsschema.StringAttribute{ + Optional: true, + MarkdownDescription: "Unique 24-hexadecimal digit string that identifies the MongoDB Cloud user.", + }, + "username": dsschema.StringAttribute{ + Optional: true, + MarkdownDescription: "Email address that represents the username of the MongoDB Cloud user.", + }, + } +} + type TFModel struct { Country types.String `tfsdk:"country"` CreatedAt types.String `tfsdk:"created_at"` diff --git a/internal/service/clouduserprojectassignment/tfplugingen/generator_config.yml b/internal/service/clouduserprojectassignment/tfplugingen/generator_config.yml index c29f693a7d..5b13d9679c 100644 --- a/internal/service/clouduserprojectassignment/tfplugingen/generator_config.yml +++ b/internal/service/clouduserprojectassignment/tfplugingen/generator_config.yml @@ -15,3 +15,8 @@ resources: path: /api/atlas/v2/groups/{groupId}/users/{userId} method: DELETE +data_sources: + cloud_user_project_assignment: + read: + path: /api/atlas/v2/groups/{groupId}/users/{userId} + method: GET \ No newline at end of file