diff --git a/github/data_source_github_enterprise.go b/github/data_source_github_enterprise.go new file mode 100644 index 0000000000..1f0b5b54bb --- /dev/null +++ b/github/data_source_github_enterprise.go @@ -0,0 +1,68 @@ +package github + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/shurcooL/githubv4" +) + +func dataSourceGithubEnterprise() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubEnterpriseRead, + Schema: map[string]*schema.Schema{ + "slug": { + Type: schema.TypeString, + Required: true, + }, + "name": { + Type: schema.TypeString, + Computed: true, + }, + "description": { + Type: schema.TypeString, + Computed: true, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "url": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceGithubEnterpriseRead(data *schema.ResourceData, meta interface{}) error { + var query struct { + Enterprise struct { + ID githubv4.String + Name githubv4.String + Description githubv4.String + CreatedAt githubv4.String + Url githubv4.String + } `graphql:"enterprise(slug: $slug)"` + } + + slug := data.Get("slug").(string) + client := meta.(*Owner).v4client + variables := map[string]interface{}{ + "slug": githubv4.String(slug), + } + err := client.Query(context.Background(), &query, variables) + if err != nil { + return err + } + if query.Enterprise.ID == "" { + return fmt.Errorf("could not find enterprise %v", slug) + } + data.SetId(string(query.Enterprise.ID)) + data.Set("name", query.Enterprise.Name) + data.Set("description", query.Enterprise.Description) + data.Set("created_at", query.Enterprise.CreatedAt) + data.Set("url", query.Enterprise.Url) + + return nil +} diff --git a/github/data_source_github_enterprise_test.go b/github/data_source_github_enterprise_test.go new file mode 100644 index 0000000000..443323c1a4 --- /dev/null +++ b/github/data_source_github_enterprise_test.go @@ -0,0 +1,46 @@ +package github + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "testing" +) + +func TestAccGithubEnterpriseDataSource(t *testing.T) { + if isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + + config := fmt.Sprintf(` + data "github_enterprise" "test" { + slug = "%s" + } + `, + testEnterprise, + ) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.github_enterprise.test", "slug", testEnterprise), + resource.TestCheckResourceAttrSet("data.github_enterprise.test", "name"), + resource.TestCheckResourceAttrSet("data.github_enterprise.test", "created_at"), + resource.TestCheckResourceAttrSet("data.github_enterprise.test", "url"), + ) + + resource.Test( + t, + resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }, + ) +} diff --git a/github/provider.go b/github/provider.go index 3922a356d0..94bb24b2e5 100644 --- a/github/provider.go +++ b/github/provider.go @@ -106,6 +106,7 @@ func Provider() terraform.ResourceProvider { "github_dependabot_organization_secret": resourceGithubDependabotOrganizationSecret(), "github_dependabot_organization_secret_repositories": resourceGithubDependabotOrganizationSecretRepositories(), "github_dependabot_secret": resourceGithubDependabotSecret(), + "github_enterprise_organization": resourceGithubEnterpriseOrganization(), "github_emu_group_mapping": resourceGithubEMUGroupMapping(), "github_issue": resourceGithubIssue(), "github_issue_label": resourceGithubIssueLabel(), @@ -152,6 +153,7 @@ func Provider() terraform.ResourceProvider { "github_dependabot_organization_secrets": dataSourceGithubDependabotOrganizationSecrets(), "github_dependabot_public_key": dataSourceGithubDependabotPublicKey(), "github_dependabot_secrets": dataSourceGithubDependabotSecrets(), + "github_enterprise": dataSourceGithubEnterprise(), "github_external_groups": dataSourceGithubExternalGroups(), "github_ip_ranges": dataSourceGithubIpRanges(), "github_membership": dataSourceGithubMembership(), diff --git a/github/provider_utils.go b/github/provider_utils.go index 6c0e418d27..8a585fcc2f 100644 --- a/github/provider_utils.go +++ b/github/provider_utils.go @@ -9,6 +9,7 @@ import ( var testCollaborator string = os.Getenv("GITHUB_TEST_COLLABORATOR") var isEnterprise string = os.Getenv("ENTERPRISE_ACCOUNT") +var testEnterprise string = os.Getenv("ENTERPRISE_SLUG") var testOrganization string = testOrganizationFunc() var testOwner string = os.Getenv("GITHUB_OWNER") var testToken string = os.Getenv("GITHUB_TOKEN") @@ -49,6 +50,13 @@ func skipUnlessMode(t *testing.T, providerMode string) { } else { t.Log("GITHUB_TOKEN environment variable should be empty") } + case enterprise: + if os.Getenv("GITHUB_TOKEN") == "" { + t.Log("GITHUB_TOKEN environment variable should be set") + } else { + return + } + case individual: if os.Getenv("GITHUB_TOKEN") != "" && os.Getenv("GITHUB_OWNER") != "" { return @@ -125,3 +133,4 @@ func testOwnerFunc() string { const anonymous = "anonymous" const individual = "individual" const organization = "organization" +const enterprise = "enterprise" diff --git a/github/resource_github_enterprise_organization.go b/github/resource_github_enterprise_organization.go new file mode 100644 index 0000000000..326f61cee8 --- /dev/null +++ b/github/resource_github_enterprise_organization.go @@ -0,0 +1,381 @@ +package github + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + + "github.com/google/go-github/v49/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/shurcooL/githubv4" +) + +func resourceGithubEnterpriseOrganization() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubEnterpriseOrganizationCreate, + Read: resourceGithubEnterpriseOrganizationRead, + Delete: resourceGithubEnterpriseOrganizationDelete, + Update: resourceGithubEnterpriseOrganizationUpdate, + Importer: &schema.ResourceImporter{ + State: resourceGithubEnterpriseOrganizationImport, + }, + Schema: map[string]*schema.Schema{ + "enterprise_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "description": { + Type: schema.TypeString, + Optional: true, + }, + "admin_logins": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "billing_email": { + Type: schema.TypeString, + Required: true, + }, + }, + } +} + +func resourceGithubEnterpriseOrganizationCreate(data *schema.ResourceData, meta interface{}) error { + var mutate struct { + CreateEnterpriseOrganization struct { + Organization struct { + ID githubv4.ID + } + } `graphql:"createEnterpriseOrganization(input:$input)"` + } + + owner := meta.(*Owner) + v3 := owner.v3client + v4 := owner.v4client + + var adminLogins []githubv4.String + for _, v := range data.Get("admin_logins").(*schema.Set).List() { + adminLogins = append(adminLogins, githubv4.String(v.(string))) + } + + input := githubv4.CreateEnterpriseOrganizationInput{ + EnterpriseID: data.Get("enterprise_id"), + Login: githubv4.String(data.Get("name").(string)), + ProfileName: githubv4.String(data.Get("name").(string)), + BillingEmail: githubv4.String(data.Get("billing_email").(string)), + AdminLogins: adminLogins, + } + + err := v4.Mutate(context.Background(), &mutate, input, nil) + if err != nil { + return err + } + data.SetId(fmt.Sprintf("%s", mutate.CreateEnterpriseOrganization.Organization.ID)) + + //We use the V3 api to set the description of the org, because there is no mutator in the V4 API to edit the org's + //description + + //NOTE: There is some odd behavior here when using an EMU with SSO. If the user token has been granted permission to + //ANY ORG in the enterprise, then this works, provided that our token has sufficient permission. If the user token + //has not been added to any orgs, then this will fail. + // + //Unfortunately, there is no way in the api to grant a token permission to access an org. This needs to be done + //via the UI. This means our resource will work fine if the user has sufficient admin permissions and at least one + //org exists. It also means that we can't use terraform to automate creation of the very first org in an enterprise. + //That sucks a little, but seems like a restriction we can live with. + // + //It would be nice if there was an API available in github to enable a token for SSO. + + description := data.Get("description").(string) + if description != "" { + _, _, err = v3.Organizations.Edit( + context.Background(), + data.Get("name").(string), + &github.Organization{ + Description: github.String(description), + }, + ) + return err + } + return nil + +} + +func resourceGithubEnterpriseOrganizationRead(data *schema.ResourceData, meta interface{}) error { + var query struct { + Node struct { + Organization struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + OrganizationBillingEmail githubv4.String + MembersWithRole struct { + Edges []struct { + User struct { + Login githubv4.String + } `graphql:"node"` + Role githubv4.String + } `graphql:"edges"` + PageInfo PageInfo + } `graphql:"membersWithRole(first:100, after:$cursor)"` + } `graphql:"... on Organization"` + } `graphql:"node(id: $id)"` + } + + variables := map[string]interface{}{ + "id": data.Id(), + "cursor": (*githubv4.String)(nil), + } + + var adminLogins []interface{} + + for { + v4 := meta.(*Owner).v4client + err := v4.Query(context.Background(), &query, variables) + if err != nil { + if strings.Contains(err.Error(), "Could not resolve to a node with the global id") { + log.Printf("[INFO] Removing organization (%s) from state because it no longer exists in GitHub", data.Id()) + data.SetId("") + return nil + } + return err + } + + for _, v := range query.Node.Organization.MembersWithRole.Edges { + if v.Role == "ADMIN" { + adminLogins = append(adminLogins, string(v.User.Login)) + } + } + + if !query.Node.Organization.MembersWithRole.PageInfo.HasNextPage { + break + } + + variables["cursor"] = githubv4.NewString(query.Node.Organization.MembersWithRole.PageInfo.EndCursor) + } + + err := data.Set("admin_logins", schema.NewSet(schema.HashString, adminLogins)) + if err != nil { + return err + } + + err = data.Set("name", query.Node.Organization.Name) + if err != nil { + return err + } + + err = data.Set("billing_email", query.Node.Organization.OrganizationBillingEmail) + if err != nil { + return err + } + + err = data.Set("description", query.Node.Organization.Description) + return err +} + +func resourceGithubEnterpriseOrganizationDelete(data *schema.ResourceData, meta interface{}) error { + return errors.New("deleting organizations is not supported programmatically by github, and hence is not supported by the provider. You will need to remove the org in the github ui, then use `terraform state rm` to remove the org from the state file") +} + +func resourceGithubEnterpriseOrganizationImport(data *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + return nil, errors.New("support for import is not yet implemented") +} + +func updateDescription(ctx context.Context, data *schema.ResourceData, v3 *github.Client) error { + orgName := data.Get("name").(string) + oldDesc, newDesc := stringChanges(data.GetChange("description")) + + if oldDesc != newDesc { + _, _, err := v3.Organizations.Edit( + ctx, + orgName, + &github.Organization{ + Description: github.String(data.Get("description").(string)), + }, + ) + return err + } + return nil +} + +func removeUsers(ctx context.Context, v3 *github.Client, v4 *githubv4.Client, toRemove []interface{}, orgName string) error { + for _, user := range toRemove { + err := removeUser(ctx, v3, v4, user.(string), orgName) + if err != nil { + return err + } + } + return nil +} + +func removeUser(ctx context.Context, v3 *github.Client, v4 *githubv4.Client, user string, orgName string) error { + //How we remove an admin user from an enterprise organization depends on if the user is a member of any teams. + //If they are a member of any teams, we shouldn't delete them, instead we edit their membership role to be + //'MEMBER' instead of 'ADMIN'. If the user is not a member of any teams, then we remove from the org. + + //First, use the v4 API to count how many teams the user is in + var query struct { + Organization struct { + Teams struct { + TotalCount githubv4.Int + } `graphql:"teams(first:1, userLogins:[$user])"` + } `graphql:"organization(login: $org)"` + } + + err := v4.Query( + ctx, + &query, + map[string]interface{}{ + "org": githubv4.String(orgName), + "user": githubv4.String(user), + }, + ) + if err != nil { + return err + } + + if query.Organization.Teams.TotalCount == 0 { + _, err = v3.Organizations.RemoveOrgMembership(ctx, user, orgName) + return err + } + + membership, _, err := v3.Organizations.GetOrgMembership(ctx, user, orgName) + if err != nil { + return err + } + + membership.Role = github.String("member") + _, _, err = v3.Organizations.EditOrgMembership(ctx, user, orgName, membership) + return err +} + +func updateAdminList(ctx context.Context, data *schema.ResourceData, orgName string, v3 *github.Client, v4 *githubv4.Client) error { + oldSet, newSet := setChanges(data.GetChange("admin_logins")) + toRemove := oldSet.Difference(newSet).List() + toAdd := newSet.Difference(oldSet).List() + + err := addUsers(ctx, data, v4, toAdd) + if err != nil { + return err + } + + return removeUsers(ctx, v3, v4, toRemove, orgName) +} + +func addUsers(ctx context.Context, data *schema.ResourceData, v4 *githubv4.Client, toAdd []interface{}) error { + if len(toAdd) != 0 { + var mutate struct { + AddEnterpriseOrganizationMember struct { + Ignored string `graphql:"clientMutationId"` + } `graphql:"addEnterpriseOrganizationMember(input: $input)"` + } + + adminRole := githubv4.OrganizationMemberRoleAdmin + userIds, err := getUserIds(v4, toAdd) + if err != nil { + return err + } + + input := githubv4.AddEnterpriseOrganizationMemberInput{ + EnterpriseID: data.Get("enterprise_id"), + OrganizationID: data.Id(), + UserIDs: userIds, + Role: &adminRole, + } + + err = v4.Mutate(ctx, &mutate, input, nil) + if err != nil { + return err + } + } + + return nil +} + +func updateBillingEmail(ctx context.Context, data *schema.ResourceData, orgName string, v3 *github.Client) error { + oldBilling, newBilling := stringChanges(data.GetChange("billing_email")) + if oldBilling != newBilling { + _, _, err := v3.Organizations.Edit( + ctx, + orgName, + &github.Organization{ + BillingEmail: &newBilling, + }, + ) + if err != nil { + return err + } + } + return nil +} + +func resourceGithubEnterpriseOrganizationUpdate(data *schema.ResourceData, meta interface{}) error { + v3 := meta.(*Owner).v3client + v4 := meta.(*Owner).v4client + ctx := context.Background() + + err := updateDescription(ctx, data, v3) + if err != nil { + return err + } + + orgName := data.Get("name").(string) + err = updateAdminList(ctx, data, orgName, v3, v4) + if err != nil { + return err + } + + return updateBillingEmail(ctx, data, orgName, v3) +} + +func getUserIds(v4 *githubv4.Client, loginNames []interface{}) ([]githubv4.ID, error) { + var query struct { + User struct { + ID githubv4.String + } `graphql:"user(login: $login)"` + } + + var ret []githubv4.ID + + for _, l := range loginNames { + err := v4.Query(context.Background(), &query, map[string]interface{}{"login": githubv4.String(l.(string))}) + if err != nil { + return nil, err + } + ret = append(ret, query.User.ID) + } + return ret, nil +} + +func stringChanges(oldValue interface{}, newValue interface{}) (string, string) { + oldString, _ := oldValue.(string) + newString, _ := newValue.(string) + + return oldString, newString +} + +func setChanges(oldValue interface{}, newValue interface{}) (*schema.Set, *schema.Set) { + oldSet, _ := oldValue.(*schema.Set) + newSet, _ := newValue.(*schema.Set) + + if oldSet == nil { + oldSet = schema.NewSet(schema.HashString, nil) + } + + if newSet == nil { + newSet = schema.NewSet(schema.HashString, nil) + } + + return oldSet, newSet +} diff --git a/website/docs/d/enterprise.html.markdown b/website/docs/d/enterprise.html.markdown new file mode 100644 index 0000000000..5a069bf55a --- /dev/null +++ b/website/docs/d/enterprise.html.markdown @@ -0,0 +1,27 @@ +--- +layout: "github" +page_title: "Github: github_enterprise" +description: |- + Get an enterprise. +--- + +# github_enterprise + +Use this data source to retrieve basic information about a GitHub enterprise. + +## Example Usage + +``` +data "github_enteprise" "example" { + slug = "example-co" +} +``` + +## Attributes Reference + +* `id` - The ID of the enterprise. +* `slug` - The URL slug identifying the enterprise. +* `name` - The name of the enteprise. +* `description` - The description of the enterpise. +* `created_at` - The time the enterprise was created. +* `url` - The url for the enterprise. diff --git a/website/github.erb b/website/github.erb index 073653a7dd..6a14742025 100644 --- a/website/github.erb +++ b/website/github.erb @@ -43,6 +43,9 @@
  • dependabot_secrets
  • +
  • + github_enteprise +
  • github_external_groups