Skip to content

Commit

Permalink
Add resource github_app_installation_repositories (integrations#1376)
Browse files Browse the repository at this point in the history
* Add resource github_app_installation_repositories

This resource allows multiple repositories to be passed in; which greatly improves the performance of the resource compared to the single repository version
when needing to control state of multiple app installations with multiple repos, required in larger organisations.

The optimisation occurs as only a single call to get the list of repos is required per installation per read, regardless of the number of respositories being added.

- Add resource_github_app_installation_repositories
- Add tests

* Update docs and link

Co-authored-by: Keegan Campbell <me@kfcampbell.com>
  • Loading branch information
2 people authored and kazaker committed Dec 28, 2022
1 parent 86290fe commit eff1633
Show file tree
Hide file tree
Showing 5 changed files with 328 additions and 0 deletions.
1 change: 1 addition & 0 deletions github/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
186 changes: 186 additions & 0 deletions github/resource_github_app_installation_repositories.go
Original file line number Diff line number Diff line change
@@ -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
}
81 changes: 81 additions & 0 deletions github/resource_github_app_installation_repositories_test.go
Original file line number Diff line number Diff line change
@@ -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)
})

})

}
57 changes: 57 additions & 0 deletions website/docs/r/app_installation_repositories.html.markdown
Original file line number Diff line number Diff line change
@@ -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
```
3 changes: 3 additions & 0 deletions website/github.erb
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@
<li>
<a href="/docs/providers/github/r/actions_secret.html">github_actions_secret</a>
</li>
<li>
<a href="/docs/providers/github/r/app_installation_repositories.html">github_app_installation_repositories</a>
</li>
<li>
<a href="/docs/providers/github/r/app_installation_repository.html">github_app_installation_repository</a>
</li>
Expand Down

0 comments on commit eff1633

Please sign in to comment.