forked from integrations/terraform-provider-github
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add resource github_app_installation_repositories (integrations#1376)
* 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
1 parent
f482da2
commit 1c32a52
Showing
5 changed files
with
328 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
186 changes: 186 additions & 0 deletions
186
github/resource_github_app_installation_repositories.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
81
github/resource_github_app_installation_repositories_test.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
57
website/docs/r/app_installation_repositories.html.markdown
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters