diff --git a/github/provider.go b/github/provider.go index 2fee050317..a3f190d60f 100644 --- a/github/provider.go +++ b/github/provider.go @@ -111,6 +111,7 @@ func Provider() terraform.ResourceProvider { "github_repository_webhook": resourceGithubRepositoryWebhook(), "github_repository": resourceGithubRepository(), "github_team_membership": resourceGithubTeamMembership(), + "github_team_members": resourceGithubTeamMembers(), "github_team_repository": resourceGithubTeamRepository(), "github_team_sync_group_mapping": resourceGithubTeamSyncGroupMapping(), "github_team": resourceGithubTeam(), diff --git a/github/resource_github_team_members.go b/github/resource_github_team_members.go new file mode 100644 index 0000000000..cf9384c0de --- /dev/null +++ b/github/resource_github_team_members.go @@ -0,0 +1,268 @@ +package github + +import ( + "context" + "log" + "reflect" + "strconv" + + "github.com/google/go-github/v39/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +type MemberChange struct { + Old, New map[string]interface{} +} + +func resourceGithubTeamMembers() *schema.Resource { + + return &schema.Resource{ + Create: resourceGithubTeamMembersCreate, + Read: resourceGithubTeamMembersRead, + Update: resourceGithubTeamMembersUpdate, + Delete: resourceGithubTeamMembersDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "team_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validateTeamIDFunc, + }, + "members": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "username": { + Type: schema.TypeString, + Required: true, + DiffSuppressFunc: caseInsensitive(), + }, + "role": { + Type: schema.TypeString, + Optional: true, + Default: "member", + ValidateFunc: validateValueFunc([]string{"member", "maintainer"}), + }, + }, + }, + }, + "etag": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceGithubTeamMembersCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + orgId := meta.(*Owner).id + + teamIdString := d.Get("team_id").(string) + teamId, err := strconv.ParseInt(teamIdString, 10, 64) + if err != nil { + return unconvertibleIdErr(teamIdString, err) + } + ctx := context.Background() + + members := d.Get("members").(*schema.Set) + for _, mMap := range members.List() { + memb := mMap.(map[string]interface{}) + username := memb["username"].(string) + role := memb["role"].(string) + + log.Printf("[DEBUG] Creating team membership: %s/%s (%s)", teamIdString, username, role) + _, _, err = client.Teams.AddTeamMembershipByID(ctx, + orgId, + teamId, + username, + &github.TeamAddTeamMembershipOptions{ + Role: role, + }, + ) + if err != nil { + return err + } + } + + d.SetId(teamIdString) + + return resourceGithubTeamMembersRead(d, meta) +} + +func resourceGithubTeamMembersUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + orgId := meta.(*Owner).id + + teamIdString := d.Get("team_id").(string) + teamId, err := strconv.ParseInt(teamIdString, 10, 64) + if err != nil { + return unconvertibleIdErr(teamIdString, err) + } + ctx := context.Background() + + o, n := d.GetChange("members") + vals := make(map[string]*MemberChange) + for _, raw := range o.(*schema.Set).List() { + obj := raw.(map[string]interface{}) + k := obj["username"].(string) + vals[k] = &MemberChange{Old: obj} + } + for _, raw := range n.(*schema.Set).List() { + obj := raw.(map[string]interface{}) + k := obj["username"].(string) + if _, ok := vals[k]; !ok { + vals[k] = &MemberChange{} + } + vals[k].New = obj + } + + for username, change := range vals { + var create, delete bool + + switch { + // create a new one if old is nil + case change.Old == nil: + create = true + // delete existing if new is nil + case change.New == nil: + delete = true + // no change + case reflect.DeepEqual(change.Old, change.New): + continue + // recreate - role changed + default: + delete = true + create = true + } + + if delete { + log.Printf("[DEBUG] Deleting team membership: %s/%s", teamIdString, username) + + _, err = client.Teams.RemoveTeamMembershipByID(ctx, orgId, teamId, username) + if err != nil { + return err + } + + continue + } + + if create { + role := change.New["role"].(string) + + log.Printf("[DEBUG] Creating team membership: %s/%s (%s)", teamIdString, username, role) + _, _, err = client.Teams.AddTeamMembershipByID(ctx, + orgId, + teamId, + username, + &github.TeamAddTeamMembershipOptions{ + Role: role, + }, + ) + if err != nil { + return err + } + continue + } + + // no change + if reflect.DeepEqual(change.Old, change.New) { + continue + } + } + + d.SetId(teamIdString) + + return resourceGithubTeamMembersRead(d, meta) +} + +func resourceGithubTeamMembersRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + orgId := meta.(*Owner).id + teamIdString := d.Get("team_id").(string) + + teamId, err := strconv.ParseInt(teamIdString, 10, 64) + if err != nil { + return unconvertibleIdErr(teamIdString, err) + } + + // We intentionally set these early to allow reconciliation + // from an upstream bug which emptied team_id in state + // See https://github.com/integrations/terraform-provider-github/issues/323 + d.Set("team_id", teamIdString) + + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + if !d.IsNewResource() { + ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string)) + } + + // List members & maintainers as list 'all' drops role information + log.Printf("[DEBUG] Reading team members: %s", teamIdString) + members, resp1, err := client.Teams.ListTeamMembersByID(ctx, orgId, teamId, &github.TeamListTeamMembersOptions{Role: "member"}) + if err != nil { + return err + } + + log.Printf("[DEBUG] Reading team maintainers: %s", teamIdString) + maintainers, resp2, err := client.Teams.ListTeamMembersByID(ctx, orgId, teamId, &github.TeamListTeamMembersOptions{Role: "maintainer"}) + if err != nil { + return err + } + + teamMembersAndMaintainers := make([]interface{}, len(members)+len(maintainers)) + // Add all members to the list + for i, member := range members { + teamMembersAndMaintainers[i] = map[string]interface{}{ + "username": member.Login, + "role": "member", + } + } + // Add all maintainers to the list + for i, member := range maintainers { + teamMembersAndMaintainers[i+len(members)] = map[string]interface{}{ + "username": member.Login, + "role": "maintainer", + } + } + + if err := d.Set("members", teamMembersAndMaintainers); err != nil { + return err + } + + // Combine etag of both requests + d.Set("etag", buildTwoPartID(resp1.Header.Get("ETag"), resp2.Header.Get("ETag"))) + + return nil +} + +func resourceGithubTeamMembersDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + orgId := meta.(*Owner).id + teamIdString := d.Get("team_id").(string) + teamId, err := strconv.ParseInt(teamIdString, 10, 64) + if err != nil { + return unconvertibleIdErr(teamIdString, err) + } + + members := d.Get("members").(*schema.Set) + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + for _, member := range members.List() { + mem := member.(map[string]interface{}) + username := mem["username"].(string) + + log.Printf("[DEBUG] Deleting team membership: %s/%s", teamIdString, username) + + _, err = client.Teams.RemoveTeamMembershipByID(ctx, orgId, teamId, username) + if err != nil { + return err + } + } + + return nil +} diff --git a/github/resource_github_team_members_test.go b/github/resource_github_team_members_test.go new file mode 100644 index 0000000000..4df5b9d3de --- /dev/null +++ b/github/resource_github_team_members_test.go @@ -0,0 +1,213 @@ +package github + +import ( + "context" + "fmt" + "strconv" + "testing" + + "github.com/google/go-github/v39/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/terraform" +) + +func TestAccGithubTeamMembers(t *testing.T) { + if testCollaborator == "" { + t.Skip("Skipping because `GITHUB_TEST_COLLABORATOR` is not set") + } + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + resourceName := "github_team_members.test_team_members" + + var membership github.Membership + + t.Run("creates a team & members configured with defaults", func(t *testing.T) { + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckGithubTeamMembersDestroy, + Steps: []resource.TestStep{ + { + Config: testAccGithubTeamMembersConfig(randomID, testCollaborator, "member"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "etag"), + testAccCheckGithubTeamMembersExists(resourceName, &membership), + testAccCheckGithubTeamMembersRoleState(resourceName, "member", &membership), + ), + }, + { + Config: testAccGithubTeamMembersConfig(randomID, testCollaborator, "maintainer"), + Check: resource.ComposeTestCheckFunc( + testAccCheckGithubTeamMembersExists(resourceName, &membership), + testAccCheckGithubTeamMembersRoleState(resourceName, "maintainer", &membership), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + + }) + +} + +func testAccCheckGithubTeamMembersDestroy(s *terraform.State) error { + conn := testAccProvider.Meta().(*Owner).v3client + orgId := testAccProvider.Meta().(*Owner).id + + for _, rs := range s.RootModule().Resources { + if rs.Type != "github_team_members" { + continue + } + + teamIdString := rs.Primary.ID + + teamId, err := strconv.ParseInt(teamIdString, 10, 64) + if err != nil { + return unconvertibleIdErr(teamIdString, err) + } + + members, resp, err := conn.Teams.ListTeamMembersByID(context.TODO(), + orgId, teamId, nil) + if err == nil { + if len(members) > 0 { + return fmt.Errorf("Team has still members: %v", members) + } + } + if resp.StatusCode != 404 { + return err + } + return nil + } + return nil +} + +func testAccCheckGithubTeamMembersExists(n string, membership *github.Membership) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not Found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No team ID is set") + } + + conn := testAccProvider.Meta().(*Owner).v3client + orgId := testAccProvider.Meta().(*Owner).id + teamIdString := rs.Primary.ID + + teamId, err := strconv.ParseInt(teamIdString, 10, 64) + if err != nil { + return unconvertibleIdErr(teamIdString, err) + } + + members, _, err := conn.Teams.ListTeamMembersByID(context.TODO(), orgId, teamId, nil) + if err != nil { + return err + } + + if len(members) != 1 { + return fmt.Errorf("Team has not one member: %d", len(members)) + } + + TeamMembership, _, err := conn.Teams.GetTeamMembershipByID(context.TODO(), orgId, teamId, *members[0].Login) + + if err != nil { + return err + } + *membership = *TeamMembership + return nil + } +} + +func testAccCheckGithubTeamMembersRoleState(n, expected string, membership *github.Membership) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not Found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No team ID is set") + } + + conn := testAccProvider.Meta().(*Owner).v3client + orgId := testAccProvider.Meta().(*Owner).id + teamIdString := rs.Primary.ID + + teamId, err := strconv.ParseInt(teamIdString, 10, 64) + if err != nil { + return unconvertibleIdErr(teamIdString, err) + } + + members, _, err := conn.Teams.ListTeamMembersByID(context.TODO(), orgId, teamId, nil) + if err != nil { + return err + } + + if len(members) != 1 { + return fmt.Errorf("Team has not one member: %d", len(members)) + } + + TeamMembers, _, err := conn.Teams.GetTeamMembershipByID(context.TODO(), + orgId, teamId, *members[0].Login) + if err != nil { + return err + } + + resourceRole := membership.GetRole() + actualRole := TeamMembers.GetRole() + + if resourceRole != expected { + return fmt.Errorf("Team membership role %v in resource does match expected state of %v", resourceRole, expected) + } + + if resourceRole != actualRole { + return fmt.Errorf("Team membership role %v in resource does match actual state of %v", resourceRole, actualRole) + } + return nil + } +} + +func testAccGithubTeamMembersConfig(randString, username, role string) string { + return fmt.Sprintf(` +resource "github_membership" "test_org_membership" { + username = "%s" + role = "member" +} + +resource "github_team" "test_team" { + name = "tf-acc-test-team-membership-%s" + description = "Terraform acc test group" +} + +resource "github_team_members" "test_team_members" { + team_id = "${github_team.test_team.id}" + members { + username = "%s" + role = "%s" + } + + depends_on = [github_membership.test_org_membership] +} +`, username, randString, username, role) +} diff --git a/website/docs/r/team_members.html.markdown b/website/docs/r/team_members.html.markdown new file mode 100644 index 0000000000..fc8187d27c --- /dev/null +++ b/website/docs/r/team_members.html.markdown @@ -0,0 +1,77 @@ +--- +layout: "github" +page_title: "GitHub: github_team_members" +description: |- + Provides an authoritative GitHub team members resource. +--- + +# github_team_members + +Provides a GitHub team members resource. + +This resource allows you to manage members of teams in your organization. It sets the requested team members for the team and removes all users not managed by Terraform. + +When applied, if the user hasn't accepted their invitation to the organization, they won't be part of the team until they do. + +When destroyed, all users will be removed from the team. + +~> **Note**: This resource is not compatible with `github_team_membership`. Use either `github_team_members` or `github_team_membership`. + +~> **Note**:Note: You can accidentally lock yourself out of your team using this resource. Deleting a `github_team_members` resource removes access from anyone without organization-level access to the team. Proceed with caution. It should generally only be used with teams fully managed by Terraform. + +## Example Usage + +```hcl +# Add a user to the organization +resource "github_membership" "membership_for_some_user" { + username = "SomeUser" + role = "member" +} + +resource "github_membership" "membership_for_another_user" { + username = "AnotherUser" + role = "member" +} + +resource "github_team" "some_team" { + name = "SomeTeam" + description = "Some cool team" +} + +resource "github_team_members" "some_team_members" { + team_id = github_team.some_team.id + + members { + username = "SomeUser" + role = "maintainer" + } + + members { + username = "AnotherUser" + role = "member" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `team_id` - (Required) The GitHub team id +* `members` - (Optional) List of team members. See [Members](#members) below for details. + +### Members + +`members` supports the following arguments: + +* `username` - (Required) The user to add to the team. +* `role` - (Optional) The role of the user within the team. + Must be one of `member` or `maintainer`. Defaults to `member`. + +## Import + +GitHub Team Membership can be imported using the team ID `teamid`, e.g. + +``` +$ terraform import github_team_member.some_team 1234567 +``` diff --git a/website/docs/r/team_membership.html.markdown b/website/docs/r/team_membership.html.markdown index 530c333b71..72ca50ac89 100644 --- a/website/docs/r/team_membership.html.markdown +++ b/website/docs/r/team_membership.html.markdown @@ -14,6 +14,8 @@ the user will be added to the team. If the user hasn't accepted their invitation organization, they won't be part of the team until they do. When destroyed, the user will be removed from the team. +~> **Note**: This resource is not compatible with `github_team_members`. Use either `github_team_members` or `github_team_membership`. + ## Example Usage ```hcl diff --git a/website/github.erb b/website/github.erb index c980362df7..d53d211e51 100644 --- a/website/github.erb +++ b/website/github.erb @@ -151,6 +151,9 @@
  • github_team_membership
  • +
  • + github_team_members +
  • github_team_repository