diff --git a/internal/provider/group_resource.go b/internal/provider/group_resource.go index 1afaebb..46bc6c9 100644 --- a/internal/provider/group_resource.go +++ b/internal/provider/group_resource.go @@ -240,19 +240,24 @@ func (r *GroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group, got error: %s", err)) return } - var newMembers []string - resp.Diagnostics.Append( - data.Members.ElementsAs(ctx, &newMembers, false)..., - ) - if resp.Diagnostics.HasError() { - return - } var add []string var remove []string if !data.Members.IsNull() { - add, remove = memberDiff(group.Members, newMembers) + var plannedMembers []UUID + resp.Diagnostics.Append( + data.Members.ElementsAs(ctx, &plannedMembers, false)..., + ) + if resp.Diagnostics.HasError() { + return + } + curMembers := make([]uuid.UUID, 0, len(group.Members)) + for _, member := range group.Members { + curMembers = append(curMembers, member.ID) + } + add, remove = memberDiff(curMembers, plannedMembers) } tflog.Trace(ctx, "updating group", map[string]any{ + "id": groupID, "new_members": add, "removed_members": remove, "new_name": data.Name, @@ -293,7 +298,9 @@ func (r *GroupResource) Delete(ctx context.Context, req resource.DeleteRequest, client := r.data.Client groupID := data.ID.ValueUUID() - tflog.Trace(ctx, "deleting group") + tflog.Trace(ctx, "deleting group", map[string]any{ + "id": groupID, + }) err := client.DeleteGroup(ctx, groupID) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete group, got error: %s", err)) @@ -320,24 +327,3 @@ func (r *GroupResource) ImportState(ctx context.Context, req resource.ImportStat } resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) } - -func memberDiff(curMembers []codersdk.ReducedUser, newMembers []string) (add, remove []string) { - curSet := make(map[string]struct{}, len(curMembers)) - newSet := make(map[string]struct{}, len(newMembers)) - - for _, user := range curMembers { - curSet[user.ID.String()] = struct{}{} - } - for _, userID := range newMembers { - newSet[userID] = struct{}{} - if _, exists := curSet[userID]; !exists { - add = append(add, userID) - } - } - for _, user := range curMembers { - if _, exists := newSet[user.ID.String()]; !exists { - remove = append(remove, user.ID.String()) - } - } - return add, remove -} diff --git a/internal/provider/group_resource_test.go b/internal/provider/group_resource_test.go index 2b90868..b049976 100644 --- a/internal/provider/group_resource_test.go +++ b/internal/provider/group_resource_test.go @@ -62,49 +62,66 @@ func TestAccGroupResource(t *testing.T) { cfg3 := cfg2 cfg3.Members = nil - resource.Test(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - // Create and Read - { - Config: cfg1.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_group.test", "name", "example-group"), - resource.TestCheckResourceAttr("coderd_group.test", "display_name", "Example Group"), - resource.TestCheckResourceAttr("coderd_group.test", "avatar_url", "https://google.com"), - resource.TestCheckResourceAttr("coderd_group.test", "quota_allowance", "100"), - resource.TestCheckResourceAttr("coderd_group.test", "organization_id", firstUser.OrganizationIDs[0].String()), - resource.TestCheckResourceAttr("coderd_group.test", "members.#", "1"), - resource.TestCheckResourceAttr("coderd_group.test", "members.0", user1.ID.String()), - ), + t.Run("CreateImportUpdateReadOk", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read + { + Config: cfg1.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_group.test", "name", "example-group"), + resource.TestCheckResourceAttr("coderd_group.test", "display_name", "Example Group"), + resource.TestCheckResourceAttr("coderd_group.test", "avatar_url", "https://google.com"), + resource.TestCheckResourceAttr("coderd_group.test", "quota_allowance", "100"), + resource.TestCheckResourceAttr("coderd_group.test", "organization_id", firstUser.OrganizationIDs[0].String()), + resource.TestCheckResourceAttr("coderd_group.test", "members.#", "1"), + resource.TestCheckResourceAttr("coderd_group.test", "members.0", user1.ID.String()), + ), + }, + // Import + { + Config: cfg1.String(t), + ResourceName: "coderd_group.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"members"}, + }, + // Update and Read + { + Config: cfg2.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_group.test", "name", "example-group-new"), + resource.TestCheckResourceAttr("coderd_group.test", "display_name", "Example Group New"), + resource.TestCheckResourceAttr("coderd_group.test", "members.#", "1"), + resource.TestCheckResourceAttr("coderd_group.test", "members.0", user2.ID.String()), + ), + }, + // Unmanaged members + { + Config: cfg3.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckNoResourceAttr("coderd_group.test", "members"), + ), + }, }, - // Import - { - Config: cfg1.String(t), - ResourceName: "coderd_group.test", - ImportState: true, - ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"members"}, - }, - // Update and Read - { - Config: cfg2.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_group.test", "name", "example-group-new"), - resource.TestCheckResourceAttr("coderd_group.test", "display_name", "Example Group New"), - resource.TestCheckResourceAttr("coderd_group.test", "members.#", "1"), - resource.TestCheckResourceAttr("coderd_group.test", "members.0", user2.ID.String()), - ), - }, - // Unmanaged members - { - Config: cfg3.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckNoResourceAttr("coderd_group.test", "members"), - ), + }) + }) + + t.Run("CreateUnmanagedMembersOk", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg3.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckNoResourceAttr("coderd_group.test", "members"), + ), + }, }, - }, + }) }) } diff --git a/internal/provider/util.go b/internal/provider/util.go index 75f5196..bb1386e 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -6,6 +6,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/google/uuid" ) func PtrTo[T any](v T) *T { @@ -76,3 +78,26 @@ func computeDirectoryHash(directory string) (string, error) { } return hex.EncodeToString(hash.Sum(nil)), nil } + +// memberDiff returns the members to add and remove from the group, given the current members and the planned members. +// plannedMembers is deliberately our custom type, as Terraform cannot automatically produce `[]uuid.UUID` from a set. +func memberDiff(curMembers []uuid.UUID, plannedMembers []UUID) (add, remove []string) { + curSet := make(map[uuid.UUID]struct{}, len(curMembers)) + planSet := make(map[uuid.UUID]struct{}, len(plannedMembers)) + + for _, userID := range curMembers { + curSet[userID] = struct{}{} + } + for _, plannedUserID := range plannedMembers { + planSet[plannedUserID.ValueUUID()] = struct{}{} + if _, exists := curSet[plannedUserID.ValueUUID()]; !exists { + add = append(add, plannedUserID.ValueString()) + } + } + for _, curUserID := range curMembers { + if _, exists := planSet[curUserID]; !exists { + remove = append(remove, curUserID.String()) + } + } + return add, remove +}