diff --git a/docs/data-sources/enterprise_app_accessible_organization_repositories.md b/docs/data-sources/enterprise_app_accessible_organization_repositories.md new file mode 100644 index 0000000000..df140abf1c --- /dev/null +++ b/docs/data-sources/enterprise_app_accessible_organization_repositories.md @@ -0,0 +1,36 @@ +--- +page_title: "github_enterprise_app_accessible_organization_repositories (Data Source) - GitHub" +description: |- + Get the repositories of an enterprise-owned organization that a GitHub App can be granted access to. +--- + +# github_enterprise_app_accessible_organization_repositories (Data Source) + +Use this data source to retrieve the repositories of an enterprise-owned organization +that a GitHub App can be granted access to. + +## Example Usage + +```terraform +data "github_enterprise_app_accessible_organization_repositories" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} +``` + +## Argument Reference + +- `enterprise_slug` - (Required) The slug of the enterprise. +- `organization` - (Required) The login of the enterprise-owned organization. + +## Attributes Reference + +- `repositories` - List of repositories. Each `repository` block consists of the fields documented below. + +--- + +The `repository` block consists of: + +- `id` - The ID of the repository. +- `name` - The name of the repository. +- `full_name` - The full name of the repository (`org/repo`). 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..915391b610 --- /dev/null +++ b/docs/data-sources/enterprise_app_installable_organizations.md @@ -0,0 +1,34 @@ +--- +page_title: "github_enterprise_app_installable_organizations (Data Source) - GitHub" +description: |- + Get the organizations in an enterprise that a GitHub App can be installed on. +--- + +# github_enterprise_app_installable_organizations (Data Source) + +Use this data source to retrieve the enterprise-owned organizations that a GitHub +App can be installed on. + +## Example Usage + +```terraform +data "github_enterprise_app_installable_organizations" "example" { + enterprise_slug = "my-enterprise" +} +``` + +## Argument Reference + +- `enterprise_slug` - (Required) The slug of the enterprise. + +## Attributes Reference + +- `organizations` - List of organizations. Each `organization` block consists of the fields documented below. + +--- + +The `organization` block consists of: + +- `id` - The ID of the organization. +- `login` - The login (slug) of the organization. +- `accessible_repositories_url` - The URL for the repositories the app can access on the organization. diff --git a/docs/data-sources/enterprise_app_installations.md b/docs/data-sources/enterprise_app_installations.md new file mode 100644 index 0000000000..c853028e37 --- /dev/null +++ b/docs/data-sources/enterprise_app_installations.md @@ -0,0 +1,46 @@ +--- +page_title: "github_enterprise_app_installations (Data Source) - GitHub" +description: |- + Get the GitHub App installations of an enterprise-owned organization. +--- + +# github_enterprise_app_installations (Data Source) + +Use this data source to retrieve the GitHub App installations on an enterprise-owned +organization. + +## Example Usage + +```terraform +data "github_enterprise_app_installations" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} +``` + +## Argument Reference + +- `enterprise_slug` - (Required) The slug of the enterprise that owns the organization. +- `organization` - (Required) The login of the enterprise-owned organization. + +## Attributes Reference + +- `installations` - List of GitHub App installations on the organization. Each `installation` block consists of the fields documented below. + +--- + +The `installation` block consists of: + +- `id` - The ID of the GitHub App installation. +- `app_id` - The ID of the GitHub App. +- `app_slug` - The URL-friendly name of the GitHub App. +- `client_id` - The OAuth client ID of the GitHub App. +- `target_id` - The ID of the account the GitHub App is installed on. +- `target_type` - The type of account the GitHub App is installed on. +- `repository_selection` - Whether the installation has access to `all` repositories or only `selected` ones. +- `permissions` - A map of the permissions granted to the GitHub App installation. +- `events` - The list of events the GitHub App installation subscribes to. +- `suspended` - Whether the GitHub App installation is currently suspended. +- `single_file_paths` - The list of single file paths the GitHub App installation has access to. +- `created_at` - The date the GitHub App installation was created. +- `updated_at` - The date the GitHub App installation was last updated. diff --git a/docs/resources/enterprise_app_installation.md b/docs/resources/enterprise_app_installation.md new file mode 100644 index 0000000000..257ceda908 --- /dev/null +++ b/docs/resources/enterprise_app_installation.md @@ -0,0 +1,51 @@ +--- +page_title: "github_enterprise_app_installation (Resource) - GitHub" +description: |- + Install and manage a GitHub App on an enterprise-owned organization. +--- + +# github_enterprise_app_installation (Resource) + +This resource installs a GitHub App on an enterprise-owned organization and manages +the installation's repository access. Deleting the resource uninstalls the app from +the organization. + +The token used by the provider must have permission to administer the enterprise, +and the organization must be owned by the enterprise. + +## Example Usage + +```terraform +resource "github_enterprise_app_installation" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" + client_id = "Iv23liABCDEFGH012345" + repository_selection = "selected" + repositories = ["repo-a", "repo-b"] +} +``` + +## Argument Reference + +- `enterprise_slug` - (Required, ForceNew) The slug of the enterprise that owns the organization. +- `organization` - (Required, ForceNew) The login of the enterprise-owned organization to install the app on. +- `client_id` - (Required, ForceNew) The Client ID of the GitHub App to install. +- `repository_selection` - (Required) Which repositories the app can access. One of `all`, `selected`, or `none`. +- `repositories` - (Optional) Repository names the installation should have access to. Only used when `repository_selection` is `selected`. + +## Attributes Reference + +The following additional attributes are exported: + +- `installation_id` - The ID of the GitHub App installation. +- `app_id` - The ID of the installed GitHub App. +- `app_slug` - The URL-friendly name of the GitHub App. + +## Import + +Enterprise App Installations can be imported using a composite ID of +`::`: + +```shell +terraform import github_enterprise_app_installation.example my-enterprise:my-org:12345678 +``` diff --git a/docs/resources/enterprise_app_installation_repositories.md b/docs/resources/enterprise_app_installation_repositories.md new file mode 100644 index 0000000000..93916cc510 --- /dev/null +++ b/docs/resources/enterprise_app_installation_repositories.md @@ -0,0 +1,47 @@ +--- +page_title: "github_enterprise_app_installation_repositories (Resource) - GitHub" +description: |- + Manage the repositories an enterprise-owned organization's GitHub App installation can access. +--- + +# github_enterprise_app_installation_repositories (Resource) + +This resource manages the set of repositories accessible to a GitHub App installation +on an enterprise-owned organization. It only applies when the installation's +`repository_selection` is `selected`. + +Use [`github_enterprise_app_installation`](enterprise_app_installation.md) to control +installation lifecycle and `repository_selection`; use this resource to drift-detect +and reconcile the repository list. + +## Example Usage + +```terraform +resource "github_enterprise_app_installation_repositories" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" + installation_id = "12345678" + selected_repositories = ["repo-a", "repo-b"] +} +``` + +## Argument Reference + +- `enterprise_slug` - (Required, ForceNew) The slug of the enterprise that owns the organization. +- `organization` - (Required, ForceNew) The login of the enterprise-owned organization the app is installed on. +- `installation_id` - (Required, ForceNew) The ID of the GitHub App installation. +- `selected_repositories` - (Required) The set of repository names the installation should have access to. + +~> **Note**: Deleting this resource removes every repository currently selected for +the installation, which leaves the installation with no accessible repositories. +Either uninstall the app via the parent `github_enterprise_app_installation` resource +or switch the installation's `repository_selection` to `all` instead. + +## Import + +Enterprise App Installation Repositories can be imported using a composite ID of +`::`: + +```shell +terraform import github_enterprise_app_installation_repositories.example my-enterprise:my-org:12345678 +``` diff --git a/examples/data-sources/enterprise_app_accessible_organization_repositories/example_1.tf b/examples/data-sources/enterprise_app_accessible_organization_repositories/example_1.tf new file mode 100644 index 0000000000..29e387abf7 --- /dev/null +++ b/examples/data-sources/enterprise_app_accessible_organization_repositories/example_1.tf @@ -0,0 +1,4 @@ +data "github_enterprise_app_accessible_organization_repositories" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} diff --git a/examples/data-sources/enterprise_app_installable_organizations/example_1.tf b/examples/data-sources/enterprise_app_installable_organizations/example_1.tf new file mode 100644 index 0000000000..51fc4906fd --- /dev/null +++ b/examples/data-sources/enterprise_app_installable_organizations/example_1.tf @@ -0,0 +1,3 @@ +data "github_enterprise_app_installable_organizations" "example" { + enterprise_slug = "my-enterprise" +} diff --git a/examples/data-sources/enterprise_app_installations/example_1.tf b/examples/data-sources/enterprise_app_installations/example_1.tf new file mode 100644 index 0000000000..5879719cd6 --- /dev/null +++ b/examples/data-sources/enterprise_app_installations/example_1.tf @@ -0,0 +1,4 @@ +data "github_enterprise_app_installations" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} diff --git a/examples/resources/enterprise_app_installation/example_1.tf b/examples/resources/enterprise_app_installation/example_1.tf new file mode 100644 index 0000000000..503c5cf71a --- /dev/null +++ b/examples/resources/enterprise_app_installation/example_1.tf @@ -0,0 +1,7 @@ +resource "github_enterprise_app_installation" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" + client_id = "Iv23liABCDEFGH012345" + repository_selection = "selected" + repositories = ["repo-a", "repo-b"] +} diff --git a/examples/resources/enterprise_app_installation_repositories/example_1.tf b/examples/resources/enterprise_app_installation_repositories/example_1.tf new file mode 100644 index 0000000000..20ee8a8143 --- /dev/null +++ b/examples/resources/enterprise_app_installation_repositories/example_1.tf @@ -0,0 +1,6 @@ +resource "github_enterprise_app_installation_repositories" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" + installation_id = "12345678" + selected_repositories = ["repo-a", "repo-b"] +} diff --git a/github/data_source_github_enterprise_app_accessible_organization_repositories.go b/github/data_source_github_enterprise_app_accessible_organization_repositories.go new file mode 100644 index 0000000000..822ede7391 --- /dev/null +++ b/github/data_source_github_enterprise_app_accessible_organization_repositories.go @@ -0,0 +1,83 @@ +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 dataSourceGithubEnterpriseAppAccessibleOrganizationRepositories() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseAppAccessibleOrganizationRepositoriesRead, + Description: "Use this data source to retrieve repositories of an enterprise-owned organization that a GitHub App can be granted access to.", + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "organization": { + Type: schema.TypeString, + Required: true, + Description: "The login of the enterprise-owned organization.", + }, + "repositories": { + Type: schema.TypeList, + Computed: true, + Description: "Repositories of the organization a GitHub App can access.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Computed: true, + }, + "name": { + Type: schema.TypeString, + Computed: true, + }, + "full_name": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseAppAccessibleOrganizationRepositoriesRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + enterprise, _ := d.Get("enterprise_slug").(string) + org, _ := d.Get("organization").(string) + + opts := &github.ListOptions{PerPage: meta.maxPerPage} + results := make([]map[string]any, 0) + for { + repos, resp, err := client.Enterprise.ListAppAccessibleOrganizationRepositories(ctx, enterprise, org, opts) + if err != nil { + return diag.FromErr(err) + } + for _, r := range repos { + results = append(results, map[string]any{ + "id": r.ID, + "name": r.Name, + "full_name": r.FullName, + }) + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + d.SetId(buildTwoPartID(enterprise, org)) + if err := d.Set("repositories", results); err != nil { + return diag.FromErr(err) + } + return nil +} 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..bee291ebb7 --- /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 organizations in an enterprise that a GitHub App can be installed on.", + + 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: "Organizations in the enterprise that can have a GitHub App installed on them.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Computed: true, + }, + "login": { + Type: schema.TypeString, + Computed: true, + }, + "accessible_repositories_url": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseAppInstallableOrganizationsRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + enterprise, _ := d.Get("enterprise_slug").(string) + + opts := &github.ListOptions{PerPage: meta.maxPerPage} + results := make([]map[string]any, 0) + for { + orgs, resp, err := client.Enterprise.ListAppInstallableOrganizations(ctx, enterprise, opts) + if err != nil { + return diag.FromErr(err) + } + for _, o := range orgs { + entry := map[string]any{ + "id": o.ID, + "login": o.Login, + } + if o.AccessibleRepositoriesURL != nil { + entry["accessible_repositories_url"] = *o.AccessibleRepositoriesURL + } + results = append(results, entry) + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + d.SetId(enterprise) + if err := d.Set("organizations", results); err != nil { + return diag.FromErr(err) + } + return nil +} diff --git a/github/data_source_github_enterprise_app_installations.go b/github/data_source_github_enterprise_app_installations.go new file mode 100644 index 0000000000..8b7b2aa443 --- /dev/null +++ b/github/data_source_github_enterprise_app_installations.go @@ -0,0 +1,120 @@ +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 dataSourceGithubEnterpriseAppInstallations() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseAppInstallationsRead, + Description: "Use this data source to retrieve the GitHub App installations on an enterprise-owned organization.", + + 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, + }, + "app_id": { + Type: schema.TypeInt, + Computed: true, + }, + "app_slug": { + Type: schema.TypeString, + Computed: true, + }, + "client_id": { + Type: schema.TypeString, + Computed: true, + }, + "target_id": { + Type: schema.TypeInt, + Computed: true, + }, + "target_type": { + Type: schema.TypeString, + Computed: true, + }, + "repository_selection": { + Type: schema.TypeString, + Computed: true, + }, + "permissions": { + Type: schema.TypeMap, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "events": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "suspended": { + Type: schema.TypeBool, + Computed: true, + }, + "single_file_paths": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseAppInstallationsRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + enterprise, _ := d.Get("enterprise_slug").(string) + org, _ := d.Get("organization").(string) + + opts := &github.ListOptions{PerPage: meta.maxPerPage} + results := make([]map[string]any, 0) + for { + installations, resp, err := client.Enterprise.ListAppInstallations(ctx, enterprise, org, opts) + if err != nil { + return diag.FromErr(err) + } + results = append(results, flattenGitHubAppInstallations(installations)...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + d.SetId(buildTwoPartID(enterprise, org)) + if err := d.Set("installations", results); err != nil { + return diag.FromErr(err) + } + return nil +} diff --git a/github/provider.go b/github/provider.go index 0bd2720b05..3107e94204 100644 --- a/github/provider.go +++ b/github/provider.go @@ -242,6 +242,8 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_user_ssh_key": resourceGithubUserSshKey(), "github_enterprise_organization": resourceGithubEnterpriseOrganization(), "github_enterprise_actions_runner_group": resourceGithubActionsEnterpriseRunnerGroup(), + "github_enterprise_app_installation": resourceGithubEnterpriseAppInstallation(), + "github_enterprise_app_installation_repositories": resourceGithubEnterpriseAppInstallationRepositories(), "github_enterprise_ip_allow_list_entry": resourceGithubEnterpriseIpAllowListEntry(), "github_enterprise_actions_workflow_permissions": resourceGithubEnterpriseActionsWorkflowPermissions(), "github_actions_organization_workflow_permissions": resourceGithubActionsOrganizationWorkflowPermissions(), @@ -328,6 +330,9 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_user_external_identity": dataSourceGithubUserExternalIdentity(), "github_users": dataSourceGithubUsers(), "github_enterprise": dataSourceGithubEnterprise(), + "github_enterprise_app_installations": dataSourceGithubEnterpriseAppInstallations(), + "github_enterprise_app_installable_organizations": dataSourceGithubEnterpriseAppInstallableOrganizations(), + "github_enterprise_app_accessible_organization_repositories": dataSourceGithubEnterpriseAppAccessibleOrganizationRepositories(), "github_repository_environment_deployment_policies": dataSourceGithubRepositoryEnvironmentDeploymentPolicies(), }, diff --git a/github/resource_github_enterprise_app_installation.go b/github/resource_github_enterprise_app_installation.go new file mode 100644 index 0000000000..e3b9c20919 --- /dev/null +++ b/github/resource_github_enterprise_app_installation.go @@ -0,0 +1,289 @@ +package github + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strconv" + + "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceGithubEnterpriseAppInstallation() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubEnterpriseAppInstallationCreate, + Read: resourceGithubEnterpriseAppInstallationRead, + Update: resourceGithubEnterpriseAppInstallationUpdate, + Delete: resourceGithubEnterpriseAppInstallationDelete, + Importer: &schema.ResourceImporter{ + State: resourceGithubEnterpriseAppInstallationImport, + }, + + 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, + Description: "Which repositories the app can access. One of 'all', 'selected', or 'none'.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"all", "selected", "none"}, false)), + }, + "repositories": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + Description: "Repository names the installation should have access to. Only used when repository_selection is 'selected'.", + }, + "installation_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the GitHub App installation.", + }, + "app_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the GitHub App.", + }, + "app_slug": { + Type: schema.TypeString, + Computed: true, + Description: "The URL-friendly name of the GitHub App.", + }, + }, + } +} + +func resourceGithubEnterpriseAppInstallationCreate(d *schema.ResourceData, m any) error { + meta, _ := m.(*Owner) + client := meta.v3client + ctx := context.Background() + + enterprise, _ := d.Get("enterprise_slug").(string) + org, _ := d.Get("organization").(string) + selection, _ := d.Get("repository_selection").(string) + clientID, _ := d.Get("client_id").(string) + + req := github.InstallAppRequest{ + ClientID: clientID, + RepositorySelection: selection, + } + if selection == "selected" { + repositories, _ := d.Get("repositories").(*schema.Set) + req.Repositories = expandStringList(repositories.List()) + } + + installation, _, err := client.Enterprise.InstallApp(ctx, enterprise, org, req) + if err != nil { + return fmt.Errorf("error installing GitHub App on %s/%s: %w", enterprise, org, err) + } + + d.SetId(buildThreePartID(enterprise, org, strconv.FormatInt(installation.GetID(), 10))) + return resourceGithubEnterpriseAppInstallationRead(d, meta) +} + +func resourceGithubEnterpriseAppInstallationRead(d *schema.ResourceData, m any) error { + meta, _ := m.(*Owner) + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + enterprise, org, idStr, err := parseID3(d.Id()) + if err != nil { + return err + } + installationID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return unconvertibleIdErr(idStr, err) + } + + installation, err := findEnterpriseAppInstallation(ctx, meta, enterprise, org, installationID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing enterprise app installation %s from state because it no longer exists in GitHub", d.Id()) + d.SetId("") + return nil + } + return err + } + if installation == nil { + log.Printf("[INFO] Removing enterprise app installation %s from state because it no longer exists in GitHub", d.Id()) + d.SetId("") + return nil + } + + if err = d.Set("enterprise_slug", enterprise); err != nil { + return err + } + if err = d.Set("organization", org); err != nil { + return err + } + if err = d.Set("installation_id", installation.GetID()); err != nil { + return err + } + if err = d.Set("app_id", installation.GetAppID()); err != nil { + return err + } + if err = d.Set("app_slug", installation.GetAppSlug()); err != nil { + return err + } + if v := installation.GetRepositorySelection(); v != "" { + if err = d.Set("repository_selection", v); err != nil { + return err + } + } + + if installation.GetRepositorySelection() == "selected" { + repos, err := listEnterpriseAppInstallationRepositories(ctx, meta, enterprise, org, installationID) + if err != nil { + return err + } + names := make([]string, 0, len(repos)) + for _, r := range repos { + names = append(names, r.Name) + } + if err = d.Set("repositories", names); err != nil { + return err + } + } else { + if err = d.Set("repositories", []string{}); err != nil { + return err + } + } + + return nil +} + +func resourceGithubEnterpriseAppInstallationUpdate(d *schema.ResourceData, m any) error { + meta, _ := m.(*Owner) + client := meta.v3client + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + enterprise, org, idStr, err := parseID3(d.Id()) + if err != nil { + return err + } + installationID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return unconvertibleIdErr(idStr, err) + } + + selection, _ := d.Get("repository_selection").(string) + + if d.HasChange("repository_selection") { + opts := github.UpdateAppInstallationRepositoriesRequest{ + RepositorySelection: &selection, + } + if selection == "selected" { + repositories, _ := d.Get("repositories").(*schema.Set) + opts.Repositories = expandStringList(repositories.List()) + } + if _, _, err := client.Enterprise.UpdateAppInstallationRepositories(ctx, enterprise, org, installationID, opts); err != nil { + return fmt.Errorf("error updating repository_selection for installation %d: %w", installationID, err) + } + } else if selection == "selected" && d.HasChange("repositories") { + oldSet, newSet := setChanges(d.GetChange("repositories")) + + toAdd := expandStringList(newSet.Difference(oldSet).List()) + toRemove := expandStringList(oldSet.Difference(newSet).List()) + + if len(toAdd) > 0 { + if _, _, err := client.Enterprise.AddRepositoriesToAppInstallation(ctx, enterprise, org, installationID, github.AppInstallationRepositoriesRequest{Repositories: toAdd}); err != nil { + return fmt.Errorf("error adding repositories to installation %d: %w", installationID, err) + } + } + if len(toRemove) > 0 { + if _, _, err := client.Enterprise.RemoveRepositoriesFromAppInstallation(ctx, enterprise, org, installationID, github.AppInstallationRepositoriesRequest{Repositories: toRemove}); err != nil { + return fmt.Errorf("error removing repositories from installation %d: %w", installationID, err) + } + } + } + + return resourceGithubEnterpriseAppInstallationRead(d, meta) +} + +func resourceGithubEnterpriseAppInstallationDelete(d *schema.ResourceData, m any) error { + meta, _ := m.(*Owner) + client := meta.v3client + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + enterprise, org, idStr, err := parseID3(d.Id()) + if err != nil { + return err + } + installationID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return unconvertibleIdErr(idStr, err) + } + + _, err = client.Enterprise.UninstallApp(ctx, enterprise, org, installationID) + return err +} + +func resourceGithubEnterpriseAppInstallationImport(d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + if _, _, _, err := parseID3(d.Id()); err != nil { + return nil, fmt.Errorf("invalid ID specified: supplied ID must be written as ::") + } + if err := resourceGithubEnterpriseAppInstallationRead(d, m); err != nil { + return nil, err + } + return []*schema.ResourceData{d}, nil +} + +// findEnterpriseAppInstallation walks the enterprise app installations on an organization +// to locate the installation matching the given ID. The REST API does not provide a direct +// GET endpoint for a single enterprise-owned org installation, so a list-and-filter is used. +func findEnterpriseAppInstallation(ctx context.Context, meta *Owner, enterprise, org string, installationID int64) (*github.Installation, error) { + opts := &github.ListOptions{PerPage: meta.maxPerPage} + for { + installations, resp, err := meta.v3client.Enterprise.ListAppInstallations(ctx, enterprise, org, opts) + if err != nil { + return nil, err + } + for _, inst := range installations { + if inst.GetID() == installationID { + return inst, nil + } + } + if resp.NextPage == 0 { + return nil, nil + } + opts.Page = resp.NextPage + } +} + +func listEnterpriseAppInstallationRepositories(ctx context.Context, meta *Owner, enterprise, org string, installationID int64) ([]*github.AccessibleRepository, error) { + var all []*github.AccessibleRepository + opts := &github.ListOptions{PerPage: meta.maxPerPage} + for { + repos, resp, err := meta.v3client.Enterprise.ListRepositoriesForOrgAppInstallation(ctx, enterprise, org, installationID, opts) + if err != nil { + return nil, err + } + all = append(all, repos...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return all, nil +} diff --git a/github/resource_github_enterprise_app_installation_repositories.go b/github/resource_github_enterprise_app_installation_repositories.go new file mode 100644 index 0000000000..a0bc625fef --- /dev/null +++ b/github/resource_github_enterprise_app_installation_repositories.go @@ -0,0 +1,202 @@ +package github + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strconv" + + "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseAppInstallationRepositories() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubEnterpriseAppInstallationRepositoriesCreateOrUpdate, + Read: resourceGithubEnterpriseAppInstallationRepositoriesRead, + Update: resourceGithubEnterpriseAppInstallationRepositoriesCreateOrUpdate, + Delete: resourceGithubEnterpriseAppInstallationRepositoriesDelete, + Importer: &schema.ResourceImporter{ + State: resourceGithubEnterpriseAppInstallationRepositoriesImport, + }, + + 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 the app is installed on.", + }, + "installation_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The ID of the GitHub App installation.", + }, + "selected_repositories": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + Description: "A set of repository names the installation should have access to.", + }, + }, + } +} + +func resourceGithubEnterpriseAppInstallationRepositoriesCreateOrUpdate(d *schema.ResourceData, m any) error { + meta, _ := m.(*Owner) + client := meta.v3client + ctx := context.Background() + + enterprise, _ := d.Get("enterprise_slug").(string) + org, _ := d.Get("organization").(string) + installationIDString, _ := d.Get("installation_id").(string) + installationID, err := strconv.ParseInt(installationIDString, 10, 64) + if err != nil { + return unconvertibleIdErr(installationIDString, err) + } + + selectedRepositories, _ := d.Get("selected_repositories").(*schema.Set) + desired := stringSetFromAny(selectedRepositories.List()) + + current, err := listEnterpriseAppInstallationRepositories(ctx, meta, enterprise, org, installationID) + if err != nil { + return err + } + currentSet := make(map[string]struct{}, len(current)) + for _, r := range current { + currentSet[r.Name] = struct{}{} + } + + var toAdd, toRemove []string + for name := range desired { + if _, ok := currentSet[name]; !ok { + toAdd = append(toAdd, name) + } + } + for name := range currentSet { + if _, ok := desired[name]; !ok { + toRemove = append(toRemove, name) + } + } + + if len(toAdd) > 0 { + log.Printf("[DEBUG] Adding %d repositories to enterprise app installation %d", len(toAdd), installationID) + if _, _, err := client.Enterprise.AddRepositoriesToAppInstallation(ctx, enterprise, org, installationID, github.AppInstallationRepositoriesRequest{Repositories: toAdd}); err != nil { + return fmt.Errorf("error adding repositories to enterprise app installation %d: %w", installationID, err) + } + } + if len(toRemove) > 0 { + log.Printf("[DEBUG] Removing %d repositories from enterprise app installation %d", len(toRemove), installationID) + if _, _, err := client.Enterprise.RemoveRepositoriesFromAppInstallation(ctx, enterprise, org, installationID, github.AppInstallationRepositoriesRequest{Repositories: toRemove}); err != nil { + return fmt.Errorf("error removing repositories from enterprise app installation %d: %w", installationID, err) + } + } + + d.SetId(buildThreePartID(enterprise, org, installationIDString)) + return resourceGithubEnterpriseAppInstallationRepositoriesRead(d, meta) +} + +func resourceGithubEnterpriseAppInstallationRepositoriesRead(d *schema.ResourceData, m any) error { + meta, _ := m.(*Owner) + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + enterprise, org, installationIDString, err := parseID3(d.Id()) + if err != nil { + return err + } + installationID, err := strconv.ParseInt(installationIDString, 10, 64) + if err != nil { + return unconvertibleIdErr(installationIDString, err) + } + + repos, err := listEnterpriseAppInstallationRepositories(ctx, meta, enterprise, org, installationID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing enterprise app installation repositories %s from state because the installation no longer exists", d.Id()) + d.SetId("") + return nil + } + return err + } + + names := make([]string, 0, len(repos)) + for _, r := range repos { + names = append(names, r.Name) + } + + if err = d.Set("enterprise_slug", enterprise); err != nil { + return err + } + if err = d.Set("organization", org); err != nil { + return err + } + if err = d.Set("installation_id", installationIDString); err != nil { + return err + } + if err = d.Set("selected_repositories", names); err != nil { + return err + } + return nil +} + +func resourceGithubEnterpriseAppInstallationRepositoriesDelete(d *schema.ResourceData, m any) error { + meta, _ := m.(*Owner) + client := meta.v3client + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + enterprise, org, installationIDString, err := parseID3(d.Id()) + if err != nil { + return err + } + installationID, err := strconv.ParseInt(installationIDString, 10, 64) + if err != nil { + return unconvertibleIdErr(installationIDString, err) + } + + current, err := listEnterpriseAppInstallationRepositories(ctx, meta, enterprise, org, installationID) + if err != nil { + return err + } + if len(current) == 0 { + return nil + } + + names := make([]string, 0, len(current)) + for _, r := range current { + names = append(names, r.Name) + } + + _, _, err = client.Enterprise.RemoveRepositoriesFromAppInstallation(ctx, enterprise, org, installationID, github.AppInstallationRepositoriesRequest{Repositories: names}) + return err +} + +func resourceGithubEnterpriseAppInstallationRepositoriesImport(d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + if _, _, _, err := parseID3(d.Id()); err != nil { + return nil, fmt.Errorf("invalid ID specified: supplied ID must be written as ::") + } + if err := resourceGithubEnterpriseAppInstallationRepositoriesRead(d, m); err != nil { + return nil, err + } + return []*schema.ResourceData{d}, nil +} + +func stringSetFromAny(vs []any) map[string]struct{} { + out := make(map[string]struct{}, len(vs)) + for _, v := range vs { + if s, ok := v.(string); ok && s != "" { + out[s] = struct{}{} + } + } + return out +} diff --git a/templates/data-sources/enterprise_app_accessible_organization_repositories.md.tmpl b/templates/data-sources/enterprise_app_accessible_organization_repositories.md.tmpl new file mode 100644 index 0000000000..4f2f404673 --- /dev/null +++ b/templates/data-sources/enterprise_app_accessible_organization_repositories.md.tmpl @@ -0,0 +1,31 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Get the repositories of an enterprise-owned organization that a GitHub App can be granted access to. +--- + +# {{.Name}} ({{.Type}}) + +Use this data source to retrieve the repositories of an enterprise-owned organization +that a GitHub App can be granted access to. + +## Example Usage + +{{ tffile "examples/data-sources/enterprise_app_accessible_organization_repositories/example_1.tf" }} + +## Argument Reference + +- `enterprise_slug` - (Required) The slug of the enterprise. +- `organization` - (Required) The login of the enterprise-owned organization. + +## Attributes Reference + +- `repositories` - List of repositories. Each `repository` block consists of the fields documented below. + +--- + +The `repository` block consists of: + +- `id` - The ID of the repository. +- `name` - The name of the repository. +- `full_name` - The full name of the repository (`org/repo`). diff --git a/templates/data-sources/enterprise_app_installable_organizations.md.tmpl b/templates/data-sources/enterprise_app_installable_organizations.md.tmpl new file mode 100644 index 0000000000..3ad8756f2d --- /dev/null +++ b/templates/data-sources/enterprise_app_installable_organizations.md.tmpl @@ -0,0 +1,30 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Get the organizations in an enterprise that a GitHub App can be installed on. +--- + +# {{.Name}} ({{.Type}}) + +Use this data source to retrieve the enterprise-owned organizations that a GitHub +App can be installed on. + +## Example Usage + +{{ tffile "examples/data-sources/enterprise_app_installable_organizations/example_1.tf" }} + +## Argument Reference + +- `enterprise_slug` - (Required) The slug of the enterprise. + +## Attributes Reference + +- `organizations` - List of organizations. Each `organization` block consists of the fields documented below. + +--- + +The `organization` block consists of: + +- `id` - The ID of the organization. +- `login` - The login (slug) of the organization. +- `accessible_repositories_url` - The URL for the repositories the app can access on the organization. diff --git a/templates/data-sources/enterprise_app_installations.md.tmpl b/templates/data-sources/enterprise_app_installations.md.tmpl new file mode 100644 index 0000000000..5e29706a38 --- /dev/null +++ b/templates/data-sources/enterprise_app_installations.md.tmpl @@ -0,0 +1,41 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Get the GitHub App installations of an enterprise-owned organization. +--- + +# {{.Name}} ({{.Type}}) + +Use this data source to retrieve the GitHub App installations on an enterprise-owned +organization. + +## Example Usage + +{{ tffile "examples/data-sources/enterprise_app_installations/example_1.tf" }} + +## Argument Reference + +- `enterprise_slug` - (Required) The slug of the enterprise that owns the organization. +- `organization` - (Required) The login of the enterprise-owned organization. + +## Attributes Reference + +- `installations` - List of GitHub App installations on the organization. Each `installation` block consists of the fields documented below. + +--- + +The `installation` block consists of: + +- `id` - The ID of the GitHub App installation. +- `app_id` - The ID of the GitHub App. +- `app_slug` - The URL-friendly name of the GitHub App. +- `client_id` - The OAuth client ID of the GitHub App. +- `target_id` - The ID of the account the GitHub App is installed on. +- `target_type` - The type of account the GitHub App is installed on. +- `repository_selection` - Whether the installation has access to `all` repositories or only `selected` ones. +- `permissions` - A map of the permissions granted to the GitHub App installation. +- `events` - The list of events the GitHub App installation subscribes to. +- `suspended` - Whether the GitHub App installation is currently suspended. +- `single_file_paths` - The list of single file paths the GitHub App installation has access to. +- `created_at` - The date the GitHub App installation was created. +- `updated_at` - The date the GitHub App installation was last updated. diff --git a/templates/resources/enterprise_app_installation.md.tmpl b/templates/resources/enterprise_app_installation.md.tmpl new file mode 100644 index 0000000000..3b704346ad --- /dev/null +++ b/templates/resources/enterprise_app_installation.md.tmpl @@ -0,0 +1,43 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Install and manage a GitHub App on an enterprise-owned organization. +--- + +# {{.Name}} ({{.Type}}) + +This resource installs a GitHub App on an enterprise-owned organization and manages +the installation's repository access. Deleting the resource uninstalls the app from +the organization. + +The token used by the provider must have permission to administer the enterprise, +and the organization must be owned by the enterprise. + +## Example Usage + +{{ tffile "examples/resources/enterprise_app_installation/example_1.tf" }} + +## Argument Reference + +- `enterprise_slug` - (Required, ForceNew) The slug of the enterprise that owns the organization. +- `organization` - (Required, ForceNew) The login of the enterprise-owned organization to install the app on. +- `client_id` - (Required, ForceNew) The Client ID of the GitHub App to install. +- `repository_selection` - (Required) Which repositories the app can access. One of `all`, `selected`, or `none`. +- `repositories` - (Optional) Repository names the installation should have access to. Only used when `repository_selection` is `selected`. + +## Attributes Reference + +The following additional attributes are exported: + +- `installation_id` - The ID of the GitHub App installation. +- `app_id` - The ID of the installed GitHub App. +- `app_slug` - The URL-friendly name of the GitHub App. + +## Import + +Enterprise App Installations can be imported using a composite ID of +`::`: + +```shell +terraform import github_enterprise_app_installation.example my-enterprise:my-org:12345678 +``` diff --git a/templates/resources/enterprise_app_installation_repositories.md.tmpl b/templates/resources/enterprise_app_installation_repositories.md.tmpl new file mode 100644 index 0000000000..2fc4207978 --- /dev/null +++ b/templates/resources/enterprise_app_installation_repositories.md.tmpl @@ -0,0 +1,40 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Manage the repositories an enterprise-owned organization's GitHub App installation can access. +--- + +# {{.Name}} ({{.Type}}) + +This resource manages the set of repositories accessible to a GitHub App installation +on an enterprise-owned organization. It only applies when the installation's +`repository_selection` is `selected`. + +Use [`github_enterprise_app_installation`](enterprise_app_installation.md) to control +installation lifecycle and `repository_selection`; use this resource to drift-detect +and reconcile the repository list. + +## Example Usage + +{{ tffile "examples/resources/enterprise_app_installation_repositories/example_1.tf" }} + +## Argument Reference + +- `enterprise_slug` - (Required, ForceNew) The slug of the enterprise that owns the organization. +- `organization` - (Required, ForceNew) The login of the enterprise-owned organization the app is installed on. +- `installation_id` - (Required, ForceNew) The ID of the GitHub App installation. +- `selected_repositories` - (Required) The set of repository names the installation should have access to. + +~> **Note**: Deleting this resource removes every repository currently selected for +the installation, which leaves the installation with no accessible repositories. +Either uninstall the app via the parent `github_enterprise_app_installation` resource +or switch the installation's `repository_selection` to `all` instead. + +## Import + +Enterprise App Installation Repositories can be imported using a composite ID of +`::`: + +```shell +terraform import github_enterprise_app_installation_repositories.example my-enterprise:my-org:12345678 +```