diff --git a/github/provider.go b/github/provider.go index c8725c35df..ff739710b0 100644 --- a/github/provider.go +++ b/github/provider.go @@ -96,6 +96,7 @@ func Provider() terraform.ResourceProvider { "github_actions_repository_permissions": resourceGithubActionsRepositoryPermissions(), "github_actions_runner_group": resourceGithubActionsRunnerGroup(), "github_actions_secret": resourceGithubActionsSecret(), + "github_app_installation_repositories": resourceGithubAppInstallationRepositories(), "github_app_installation_repository": resourceGithubAppInstallationRepository(), "github_branch": resourceGithubBranch(), "github_branch_default": resourceGithubBranchDefault(), diff --git a/github/resource_github_app_installation_repositories.go b/github/resource_github_app_installation_repositories.go new file mode 100644 index 0000000000..f183e5986e --- /dev/null +++ b/github/resource_github_app_installation_repositories.go @@ -0,0 +1,186 @@ +package github + +import ( + "context" + "log" + "strconv" + + "github.com/google/go-github/v48/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func resourceGithubAppInstallationRepositories() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubAppInstallationRepositoriesCreateOrUpdate, + Read: resourceGithubAppInstallationRepositoriesRead, + Update: resourceGithubAppInstallationRepositoriesCreateOrUpdate, + Delete: resourceGithubAppInstallationRepositoriesDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "installation_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "selected_repositories": { + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Set: schema.HashString, + Required: true, + }, + }, + } +} + +func resourceGithubAppInstallationRepositoriesCreateOrUpdate(d *schema.ResourceData, meta interface{}) error { + installationIDString := d.Get("installation_id").(string) + selectedRepositories := d.Get("selected_repositories") + + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.WithValue(context.Background(), ctxId, installationIDString) + + selectedRepositoryNames := []string{} + + names := selectedRepositories.(*schema.Set).List() + for _, name := range names { + selectedRepositoryNames = append(selectedRepositoryNames, name.(string)) + } + + currentReposNameIDs, instID, err := getAllAccessibleRepos(meta, installationIDString) + if err != nil { + return err + } + + // Add repos that are not in the current state on GitHub + for _, repoName := range selectedRepositoryNames { + if _, ok := currentReposNameIDs[repoName]; ok { + // If it already exists, remove it from the map so we can delete all that are left at the end + delete(currentReposNameIDs, repoName) + } else { + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return err + } + repoID := repo.GetID() + log.Printf("[DEBUG]: Adding %v:%v to app installation %v", repoName, repoID, instID) + _, _, err = client.Apps.AddRepository(ctx, instID, repoID) + if err != nil { + return err + } + } + } + + // Remove repositories that existed on GitHub but not selectedRepositories + // There is a github limitation that means we can't remove the last repository from an installation. + // Therefore, we skip the first and delete the rest. The app will then need to be uninstalled via the GUI + // as there is no current API endpoint for [un]installation. Ensure there is at least one repository remaining. + if len(selectedRepositoryNames) >= 1 { + for repoName, repoID := range currentReposNameIDs { + log.Printf("[DEBUG]: Removing %v:%v from app installation %v", repoName, repoID, instID) + _, err = client.Apps.RemoveRepository(ctx, instID, repoID) + if err != nil { + return err + } + } + } + + d.SetId(installationIDString) + return resourceGithubAppInstallationRepositoriesRead(d, meta) +} + +func resourceGithubAppInstallationRepositoriesRead(d *schema.ResourceData, meta interface{}) error { + installationIDString := d.Id() + + reposNameIDs, _, err := getAllAccessibleRepos(meta, installationIDString) + if err != nil { + return err + } + + repoNames := []string{} + for name := range reposNameIDs { + repoNames = append(repoNames, name) + } + + if len(reposNameIDs) > 0 { + d.Set("installation_id", installationIDString) + d.Set("selected_repositories", repoNames) + return nil + } + + log.Printf("[INFO] Removing app installation repository association %s from state because it no longer exists in GitHub", + d.Id()) + d.SetId("") + return nil +} + +func resourceGithubAppInstallationRepositoriesDelete(d *schema.ResourceData, meta interface{}) error { + installationIDString := d.Get("installation_id").(string) + + reposNameIDs, instID, err := getAllAccessibleRepos(meta, installationIDString) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + ctx := context.WithValue(context.Background(), ctxId, installationIDString) + + // There is a github limitation that means we can't remove the last repository from an installation. + // Therefore, we skip the first and delete the rest. The app will then need to be uninstalled via the GUI + // as there is no current API endpoint for [un]installation. + first := true + for repoName, repoID := range reposNameIDs { + if first { + first = false + log.Printf("[WARN]: Cannot remove %v:%v from app installation %v as there must remain at least one repository selected due to API limitations. Manually uninstall the app to remove.", repoName, repoID, instID) + continue + } else { + _, err = client.Apps.RemoveRepository(ctx, instID, repoID) + log.Printf("[DEBUG]: Removing %v:%v from app installation %v", repoName, repoID, instID) + if err != nil { + return err + } + } + } + return nil +} + +func getAllAccessibleRepos(meta interface{}, idString string) (map[string]int64, int64, error) { + err := checkOrganization(meta) + if err != nil { + return nil, 0, err + } + + installationID, err := strconv.ParseInt(idString, 10, 64) + if err != nil { + return nil, 0, unconvertibleIdErr(idString, err) + } + + ctx := context.WithValue(context.Background(), ctxId, idString) + opt := &github.ListOptions{PerPage: maxPerPage} + client := meta.(*Owner).v3client + + allRepos := make(map[string]int64) + + for { + repos, resp, err := client.Apps.ListUserRepos(ctx, installationID, opt) + if err != nil { + return nil, 0, err + } + for _, r := range repos.Repositories { + allRepos[r.GetName()] = r.GetID() + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return allRepos, installationID, nil +} diff --git a/github/resource_github_app_installation_repositories_test.go b/github/resource_github_app_installation_repositories_test.go new file mode 100644 index 0000000000..4c3c361f2f --- /dev/null +++ b/github/resource_github_app_installation_repositories_test.go @@ -0,0 +1,81 @@ +package github + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubAppInstallationRepositories(t *testing.T) { + + const APP_INSTALLATION_ID = "APP_INSTALLATION_ID" + randomID1 := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + randomID2 := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + installation_id, exists := os.LookupEnv(APP_INSTALLATION_ID) + + t.Run("installs an app to multiple repositories", func(t *testing.T) { + + if !exists { + t.Skipf("%s environment variable is missing", APP_INSTALLATION_ID) + } + + config := fmt.Sprintf(` + + resource "github_repository" "test1" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_repository" "test2" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_app_installation_repositories" "test" { + # The installation id of the app (in the organization). + installation_id = "%s" + selected_repositories = [github_repository.test1.name, github_repository.test2.name] + } + + `, randomID1, randomID2, installation_id) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet( + "github_app_installation_repositories.test", "installation_id", + ), + resource.TestCheckResourceAttr( + "github_app_installation_repositories.test", "selected_repositories.#", "2", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + 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) + }) + + }) + +} diff --git a/website/docs/r/app_installation_repositories.html.markdown b/website/docs/r/app_installation_repositories.html.markdown new file mode 100644 index 0000000000..f47e26419f --- /dev/null +++ b/website/docs/r/app_installation_repositories.html.markdown @@ -0,0 +1,57 @@ +--- +layout: "github" +page_title: "GitHub: github_app_installation_repository" +description: |- + Manages the associations between app installations and repositories. +--- + +# github_app_installation_repositories + +~> **Note**: This resource is not compatible with the GitHub App Installation authentication method. + +This resource manages relationships between app installations and repositories +in your GitHub organization. + +Creating this resource installs a particular app on multiple repositories. + +The app installation and the repositories must all belong to the same +organization on GitHub. Note: you can review your organization's installations +by the following the instructions at this +[link](https://docs.github.com/en/github/setting-up-and-managing-organizations-and-teams/reviewing-your-organizations-installed-integrations). + +## Example Usage + +```hcl +# Create some repositories. +resource "github_repository" "some_repo" { + name = "some-repo" +} + +resource "github_repository" "another_repo" { + name = "another-repo" +} + +resource "github_app_installation_repositories" "some_app_repos" { + # The installation id of the app (in the organization). + installation_id = "1234567" + selected_repository_ids = [github_repository.some_repo.name, github_repository.another_repo.name]" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `installation_id` - (Required) The GitHub app installation id. +* `selected_repositories` - (Required) A list of repository names to install the app on. + +~> **Note**: Due to how GitHub implements app installations, apps cannot be installed with no repositories selected. Therefore deleting this resource will leave one repository with the app installed. Manually uninstall the app or set the installation to all repositories via the GUI as after deleting this resource. + +## Import + +GitHub App Installation Repository can be imported +using an ID made up of `installation_id:repository`, e.g. + +``` +$ terraform import github_app_installation_repository.terraform_repo 1234567:terraform +``` diff --git a/website/github.erb b/website/github.erb index 018f7f3c78..749b2d8ce8 100644 --- a/website/github.erb +++ b/website/github.erb @@ -133,6 +133,9 @@
  • github_actions_secret
  • +
  • + github_app_installation_repositories +
  • github_app_installation_repository