diff --git a/github/provider.go b/github/provider.go index d9922f45e3..18ae9eac9e 100644 --- a/github/provider.go +++ b/github/provider.go @@ -78,6 +78,7 @@ func Provider() terraform.ResourceProvider { "github_actions_environment_secret": resourceGithubActionsEnvironmentSecret(), "github_actions_organization_secret": resourceGithubActionsOrganizationSecret(), "github_actions_organization_secret_repositories": resourceGithubActionsOrganizationSecretRepositories(), + "github_actions_runner_group": resourceGithubActionsRunnerGroup(), "github_actions_secret": resourceGithubActionsSecret(), "github_app_installation_repository": resourceGithubAppInstallationRepository(), "github_branch": resourceGithubBranch(), diff --git a/github/resource_github_actions_runner_group.go b/github/resource_github_actions_runner_group.go new file mode 100644 index 0000000000..e8143af627 --- /dev/null +++ b/github/resource_github_actions_runner_group.go @@ -0,0 +1,235 @@ +package github + +import ( + "context" + "fmt" + "log" + "net/http" + "strconv" + + "github.com/google/go-github/v38/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" +) + +func resourceGithubActionsRunnerGroup() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubActionsRunnerGroupCreate, + Read: resourceGithubActionsRunnerGroupRead, + Update: resourceGithubActionsRunnerGroupUpdate, + Delete: resourceGithubActionsRunnerGroupDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "allows_public_repositories": { + Type: schema.TypeBool, + Computed: true, + }, + "default": { + Type: schema.TypeBool, + Computed: true, + }, + "etag": { + Type: schema.TypeString, + Computed: true, + }, + "inherited": { + Type: schema.TypeBool, + Computed: true, + }, + "name": { + Type: schema.TypeString, + Required: true, + }, + "runners_url": { + Type: schema.TypeString, + Computed: true, + }, + "selected_repository_ids": { + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeInt, + }, + Set: schema.HashInt, + Optional: true, + }, + "selected_repositories_url": { + Type: schema.TypeString, + Computed: true, + }, + "visibility": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"all", "selected", "private"}, false), + }, + }, + } +} + +func resourceGithubActionsRunnerGroupCreate(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + name := d.Get("name").(string) + visibility := d.Get("visibility").(string) + selectedRepositories, hasSelectedRepositories := d.GetOk("selected_repository_ids") + + if visibility != "selected" && hasSelectedRepositories { + return fmt.Errorf("Cannot use selected_repository_ids without visibility being set to selected") + } + + selectedRepositoryIDs := []int64{} + + if hasSelectedRepositories { + ids := selectedRepositories.(*schema.Set).List() + + for _, id := range ids { + selectedRepositoryIDs = append(selectedRepositoryIDs, int64(id.(int))) + } + } + + ctx := context.Background() + + log.Printf("[DEBUG] Creating organization runner group: %s (%s)", name, orgName) + runnerGroup, resp, err := client.Actions.CreateOrganizationRunnerGroup(ctx, + orgName, + github.CreateRunnerGroupRequest{ + Name: &name, + Visibility: &visibility, + SelectedRepositoryIDs: selectedRepositoryIDs, + }, + ) + if err != nil { + return err + } + d.SetId(strconv.FormatInt(runnerGroup.GetID(), 10)) + d.Set("etag", resp.Header.Get("ETag")) + d.Set("allows_public_repositories", runnerGroup.GetAllowsPublicRepositories()) + d.Set("default", runnerGroup.GetDefault()) + d.Set("id", runnerGroup.GetID()) + d.Set("inherited", runnerGroup.GetInherited()) + d.Set("name", runnerGroup.GetName()) + d.Set("runners_url", runnerGroup.GetRunnersURL()) + d.Set("selected_repositories_url", runnerGroup.GetSelectedRepositoriesURL()) + d.Set("visibility", runnerGroup.GetVisibility()) + d.Set("selected_repository_ids", selectedRepositoryIDs) // Note: runnerGroup has no method to get selected repository IDs + + return resourceGithubActionsRunnerGroupRead(d, meta) +} + +func resourceGithubActionsRunnerGroupRead(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + + runnerGroupID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return err + } + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + if !d.IsNewResource() { + ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string)) + } + + log.Printf("[DEBUG] Reading organization runner group: %s (%s)", d.Id(), orgName) + runnerGroup, resp, err := client.Actions.GetOrganizationRunnerGroup(ctx, orgName, runnerGroupID) + if err != nil { + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusNotModified { + return nil + } + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Removing organization runner group %s/%s from state because it no longer exists in GitHub", + orgName, d.Id()) + d.SetId("") + return nil + } + } + return err + } + + d.Set("etag", resp.Header.Get("ETag")) + d.Set("allows_public_repositories", runnerGroup.GetAllowsPublicRepositories()) + d.Set("default", runnerGroup.GetDefault()) + d.Set("id", runnerGroup.GetID()) + d.Set("inherited", runnerGroup.GetInherited()) + d.Set("name", runnerGroup.GetName()) + d.Set("runners_url", runnerGroup.GetRunnersURL()) + d.Set("selected_repositories_url", runnerGroup.GetSelectedRepositoriesURL()) + d.Set("visibility", runnerGroup.GetVisibility()) + + log.Printf("[DEBUG] Reading organization runner group repositories: %s (%s)", d.Id(), orgName) + runnerGroupRepositories, _, err := client.Actions.ListRepositoryAccessRunnerGroup(ctx, orgName, runnerGroupID) + if err != nil { + return err + } + + selectedRepositoryIDs := []int64{} + for _, repo := range runnerGroupRepositories.Repositories { + selectedRepositoryIDs = append(selectedRepositoryIDs, *repo.ID) + } + log.Printf("[DEBUG] Got selected_repository_ids: %v", selectedRepositoryIDs) + d.Set("selected_repository_ids", selectedRepositoryIDs) + + return nil +} + +func resourceGithubActionsRunnerGroupUpdate(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + + name := d.Get("name").(string) + visibility := d.Get("visibility").(string) + + options := github.UpdateRunnerGroupRequest{ + Name: &name, + Visibility: &visibility, + } + + runnerGroupID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return err + } + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + log.Printf("[DEBUG] Updating organization runner group: %s (%s)", d.Id(), orgName) + if _, _, err := client.Actions.UpdateOrganizationRunnerGroup(ctx, orgName, runnerGroupID, options); err != nil { + return err + } + + return resourceGithubActionsRunnerGroupRead(d, meta) +} + +func resourceGithubActionsRunnerGroupDelete(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + runnerGroupID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return err + } + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + log.Printf("[DEBUG] Deleting organization runner group: %s (%s)", d.Id(), orgName) + _, err = client.Actions.DeleteOrganizationRunnerGroup(ctx, orgName, runnerGroupID) + return err +} diff --git a/github/resource_github_actions_runner_group_test.go b/github/resource_github_actions_runner_group_test.go new file mode 100644 index 0000000000..43a258c770 --- /dev/null +++ b/github/resource_github_actions_runner_group_test.go @@ -0,0 +1,289 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubActionsRunnerGroup(t *testing.T) { + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("creates runner groups without error", func(t *testing.T) { + + // t.Skip("requires an enterprise cloud account") + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + vulnerability_alerts = false + } + + resource "github_actions_runner_group" "test" { + name = github_repository.test.name + visibility = "all" + } + `, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet( + "github_actions_runner_group.test", "name", + ), + resource.TestCheckResourceAttr( + "github_actions_runner_group.test", "name", + fmt.Sprintf(`tf-acc-test-%s`, randomID), + ), + resource.TestCheckResourceAttr( + "github_actions_runner_group.test", "visibility", + "all", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("manages runner visibility", func(t *testing.T) { + + // t.Skip("requires an enterprise cloud account") + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + resource "github_actions_runner_group" "test" { + name = github_repository.test.name + visibility = "selected" + selected_repository_ids = [github_repository.test.repo_id] + } + `, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet( + "github_actions_runner_group.test", "name", + ), + resource.TestCheckResourceAttr( + "github_actions_runner_group.test", "name", + fmt.Sprintf(`tf-acc-test-%s`, randomID), + ), + resource.TestCheckResourceAttr( + "github_actions_runner_group.test", "visibility", + "selected", + ), + resource.TestCheckResourceAttr( + "github_actions_runner_group.test", "selected_repository_ids.#", + "1", + ), + resource.TestCheckResourceAttrSet( + "github_actions_runner_group.test", "selected_repositories_url", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("imports an all runner group without error", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + resource "github_actions_runner_group" "test" { + name = github_repository.test.name + visibility = "all" + } + `, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_runner_group.test", "name"), + resource.TestCheckResourceAttrSet("github_actions_runner_group.test", "visibility"), + resource.TestCheckResourceAttr("github_actions_runner_group.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_actions_runner_group.test", "name", fmt.Sprintf(`tf-acc-test-%s`, randomID)), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + ResourceName: "github_actions_runner_group.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("imports a private runner group without error", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + resource "github_actions_runner_group" "test" { + name = github_repository.test.name + visibility = "private" + } + `, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_runner_group.test", "name"), + resource.TestCheckResourceAttr("github_actions_runner_group.test", "name", fmt.Sprintf(`tf-acc-test-%s`, randomID)), + resource.TestCheckResourceAttrSet("github_actions_runner_group.test", "visibility"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + ResourceName: "github_actions_runner_group.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + // Note: this test is skipped because when setting visibility 'private', it always fails with: + // Step 0 error: After applying this step, the plan was not empty: + // visibility: "all" => "private" + t.Skip("always shows a diff for visibility 'all' => 'private'") + testCase(t, organization) + }) + }) + + t.Run("imports a selected runner group without error", func(t *testing.T) { + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + resource "github_actions_runner_group" "test" { + name = github_repository.test.name + visibility = "selected" + selected_repository_ids = [github_repository.test.repo_id] + } + `, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_runner_group.test", "name"), + resource.TestCheckResourceAttr("github_actions_runner_group.test", "name", fmt.Sprintf(`tf-acc-test-%s`, randomID)), + resource.TestCheckResourceAttrSet("github_actions_runner_group.test", "visibility"), + resource.TestCheckResourceAttr("github_actions_runner_group.test", "visibility", "selected"), + resource.TestCheckResourceAttr( + "github_actions_runner_group.test", "selected_repository_ids.#", + "1", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + ResourceName: "github_actions_runner_group.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + } + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) +} diff --git a/website/docs/r/actions_runner_group.html.markdown b/website/docs/r/actions_runner_group.html.markdown new file mode 100644 index 0000000000..941c6eef6c --- /dev/null +++ b/website/docs/r/actions_runner_group.html.markdown @@ -0,0 +1,52 @@ +--- +layout: "github" +page_title: "GitHub: github_actions_runner_group" +description: |- + Creates and manages an Actions Runner Group within a GitHub organization +--- + +# github_actions_runner_group + +This resource allows you to create and manage GitHub Actions runner groups within your GitHub enterprise organizations. +You must have admin access to an organization to use this resource. + +## Example Usage + +```hcl +resource "github_repository" "example" { + name = "my-repository" +} + +resource "github_actions_runner_group" "example" { + name = github_repository.example.name + visibility = "selected" + selected_repository_ids = [github_repository.example.repo_id] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) Name of the runner group +* `selected_repository_ids` - (Optional) IDs of the repositories which should be added to the runner group +* `visibility` - (Optional) Visibility of a runner group. Whether the runner group can include `all`, `selected`, or `private` repositories. A value of `private` is not currently supported due to limitations in the GitHub API. + +## Attributes Reference + +* `allows_public_repositories` - Whether public repositories can be added to the runner group +* `default` - Whether this is the default runner group +* `etag` - An etag representing the runner group object +* `inherited` - Whether the runner group is inherited from the enterprise level +* `runners_url` - The GitHub API URL for the runner group's runners +* `selected_repository_ids` - List of repository IDs that can access the runner group +* `selected_repositories_url` - Github API URL for the runner group's repositories +* `visibility` - The visibility of the runner group + +## Import + +This resource can be imported using the ID of the runner group: + +``` +$ terraform import github_actions_runner_group.test 7 +``` diff --git a/website/github.erb b/website/github.erb index 36b9420ecd..3023bc9c99 100644 --- a/website/github.erb +++ b/website/github.erb @@ -67,6 +67,9 @@
  • github_actions_organization_secret_repositories
  • +
  • + github_actions_runner_group +
  • github_actions_secret