diff --git a/.github/workflows/acceptance-tests.yaml b/.github/workflows/acceptance-tests.yaml index 118887cc19..c1217ce056 100644 --- a/.github/workflows/acceptance-tests.yaml +++ b/.github/workflows/acceptance-tests.yaml @@ -216,6 +216,7 @@ jobs: GITHUB_BASE_URL: https://api.github.com/ GITHUB_OWNER: ${{ case(matrix.mode == 'anonymous', '', matrix.mode == 'individual', vars.GH_TEST_LOGIN, vars.GH_TEST_ORG_NAME) }} GITHUB_ENTERPRISE_SLUG: ${{ case(matrix.mode == 'enterprise', vars.GH_TEST_ENTERPRISE_SLUG, '') }} + GH_TEST_ENTERPRISE_APP_CLIENT_ID: ${{ vars.GH_TEST_ENTERPRISE_APP_CLIENT_ID }} GITHUB_LEGACY_CLIENT: "false" GH_TEST_AUTH_MODE: ${{ matrix.mode }} GH_TEST_ORG_USER1: ${{ vars.GH_TEST_ORG_USER1 }} diff --git a/RESOURCES.md b/RESOURCES.md index b1b1c3e2b0..f0b3fa8283 100644 --- a/RESOURCES.md +++ b/RESOURCES.md @@ -69,6 +69,9 @@ The overall status of each resource or data source is captured in this document | `github_dependabot_public_key` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_dependabot_secrets` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | +| `github_enterprise_app_installable_organizations` | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | +| `github_enterprise_organization_app_accessible_repositories` | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | +| `github_enterprise_organization_app_installations` | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | | `github_external_groups` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `github_ip_ranges` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_issue_labels` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | @@ -162,6 +165,7 @@ The overall status of each resource or data source is captured in this document | `github_enterprise_actions_workflow_permissions` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_ip_allow_list_entry` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_organization` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | +| `github_enterprise_organization_app_installation` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_security_analysis_settings` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_issue` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_issue_label` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | diff --git a/docs/data-sources/enterprise_app_installable_organizations.md b/docs/data-sources/enterprise_app_installable_organizations.md new file mode 100644 index 0000000000..e0ce62e545 --- /dev/null +++ b/docs/data-sources/enterprise_app_installable_organizations.md @@ -0,0 +1,39 @@ +--- +page_title: "github_enterprise_app_installable_organizations (Data Source) - GitHub" +subcategory: "" +description: |- + Use this data source to retrieve the enterprise-owned organizations that GitHub Apps can be installed on. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. +--- + +# github_enterprise_app_installable_organizations (Data Source) + +Use this data source to retrieve the enterprise-owned organizations that GitHub Apps can be installed on. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. + +## Example Usage + +```terraform +data "github_enterprise_app_installable_organizations" "example" { + enterprise_slug = "my-enterprise" +} +``` + + +## Schema + +### Required + +- `enterprise_slug` (String) The slug of the enterprise. + +### Read-Only + +- `id` (String) The ID of this resource. +- `organizations` (List of Object) List of organizations in the enterprise that GitHub Apps can be installed on. (see [below for nested schema](#nestedatt--organizations)) + + +### Nested Schema for `organizations` + +Read-Only: + +- `accessible_repositories_url` (String) +- `id` (Number) +- `login` (String) diff --git a/docs/data-sources/enterprise_organization_app_accessible_repositories.md b/docs/data-sources/enterprise_organization_app_accessible_repositories.md new file mode 100644 index 0000000000..11d33a6250 --- /dev/null +++ b/docs/data-sources/enterprise_organization_app_accessible_repositories.md @@ -0,0 +1,41 @@ +--- +page_title: "github_enterprise_organization_app_accessible_repositories (Data Source) - GitHub" +subcategory: "" +description: |- + Use this data source to retrieve the repositories of an enterprise-owned organization that GitHub Apps can be granted access to. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. +--- + +# github_enterprise_organization_app_accessible_repositories (Data Source) + +Use this data source to retrieve the repositories of an enterprise-owned organization that GitHub Apps can be granted access to. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. + +## Example Usage + +```terraform +data "github_enterprise_organization_app_accessible_repositories" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} +``` + + +## Schema + +### Required + +- `enterprise_slug` (String) The slug of the enterprise that owns the organization. +- `organization` (String) The login of the enterprise-owned organization. + +### Read-Only + +- `id` (String) The ID of this resource. +- `repositories` (List of Object) List of repositories of the organization that GitHub Apps can be granted access to. (see [below for nested schema](#nestedatt--repositories)) + + +### Nested Schema for `repositories` + +Read-Only: + +- `full_name` (String) +- `id` (Number) +- `name` (String) diff --git a/docs/data-sources/enterprise_organization_app_installations.md b/docs/data-sources/enterprise_organization_app_installations.md new file mode 100644 index 0000000000..0bdc35ab8e --- /dev/null +++ b/docs/data-sources/enterprise_organization_app_installations.md @@ -0,0 +1,51 @@ +--- +page_title: "github_enterprise_organization_app_installations (Data Source) - GitHub" +subcategory: "" +description: |- + Use this data source to retrieve the GitHub App installations on an enterprise-owned organization. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. +--- + +# github_enterprise_organization_app_installations (Data Source) + +Use this data source to retrieve the GitHub App installations on an enterprise-owned organization. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. + +## Example Usage + +```terraform +data "github_enterprise_organization_app_installations" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} +``` + + +## Schema + +### Required + +- `enterprise_slug` (String) The slug of the enterprise that owns the organization. +- `organization` (String) The login of the enterprise-owned organization. + +### Read-Only + +- `id` (String) The ID of this resource. +- `installations` (List of Object) List of GitHub App installations on the organization. (see [below for nested schema](#nestedatt--installations)) + + +### Nested Schema for `installations` + +Read-Only: + +- `app_id` (Number) +- `app_slug` (String) +- `client_id` (String) +- `created_at` (String) +- `events` (List of String) +- `id` (Number) +- `permissions` (Map of String) +- `repository_selection` (String) +- `single_file_paths` (List of String) +- `suspended` (Boolean) +- `target_id` (Number) +- `target_type` (String) +- `updated_at` (String) diff --git a/docs/resources/enterprise_organization_app_installation.md b/docs/resources/enterprise_organization_app_installation.md new file mode 100644 index 0000000000..6fc6d382fd --- /dev/null +++ b/docs/resources/enterprise_organization_app_installation.md @@ -0,0 +1,59 @@ +--- +page_title: "github_enterprise_organization_app_installation (Resource) - GitHub" +subcategory: "" +description: |- + Manage the installation of a GitHub App on an organization owned by an enterprise. The app is installed on the organization, not on the enterprise account itself; GitHub offers no API for the latter. This resource requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. +--- + +# github_enterprise_organization_app_installation (Resource) + +Manage the installation of a GitHub App on an organization owned by an enterprise. The app is installed on the organization, not on the enterprise account itself; GitHub offers no API for the latter. This resource requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. + +## Example Usage + +```terraform +resource "github_enterprise_organization_app_installation" "all_repos" { + enterprise_slug = "my-enterprise" + organization = "my-org" + client_id = "Iv1.abc123def456" + repository_selection = "all" +} + +resource "github_enterprise_organization_app_installation" "selected_repos" { + enterprise_slug = "my-enterprise" + organization = "my-org" + client_id = "Iv1.789ghi012jkl" + repository_selection = "selected" + selected_repositories = ["my-repo-1", "my-repo-2"] +} +``` + + +## Schema + +### Required + +- `client_id` (String) The client ID of the GitHub App to install. +- `enterprise_slug` (String) The slug of the enterprise that owns the organization. +- `organization` (String) The login of the enterprise-owned organization to install the app on. +- `repository_selection` (String) The repositories the installation can access. Can be one of 'all', 'selected' or 'none'. + +### Optional + +- `selected_repositories` (Set of String) The names of the repositories the installation can access when 'repository_selection' is 'selected'. + +### Read-Only + +- `app_slug` (String) The slug of the installed app. +- `id` (String) The ID of this resource. +- `installation_id` (String) The ID of the installation. + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +terraform import github_enterprise_organization_app_installation.all_repos my-enterprise:my-org:Iv1.abc123def456 +``` diff --git a/examples/data-sources/github_enterprise_app_installable_organizations/data-source_1.tf b/examples/data-sources/github_enterprise_app_installable_organizations/data-source_1.tf new file mode 100644 index 0000000000..51fc4906fd --- /dev/null +++ b/examples/data-sources/github_enterprise_app_installable_organizations/data-source_1.tf @@ -0,0 +1,3 @@ +data "github_enterprise_app_installable_organizations" "example" { + enterprise_slug = "my-enterprise" +} diff --git a/examples/data-sources/github_enterprise_organization_app_accessible_repositories/data-source_1.tf b/examples/data-sources/github_enterprise_organization_app_accessible_repositories/data-source_1.tf new file mode 100644 index 0000000000..3c0860968a --- /dev/null +++ b/examples/data-sources/github_enterprise_organization_app_accessible_repositories/data-source_1.tf @@ -0,0 +1,4 @@ +data "github_enterprise_organization_app_accessible_repositories" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} diff --git a/examples/data-sources/github_enterprise_organization_app_installations/data-source_1.tf b/examples/data-sources/github_enterprise_organization_app_installations/data-source_1.tf new file mode 100644 index 0000000000..a8ea83f3a0 --- /dev/null +++ b/examples/data-sources/github_enterprise_organization_app_installations/data-source_1.tf @@ -0,0 +1,4 @@ +data "github_enterprise_organization_app_installations" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} diff --git a/examples/resources/github_enterprise_organization_app_installation/import.sh b/examples/resources/github_enterprise_organization_app_installation/import.sh new file mode 100644 index 0000000000..1c9a0d0f2a --- /dev/null +++ b/examples/resources/github_enterprise_organization_app_installation/import.sh @@ -0,0 +1 @@ +terraform import github_enterprise_organization_app_installation.all_repos my-enterprise:my-org:Iv1.abc123def456 diff --git a/examples/resources/github_enterprise_organization_app_installation/resource.tf b/examples/resources/github_enterprise_organization_app_installation/resource.tf new file mode 100644 index 0000000000..a29a80a233 --- /dev/null +++ b/examples/resources/github_enterprise_organization_app_installation/resource.tf @@ -0,0 +1,14 @@ +resource "github_enterprise_organization_app_installation" "all_repos" { + enterprise_slug = "my-enterprise" + organization = "my-org" + client_id = "Iv1.abc123def456" + repository_selection = "all" +} + +resource "github_enterprise_organization_app_installation" "selected_repos" { + enterprise_slug = "my-enterprise" + organization = "my-org" + client_id = "Iv1.789ghi012jkl" + repository_selection = "selected" + selected_repositories = ["my-repo-1", "my-repo-2"] +} diff --git a/github/data_source_github_enterprise_app_installable_organizations.go b/github/data_source_github_enterprise_app_installable_organizations.go new file mode 100644 index 0000000000..7e9a6a46fb --- /dev/null +++ b/github/data_source_github_enterprise_app_installable_organizations.go @@ -0,0 +1,80 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseAppInstallableOrganizations() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseAppInstallableOrganizationsRead, + Description: "Use this data source to retrieve the enterprise-owned organizations that GitHub Apps can be installed on. " + + "This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner.", + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "organizations": { + Type: schema.TypeList, + Computed: true, + Description: "List of organizations in the enterprise that GitHub Apps can be installed on.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the organization.", + }, + "login": { + Type: schema.TypeString, + Computed: true, + Description: "The login of the organization.", + }, + "accessible_repositories_url": { + Type: schema.TypeString, + Computed: true, + Description: "The API URL listing the repositories that can be made accessible to a GitHub App installed on the organization.", + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseAppInstallableOrganizationsRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + + enterpriseSlug := d.Get("enterprise_slug").(string) + + opts := &github.ListOptions{ + PerPage: meta.maxPerPage, + } + + results := make([]map[string]any, 0) + for organization, err := range client.Enterprise.ListAppInstallableOrganizationsIter(ctx, enterpriseSlug, opts) { + if err != nil { + return diag.FromErr(err) + } + + results = append(results, map[string]any{ + "id": organization.ID, + "login": organization.Login, + "accessible_repositories_url": organization.GetAccessibleRepositoriesURL(), + }) + } + + d.SetId(enterpriseSlug) + if err := d.Set("organizations", results); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_enterprise_app_installable_organizations_test.go b/github/data_source_github_enterprise_app_installable_organizations_test.go new file mode 100644 index 0000000000..12555ce6bc --- /dev/null +++ b/github/data_source_github_enterprise_app_installable_organizations_test.go @@ -0,0 +1,43 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccDataSourceGithubEnterpriseAppInstallableOrganizations(t *testing.T) { + t.Parallel() + + skipUnlessEnterprise(t) + + t.Run("queries_installable_organizations", func(t *testing.T) { + t.Parallel() + + config := fmt.Sprintf(` +data "github_enterprise_app_installable_organizations" "test" { + enterprise_slug = "%s" +} +`, testAccConf.enterpriseSlug) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.github_enterprise_app_installable_organizations.test", tfjsonpath.New("organizations"), knownvalue.SetPartial([]knownvalue.Check{ + knownvalue.MapPartial(map[string]knownvalue.Check{ + "login": knownvalue.StringExact(testAccConf.owner), + }), + })), + }, + }, + }, + }) + }) +} diff --git a/github/data_source_github_enterprise_organization_app_accessible_repositories.go b/github/data_source_github_enterprise_organization_app_accessible_repositories.go new file mode 100644 index 0000000000..7faf10e452 --- /dev/null +++ b/github/data_source_github_enterprise_organization_app_accessible_repositories.go @@ -0,0 +1,86 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseOrganizationAppAccessibleRepositories() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseOrganizationAppAccessibleRepositoriesRead, + Description: "Use this data source to retrieve the repositories of an enterprise-owned organization that GitHub Apps can be granted access to. " + + "This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner.", + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise that owns the organization.", + }, + "organization": { + Type: schema.TypeString, + Required: true, + Description: "The login of the enterprise-owned organization.", + }, + "repositories": { + Type: schema.TypeList, + Computed: true, + Description: "List of repositories of the organization that GitHub Apps can be granted access to.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the repository.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the repository.", + }, + "full_name": { + Type: schema.TypeString, + Computed: true, + Description: "The full name of the repository, in the format `/`.", + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseOrganizationAppAccessibleRepositoriesRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + + enterpriseSlug := d.Get("enterprise_slug").(string) + org := d.Get("organization").(string) + + opts := &github.ListOptions{ + PerPage: meta.maxPerPage, + } + + results := make([]map[string]any, 0) + for repository, err := range client.Enterprise.ListAppAccessibleOrganizationRepositoriesIter(ctx, enterpriseSlug, org, opts) { + if err != nil { + return diag.FromErr(err) + } + + results = append(results, map[string]any{ + "id": repository.ID, + "name": repository.Name, + "full_name": repository.FullName, + }) + } + + d.SetId(buildTwoPartID(enterpriseSlug, org)) + if err := d.Set("repositories", results); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_enterprise_organization_app_accessible_repositories_test.go b/github/data_source_github_enterprise_organization_app_accessible_repositories_test.go new file mode 100644 index 0000000000..321e6cc819 --- /dev/null +++ b/github/data_source_github_enterprise_organization_app_accessible_repositories_test.go @@ -0,0 +1,55 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccDataSourceGithubEnterpriseOrganizationAppAccessibleRepositories(t *testing.T) { + t.Parallel() + + skipUnlessEnterprise(t) + + t.Run("queries_accessible_repositories", func(t *testing.T) { + t.Parallel() + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("tf-acc-test-eaaor-%s", randomID) + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" + auto_init = true +} + +data "github_enterprise_organization_app_accessible_repositories" "test" { + enterprise_slug = "%s" + organization = "%s" + + depends_on = [github_repository.test] +} +`, repoName, testAccConf.enterpriseSlug, testAccConf.owner) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.github_enterprise_organization_app_accessible_repositories.test", tfjsonpath.New("repositories"), knownvalue.SetPartial([]knownvalue.Check{ + knownvalue.MapPartial(map[string]knownvalue.Check{ + "name": knownvalue.StringExact(repoName), + }), + })), + }, + }, + }, + }) + }) +} diff --git a/github/data_source_github_enterprise_organization_app_installations.go b/github/data_source_github_enterprise_organization_app_installations.go new file mode 100644 index 0000000000..8cb8baeec6 --- /dev/null +++ b/github/data_source_github_enterprise_organization_app_installations.go @@ -0,0 +1,136 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseOrganizationAppInstallations() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseOrganizationAppInstallationsRead, + Description: "Use this data source to retrieve the GitHub App installations on an enterprise-owned organization. " + + "This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner.", + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise that owns the organization.", + }, + "organization": { + Type: schema.TypeString, + Required: true, + Description: "The login of the enterprise-owned organization.", + }, + "installations": { + Type: schema.TypeList, + Computed: true, + Description: "List of GitHub App installations on the organization.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the GitHub App installation.", + }, + "app_slug": { + Type: schema.TypeString, + Computed: true, + Description: "The URL-friendly name of the GitHub App.", + }, + "app_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the GitHub App.", + }, + "repository_selection": { + Type: schema.TypeString, + Computed: true, + Description: "Whether the installation has access to all repositories or only selected ones. Possible values are 'all' or 'selected'.", + }, + "permissions": { + Type: schema.TypeMap, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The permissions granted to the GitHub App installation.", + }, + "events": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The list of events the GitHub App installation subscribes to.", + }, + "client_id": { + Type: schema.TypeString, + Computed: true, + Description: "The OAuth client ID of the GitHub App.", + }, + "target_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the account the GitHub App is installed on.", + }, + "target_type": { + Type: schema.TypeString, + Computed: true, + Description: "The type of account the GitHub App is installed on. Possible values are 'Organization' or 'User'.", + }, + "suspended": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether the GitHub App installation is currently suspended.", + }, + "single_file_paths": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The list of single file paths the GitHub App installation has access to.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "The date the GitHub App installation was created.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "The date the GitHub App installation was last updated.", + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseOrganizationAppInstallationsRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + + enterpriseSlug := d.Get("enterprise_slug").(string) + org := d.Get("organization").(string) + + opts := &github.ListOptions{ + PerPage: meta.maxPerPage, + } + + installations := make([]*github.Installation, 0) + for installation, err := range client.Enterprise.ListAppInstallationsIter(ctx, enterpriseSlug, org, opts) { + if err != nil { + return diag.FromErr(err) + } + + installations = append(installations, installation) + } + results := flattenGitHubAppInstallations(installations) + + d.SetId(buildTwoPartID(enterpriseSlug, org)) + if err := d.Set("installations", results); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_enterprise_organization_app_installations_test.go b/github/data_source_github_enterprise_organization_app_installations_test.go new file mode 100644 index 0000000000..e7dfecfe5c --- /dev/null +++ b/github/data_source_github_enterprise_organization_app_installations_test.go @@ -0,0 +1,40 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccDataSourceGithubEnterpriseOrganizationAppInstallations(t *testing.T) { + t.Parallel() + + skipUnlessEnterprise(t) + + t.Run("queries_app_installations", func(t *testing.T) { + t.Parallel() + + config := fmt.Sprintf(` +data "github_enterprise_organization_app_installations" "test" { + enterprise_slug = "%s" + organization = "%s" +} +`, testAccConf.enterpriseSlug, testAccConf.owner) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.github_enterprise_organization_app_installations.test", tfjsonpath.New("installations"), knownvalue.NotNull()), + }, + }, + }, + }) + }) +} diff --git a/github/provider.go b/github/provider.go index 0bd2720b05..fb0f146855 100644 --- a/github/provider.go +++ b/github/provider.go @@ -240,6 +240,7 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_user_gpg_key": resourceGithubUserGpgKey(), "github_user_invitation_accepter": resourceGithubUserInvitationAccepter(), "github_user_ssh_key": resourceGithubUserSshKey(), + "github_enterprise_organization_app_installation": resourceGithubEnterpriseOrganizationAppInstallation(), "github_enterprise_organization": resourceGithubEnterpriseOrganization(), "github_enterprise_actions_runner_group": resourceGithubActionsEnterpriseRunnerGroup(), "github_enterprise_ip_allow_list_entry": resourceGithubEnterpriseIpAllowListEntry(), @@ -328,6 +329,9 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_user_external_identity": dataSourceGithubUserExternalIdentity(), "github_users": dataSourceGithubUsers(), "github_enterprise": dataSourceGithubEnterprise(), + "github_enterprise_organization_app_accessible_repositories": dataSourceGithubEnterpriseOrganizationAppAccessibleRepositories(), + "github_enterprise_app_installable_organizations": dataSourceGithubEnterpriseAppInstallableOrganizations(), + "github_enterprise_organization_app_installations": dataSourceGithubEnterpriseOrganizationAppInstallations(), "github_repository_environment_deployment_policies": dataSourceGithubRepositoryEnvironmentDeploymentPolicies(), }, diff --git a/github/resource_github_enterprise_organization_app_installation.go b/github/resource_github_enterprise_organization_app_installation.go new file mode 100644 index 0000000000..98230d1f61 --- /dev/null +++ b/github/resource_github_enterprise_organization_app_installation.go @@ -0,0 +1,375 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strconv" + + "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// maxInstallationRepositoriesPerRequest is the maximum number of repositories +// the enterprise organization-installations endpoints accept per request. +const maxInstallationRepositoriesPerRequest = 50 + +func resourceGithubEnterpriseOrganizationAppInstallation() *schema.Resource { + return &schema.Resource{ + Description: "Manage the installation of a GitHub App on an organization owned by an enterprise. " + + "The app is installed on the organization, not on the enterprise account itself; GitHub offers no API for the latter. " + + "This resource requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner.", + CreateContext: resourceGithubEnterpriseOrganizationAppInstallationCreate, + ReadContext: resourceGithubEnterpriseOrganizationAppInstallationRead, + UpdateContext: resourceGithubEnterpriseOrganizationAppInstallationUpdate, + DeleteContext: resourceGithubEnterpriseOrganizationAppInstallationDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise that owns the organization.", + }, + "organization": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The login of the enterprise-owned organization to install the app on.", + }, + "client_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The client ID of the GitHub App to install.", + }, + "repository_selection": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validateValueFunc([]string{"all", "selected", "none"}), + Description: "The repositories the installation can access. Can be one of 'all', 'selected' or 'none'.", + }, + "selected_repositories": { + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + Description: "The names of the repositories the installation can access when 'repository_selection' is 'selected'.", + }, + "installation_id": { + Type: schema.TypeString, + Computed: true, + Description: "The ID of the installation.", + }, + "app_slug": { + Type: schema.TypeString, + Computed: true, + Description: "The slug of the installed app.", + }, + }, + + CustomizeDiff: customdiff.All( + // The API can only toggle an existing installation between 'all' and + // 'selected'; transitions involving 'none' require a reinstall. An + // unknown new value could resolve to 'none' during apply, so it must + // also force replacement, or the expanded plan would disagree with + // the initial one and Terraform would abort the apply. + customdiff.ForceNewIf("repository_selection", func(ctx context.Context, d *schema.ResourceDiff, meta any) bool { + if !d.NewValueKnown("repository_selection") { + return true + } + oldValue, newValue := d.GetChange("repository_selection") + return oldValue.(string) == "none" || newValue.(string) == "none" + }), + func(ctx context.Context, d *schema.ResourceDiff, meta any) error { + // Unknown values read back as their zero value, which would + // fail this check for configurations that are still valid. + if !d.NewValueKnown("repository_selection") || !d.NewValueKnown("selected_repositories") { + return nil + } + + selection := d.Get("repository_selection").(string) + repoCount := d.Get("selected_repositories").(*schema.Set).Len() + if selection == "selected" && repoCount == 0 { + return fmt.Errorf("'selected_repositories' must be set when 'repository_selection' is 'selected'") + } + if selection != "selected" && repoCount > 0 { + return fmt.Errorf("'selected_repositories' can only be set when 'repository_selection' is 'selected'") + } + return nil + }, + ), + } +} + +func resourceGithubEnterpriseOrganizationAppInstallationCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug := d.Get("enterprise_slug").(string) + org := d.Get("organization").(string) + clientID := d.Get("client_id").(string) + + repositories := expandStringList(d.Get("selected_repositories").(*schema.Set).List()) + + // The install endpoint accepts at most maxInstallationRepositoriesPerRequest + // repositories; any remainder is granted with follow-up requests. + initial := repositories + var remainder []string + if len(repositories) > maxInstallationRepositoriesPerRequest { + initial = repositories[:maxInstallationRepositoriesPerRequest] + remainder = repositories[maxInstallationRepositoriesPerRequest:] + } + + req := github.InstallAppRequest{ + ClientID: clientID, + RepositorySelection: d.Get("repository_selection").(string), + Repositories: initial, + } + + tflog.Debug(ctx, "Installing app on enterprise-owned organization", map[string]any{ + "client_id": clientID, + "org": org, + "enterprise": enterpriseSlug, + }) + installation, _, err := client.Enterprise.InstallApp(ctx, enterpriseSlug, org, req) + if err != nil { + return diag.FromErr(err) + } + + // Record the installation in state as soon as the app is installed, so a + // failure while granting the remaining repositories leaves a recoverable + // resource rather than an orphaned installation. Delete resolves the + // installation through 'installation_id', so it has to be set here too. + id, err := buildID(enterpriseSlug, org, clientID) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + if err := d.Set("installation_id", strconv.FormatInt(installation.GetID(), 10)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("app_slug", installation.GetAppSlug()); err != nil { + return diag.FromErr(err) + } + + if err := addEnterpriseOrganizationAppInstallationRepositories(ctx, client, enterpriseSlug, org, installation.GetID(), remainder); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseOrganizationAppInstallationRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + owner := meta.(*Owner) + client := owner.v3client + + enterpriseSlug, org, clientID, err := parseID3(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + installation, err := findEnterpriseOrganizationAppInstallation(ctx, owner, enterpriseSlug, org, clientID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Removing enterprise app installation from state because it no longer exists in GitHub", map[string]any{ + "id": d.Id(), + }) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + if installation == nil { + tflog.Info(ctx, "Removing enterprise app installation from state because it no longer exists in GitHub", map[string]any{ + "id": d.Id(), + }) + d.SetId("") + return nil + } + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("organization", org); err != nil { + return diag.FromErr(err) + } + if err := d.Set("client_id", clientID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("repository_selection", installation.GetRepositorySelection()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("installation_id", strconv.FormatInt(installation.GetID(), 10)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("app_slug", installation.GetAppSlug()); err != nil { + return diag.FromErr(err) + } + + selectedRepositories := []string{} + if installation.GetRepositorySelection() == "selected" { + opts := &github.ListOptions{PerPage: owner.maxPerPage} + for repo, err := range client.Enterprise.ListRepositoriesForOrgAppInstallationIter(ctx, enterpriseSlug, org, installation.GetID(), opts) { + if err != nil { + return diag.FromErr(err) + } + selectedRepositories = append(selectedRepositories, repo.GetName()) + } + } + if err := d.Set("selected_repositories", flattenStringList(selectedRepositories)); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseOrganizationAppInstallationUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug, org, _, err := parseID3(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + installationID, err := strconv.ParseInt(d.Get("installation_id").(string), 10, 64) + if err != nil { + return diag.FromErr(unconvertibleIdErr(d.Get("installation_id").(string), err)) + } + + if d.HasChange("repository_selection") { + selection := d.Get("repository_selection").(string) + req := github.UpdateAppInstallationRepositoriesRequest{ + RepositorySelection: new(selection), + } + var remainder []string + if selection == "selected" { + repositories := expandStringList(d.Get("selected_repositories").(*schema.Set).List()) + // The toggle endpoint accepts at most + // maxInstallationRepositoriesPerRequest repositories; any remainder + // is granted with follow-up requests. + req.Repositories = repositories + if len(repositories) > maxInstallationRepositoriesPerRequest { + req.Repositories = repositories[:maxInstallationRepositoriesPerRequest] + remainder = repositories[maxInstallationRepositoriesPerRequest:] + } + } + + tflog.Debug(ctx, "Updating repository selection for enterprise app installation", map[string]any{ + "selection": selection, + "installation_id": installationID, + "org": org, + "enterprise": enterpriseSlug, + }) + _, _, err := client.Enterprise.UpdateAppInstallationRepositories(ctx, enterpriseSlug, org, installationID, req) + if err != nil { + return diag.FromErr(err) + } + + if err := addEnterpriseOrganizationAppInstallationRepositories(ctx, client, enterpriseSlug, org, installationID, remainder); err != nil { + return diag.FromErr(err) + } + } else if d.HasChange("selected_repositories") { + oldRepos, newRepos := d.GetChange("selected_repositories") + oldSet := oldRepos.(*schema.Set) + newSet := newRepos.(*schema.Set) + + // Add before removing so the installation never has an empty selection. + toAdd := expandStringList(newSet.Difference(oldSet).List()) + if err := addEnterpriseOrganizationAppInstallationRepositories(ctx, client, enterpriseSlug, org, installationID, toAdd); err != nil { + return diag.FromErr(err) + } + + toRemove := expandStringList(oldSet.Difference(newSet).List()) + for chunk := range slices.Chunk(toRemove, maxInstallationRepositoriesPerRequest) { + tflog.Debug(ctx, "Revoking enterprise app installation access to repositories", map[string]any{ + "installation_id": installationID, + "org": org, + "enterprise": enterpriseSlug, + "repositories": chunk, + }) + _, _, err := client.Enterprise.RemoveRepositoriesFromAppInstallation(ctx, enterpriseSlug, org, installationID, github.AppInstallationRepositoriesRequest{Repositories: chunk}) + if err != nil { + return diag.FromErr(err) + } + } + } + + return nil +} + +func resourceGithubEnterpriseOrganizationAppInstallationDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug, org, clientID, err := parseID3(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + installationID, err := strconv.ParseInt(d.Get("installation_id").(string), 10, 64) + if err != nil { + return diag.FromErr(unconvertibleIdErr(d.Get("installation_id").(string), err)) + } + + tflog.Debug(ctx, "Uninstalling app from enterprise-owned organization", map[string]any{ + "client_id": clientID, + "installation_id": installationID, + "org": org, + "enterprise": enterpriseSlug, + }) + _, err = client.Enterprise.UninstallApp(ctx, enterpriseSlug, org, installationID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + return nil + } + return diag.FromErr(err) + } + + return nil +} + +// addEnterpriseOrganizationAppInstallationRepositories grants an installation +// access to the given repositories, in chunks the API accepts. +func addEnterpriseOrganizationAppInstallationRepositories(ctx context.Context, client *github.Client, enterpriseSlug, org string, installationID int64, repositories []string) error { + for chunk := range slices.Chunk(repositories, maxInstallationRepositoriesPerRequest) { + tflog.Debug(ctx, "Granting enterprise app installation access to repositories", map[string]any{ + "installation_id": installationID, + "org": org, + "enterprise": enterpriseSlug, + "repositories": chunk, + }) + _, _, err := client.Enterprise.AddRepositoriesToAppInstallation(ctx, enterpriseSlug, org, installationID, github.AppInstallationRepositoriesRequest{Repositories: chunk}) + if err != nil { + return err + } + } + + return nil +} + +// findEnterpriseOrganizationAppInstallation returns the installation of the app +// with the given client ID on an enterprise-owned organization, or nil if the +// app is not installed. +func findEnterpriseOrganizationAppInstallation(ctx context.Context, owner *Owner, enterpriseSlug, org, clientID string) (*github.Installation, error) { + opts := &github.ListOptions{PerPage: owner.maxPerPage} + for installation, err := range owner.v3client.Enterprise.ListAppInstallationsIter(ctx, enterpriseSlug, org, opts) { + if err != nil { + return nil, err + } + if installation.GetClientID() == clientID { + return installation, nil + } + } + + return nil, nil +} diff --git a/github/resource_github_enterprise_organization_app_installation_test.go b/github/resource_github_enterprise_organization_app_installation_test.go new file mode 100644 index 0000000000..d2270db3a3 --- /dev/null +++ b/github/resource_github_enterprise_organization_app_installation_test.go @@ -0,0 +1,117 @@ +package github + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGithubEnterpriseOrganizationAppInstallation(t *testing.T) { + appClientID := os.Getenv("GH_TEST_ENTERPRISE_APP_CLIENT_ID") + skipUnlessEnterpriseAppClientID := func(t *testing.T) { + t.Helper() + if appClientID == "" { + t.Skip("Skipping because GH_TEST_ENTERPRISE_APP_CLIENT_ID is not set") + } + } + + t.Run("installs an app on all repositories", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_enterprise_organization_app_installation" "test" { + enterprise_slug = "%s" + organization = "%s" + client_id = "%s" + repository_selection = "all" + } + `, testAccConf.enterpriseSlug, testAccConf.owner, appClientID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + skipUnlessEnterprise(t) + skipUnlessEnterpriseAppClientID(t) + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("organization"), knownvalue.StringExact(testAccConf.owner)), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("client_id"), knownvalue.StringExact(appClientID)), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("all")), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("installation_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("app_slug"), knownvalue.NotNull()), + }, + }, + { + ResourceName: "github_enterprise_organization_app_installation.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("installs an app on selected repositories and toggles to all", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + configSelected := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-eai-%[4]s" + auto_init = true + } + + resource "github_enterprise_organization_app_installation" "test" { + enterprise_slug = "%[1]s" + organization = "%[2]s" + client_id = "%[3]s" + repository_selection = "selected" + selected_repositories = [github_repository.test.name] + } + `, testAccConf.enterpriseSlug, testAccConf.owner, appClientID, randomID) + + configAll := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-eai-%[4]s" + auto_init = true + } + + resource "github_enterprise_organization_app_installation" "test" { + enterprise_slug = "%[1]s" + organization = "%[2]s" + client_id = "%[3]s" + repository_selection = "all" + } + `, testAccConf.enterpriseSlug, testAccConf.owner, appClientID, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + skipUnlessEnterprise(t) + skipUnlessEnterpriseAppClientID(t) + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configSelected, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("selected")), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("selected_repositories"), knownvalue.SetSizeExact(1)), + }, + }, + { + Config: configAll, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("all")), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("selected_repositories"), knownvalue.SetSizeExact(0)), + }, + }, + }, + }) + }) +}