diff --git a/GNUmakefile b/GNUmakefile index c23c4b8b55..04824add4a 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -1,4 +1,4 @@ -SWEEP?=repositories,teams +SWEEP?=all PKG_NAME=github TESTACC?=./$(PKG_NAME)/... @@ -81,7 +81,7 @@ testacc: sweep: @echo "WARNING: This will destroy infrastructure. Use only in development accounts." - go test $(TESTACC) -v -sweep=$(SWEEP) $(SWEEPARGS) + go test ./github -v -sweep=$(SWEEP) $(SWEEPARGS) generatedocs: @cd tools; go generate ./... diff --git a/RESOURCES.md b/RESOURCES.md index dd00ac0a50..d9256bdee1 100644 --- a/RESOURCES.md +++ b/RESOURCES.md @@ -122,7 +122,7 @@ The overall status of each resource or data source is captured in this document | --- | --- | --- | --- | --- | --- | --- | --- | | `github_actions_environment_secret` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_actions_environment_variable` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | -| `github_actions_hosted_runner` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | +| `github_actions_hosted_runner` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `github_actions_organization_oidc_subject_claim_customization_template` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_actions_organization_permissions` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_actions_organization_secret` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | diff --git a/docs/resources/actions_hosted_runner.md b/docs/resources/actions_hosted_runner.md index 5f0c4d1ba2..6cc2692a56 100644 --- a/docs/resources/actions_hosted_runner.md +++ b/docs/resources/actions_hosted_runner.md @@ -1,48 +1,51 @@ --- page_title: "github_actions_hosted_runner (Resource) - GitHub" +subcategory: "" description: |- - Creates and manages GitHub-hosted runners within a GitHub organization + This resource allows you to create and manage GitHub-hosted runners within your GitHub organization. You must have admin access to an organization to use this resource. --- # github_actions_hosted_runner (Resource) This resource allows you to create and manage GitHub-hosted runners within your GitHub organization. You must have admin access to an organization to use this resource. -GitHub-hosted runners are fully managed virtual machines that run your GitHub Actions workflows. Unlike self-hosted runners, GitHub handles the infrastructure, maintenance, and scaling. +## Notes -## Example Usage +- This resource is **organization-only** and cannot be used with individual accounts. +- Image IDs for GitHub-owned images are numeric strings (e.g., "2306" for Ubuntu Latest 24.04), not names like "ubuntu-latest". +- Deletion of hosted runners is asynchronous. The provider will poll for up to 10 minutes (configurable via timeouts) to confirm deletion. +- Runner creation and updates may take several minutes as GitHub provisions the infrastructure. -### Basic Usage +## Getting Available Images and Sizes -```terraform -resource "github_actions_runner_group" "example" { - name = "example-runner-group" - visibility = "all" -} +To get a list of available images: -resource "github_actions_hosted_runner" "example" { - name = "example-hosted-runner" +```bash +curl -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/orgs/YOUR_ORG/actions/hosted-runners/images/github-owned +``` - image { - id = "2306" - source = "github" - } +To get available machine sizes: - size = "4-core" - runner_group_id = github_actions_runner_group.example.id -} +```bash +curl -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/orgs/YOUR_ORG/actions/hosted-runners/machine-sizes ``` -### Advanced Usage with Optional Parameters +## Example Usage + +### Advanced Usage ```terraform -resource "github_actions_runner_group" "advanced" { - name = "advanced-runner-group" - visibility = "selected" +resource "github_actions_runner_group" "example" { + name = "example-runner-group" + visibility = "all" } -resource "github_actions_hosted_runner" "advanced" { - name = "advanced-hosted-runner" +resource "github_actions_hosted_runner" "example" { + name = "example-hosted-runner" image { id = "2306" @@ -50,104 +53,79 @@ resource "github_actions_hosted_runner" "advanced" { } size = "8-core" - runner_group_id = github_actions_runner_group.advanced.id + runner_group_id = github_actions_runner_group.example.id maximum_runners = 10 public_ip_enabled = true } ``` -## Argument Reference + +## Schema -The following arguments are supported: +### Required -- `name` - (Required) Name of the hosted runner. Must be between 1 and 64 characters and may only contain alphanumeric characters, '.', '-', and '_'. -- `image` - (Required) Image configuration for the hosted runner. Cannot be changed after creation. Block supports: - - `id` - (Required) The image ID. For GitHub-owned images, use numeric IDs like "2306" for Ubuntu Latest 24.04. To get available images, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/images/github-owned`. - - `source` - (Optional) The image source. Valid values are "github", "partner", or "custom". Defaults to "github". -- `size` - (Required) Machine size for the hosted runner (e.g., "4-core", "8-core"). Can be updated to scale the runner. To list available sizes, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/machine-sizes`. -- `runner_group_id` - (Required) The ID of the runner group to assign this runner to. -- `maximum_runners` - (Optional) Maximum number of runners to scale up to. Runners will not auto-scale above this number. Use this setting to limit costs. -- `public_ip_enabled` - (Optional) Whether to enable static public IP for the runner. Note there are account limits. To list limits, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/limits`. Defaults to false. -- `image_version` - (Optional) The version of the runner image to deploy. This is only relevant for runners using custom images. +- `image` (Block List, Min: 1, Max: 1) Image configuration for the hosted runner. Cannot be changed after creation. (see [below for nested schema](#nestedblock--image)) +- `name` (String) Name of the hosted runner. Must be between 1 and 64 characters and may only contain upper and lowercase letters a-z, numbers 0-9, '.', '-', and '_'. +- `runner_group_id` (Number) The ID of the runner group to assign this runner to. +- `size` (String) Machine size for the hosted runner (e.g., `4-core`, `8-core`). Can be updated to scale the runner. To list available sizes, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/machine-sizes`. -## Timeouts +### Optional -The `timeouts` block allows you to specify timeouts for certain actions: +- `image_gen` (Boolean) Whether this runner should be used to generate custom images. Cannot be changed after creation. +- `image_version` (String) The version of the runner image to deploy. This is only relevant for runners using custom images. +- `maximum_runners` (Number) Maximum number of runners to scale up to. Runners will not auto-scale above this number. Use this setting to limit costs. +- `public_ip_enabled` (Boolean) Whether to enable static public IP for the runner. Note there are account limits. To list limits, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/limits`. Defaults to false. +- `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) -- `delete` - (Defaults to 10 minutes) Used for waiting for the hosted runner deletion to complete. +### Read-Only -Example: +- `id` (String) The hosted runner ID. +- `last_active_on` (String) Timestamp when the runner was last active. +- `machine_size_details` (List of Object) Detailed machine size specifications. (see [below for nested schema](#nestedatt--machine_size_details)) +- `platform` (String) Platform of the runner. +- `public_ips` (List of Object) List of public IP ranges assigned to this runner. (see [below for nested schema](#nestedatt--public_ips)) +- `status` (String) Current status of the runner. -```terraform -resource "github_actions_hosted_runner" "example" { - name = "example-hosted-runner" + +### Nested Schema for `image` - image { - id = "2306" - source = "github" - } +Required: - size = "4-core" - runner_group_id = github_actions_runner_group.example.id +- `id` (String) The image ID. - timeouts { - delete = "15m" - } -} -``` +Optional: -## Attributes Reference +- `source` (String) The image source (github, partner, or custom). -In addition to the arguments above, the following attributes are exported: +Read-Only: -- `id` - The ID of the hosted runner. -- `status` - Current status of the runner (e.g., "Ready", "Provisioning"). -- `platform` - Platform of the runner (e.g., "linux-x64", "win-x64"). -- `image` - In addition to the arguments above, the image block exports: - - `size_gb` - The size of the image in gigabytes. -- `machine_size_details` - Detailed specifications of the machine size: - - `id` - Machine size identifier. - - `cpu_cores` - Number of CPU cores. - - `memory_gb` - Amount of memory in gigabytes. - - `storage_gb` - Amount of storage in gigabytes. -- `public_ips` - List of public IP ranges assigned to this runner (only if `public_ip_enabled` is true): - - `enabled` - Whether this IP range is enabled. - - `prefix` - IP address prefix. - - `length` - Subnet length. -- `last_active_on` - Timestamp (RFC3339) when the runner was last active. +- `size_gb` (Number) The size of the image in GB. -## Import -Hosted runners can be imported using the runner ID: + +### Nested Schema for `timeouts` -```shell -terraform import github_actions_hosted_runner.example 123456 -``` +Optional: -## Notes +- `delete` (String) -- This resource is **organization-only** and cannot be used with individual accounts. -- The `image` field cannot be changed after the runner is created. Changing it will force recreation of the runner. -- The `size` field can be updated to scale the runner up or down as needed. -- Image IDs for GitHub-owned images are numeric strings (e.g., "2306" for Ubuntu Latest 24.04), not names like "ubuntu-latest". -- Deletion of hosted runners is asynchronous. The provider will poll for up to 10 minutes (configurable via timeouts) to confirm deletion. -- Runner creation and updates may take several minutes as GitHub provisions the infrastructure. -- Static public IPs are subject to account limits. Check your organization's limits before enabling. -## Getting Available Images and Sizes + +### Nested Schema for `machine_size_details` -To get a list of available images: +Read-Only: -```bash -curl -H "Authorization: Bearer YOUR_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - https://api.github.com/orgs/YOUR_ORG/actions/hosted-runners/images/github-owned -``` +- `cpu_cores` (Number) +- `id` (String) +- `memory_gb` (Number) +- `storage_gb` (Number) -To get available machine sizes: -```bash -curl -H "Authorization: Bearer YOUR_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - https://api.github.com/orgs/YOUR_ORG/actions/hosted-runners/machine-sizes -``` + +### Nested Schema for `public_ips` + +Read-Only: + +- `enabled` (Boolean) +- `length` (Number) +- `prefix` (String) diff --git a/examples/resources/actions_hosted_runner/example_2.tf b/examples/resources/actions_hosted_runner/example_2.tf deleted file mode 100644 index ee7e3f69f7..0000000000 --- a/examples/resources/actions_hosted_runner/example_2.tf +++ /dev/null @@ -1,18 +0,0 @@ -resource "github_actions_runner_group" "advanced" { - name = "advanced-runner-group" - visibility = "selected" -} - -resource "github_actions_hosted_runner" "advanced" { - name = "advanced-hosted-runner" - - image { - id = "2306" - source = "github" - } - - size = "8-core" - runner_group_id = github_actions_runner_group.advanced.id - maximum_runners = 10 - public_ip_enabled = true -} diff --git a/examples/resources/actions_hosted_runner/example_3.tf b/examples/resources/actions_hosted_runner/example_3.tf deleted file mode 100644 index 195b7aeb8c..0000000000 --- a/examples/resources/actions_hosted_runner/example_3.tf +++ /dev/null @@ -1,15 +0,0 @@ -resource "github_actions_hosted_runner" "example" { - name = "example-hosted-runner" - - image { - id = "2306" - source = "github" - } - - size = "4-core" - runner_group_id = github_actions_runner_group.example.id - - timeouts { - delete = "15m" - } -} diff --git a/examples/resources/actions_hosted_runner/example_1.tf b/examples/resources/github_actions_hosted_runner/resource_advanced.tf similarity index 64% rename from examples/resources/actions_hosted_runner/example_1.tf rename to examples/resources/github_actions_hosted_runner/resource_advanced.tf index ef5ae400c3..f43c543ee1 100644 --- a/examples/resources/actions_hosted_runner/example_1.tf +++ b/examples/resources/github_actions_hosted_runner/resource_advanced.tf @@ -11,6 +11,8 @@ resource "github_actions_hosted_runner" "example" { source = "github" } - size = "4-core" - runner_group_id = github_actions_runner_group.example.id + size = "8-core" + runner_group_id = github_actions_runner_group.example.id + maximum_runners = 10 + public_ip_enabled = true } diff --git a/github/acc_helpers_test.go b/github/acc_helpers_test.go index ebd2daf3af..fe3ed5c781 100644 --- a/github/acc_helpers_test.go +++ b/github/acc_helpers_test.go @@ -161,6 +161,38 @@ func mustCreateTestRepository(t *testing.T) *github.Repository { return repo } +func mustCreateTestOrganizationActionsRunnerGroup(t *testing.T) *github.RunnerGroup { + t.Helper() + + randomID := acctest.RandString(testRandomIDLength) + name := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + + req := github.CreateRunnerGroupRequest{ + Name: &name, + Visibility: new("all"), + } + + runnerGroup, _, err := testAccConf.meta.v3client.Actions.CreateOrganizationRunnerGroup(t.Context(), testAccConf.meta.name, req) + if err != nil { + t.Fatalf("failed to create test organization runner group: %v", err) + } + + t.Cleanup(func() { + ctx := context.WithoutCancel(t.Context()) + if err := sweepRunnerGroupRunners(ctx, runnerGroup); err != nil { + t.Logf("failed to delete runner group runners for %s: %v", runnerGroup.GetName(), err) + } + if _, err := testAccConf.meta.v3client.Actions.DeleteOrganizationRunnerGroup(ctx, testAccConf.meta.name, runnerGroup.GetID()); err != nil { + if err, ok := errors.AsType[*github.ErrorResponse](err); ok && err.Response.StatusCode == 404 { + return + } + t.Logf("failed to delete test organization runner group %s: %v", name, err) + } + }) + + return runnerGroup +} + func mustAddRepositoryCollaborator(t *testing.T, repo *github.Repository, username string) { t.Helper() diff --git a/github/acc_test.go b/github/acc_test.go index 4c9f72a4be..28e9037fe1 100644 --- a/github/acc_test.go +++ b/github/acc_test.go @@ -2,13 +2,16 @@ package github import ( "context" + "errors" "fmt" + "net/http" "net/url" "os" "slices" "strconv" "strings" "testing" + "time" "github.com/google/go-github/v88/github" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -267,6 +270,11 @@ func configureSweepers() { Name: "teams", F: sweepTeams, }) + + resource.AddTestSweepers("organization_runner_groups", &resource.Sweeper{ + Name: "organization_runner_groups", + F: sweepOrganizationRunnerGroups, + }) } func sweepTeams(_ string) error { @@ -329,6 +337,76 @@ func sweepRepositories(_ string) error { return nil } +func sweepOrganizationRunnerGroups(_ string) error { + if !slices.Contains(orgTestModes, testMode(os.Getenv("GH_TEST_AUTH_MODE"))) { + return nil + } + + client := testAccConf.meta.v3client + owner := testAccConf.meta.name + ctx := context.Background() + + fmt.Println("sweeping organization runner groups") + runnerGroups, _, err := client.Actions.ListOrganizationRunnerGroups(ctx, owner, &github.ListOrgRunnerGroupOptions{}) + if err != nil { + return err + } + + for _, rg := range runnerGroups.GetRunnerGroups() { + if name := rg.GetName(); strings.HasPrefix(name, testResourcePrefix) { + fmt.Printf("destroying organization runner group %s\n", name) + + if err := sweepRunnerGroupRunners(ctx, rg); err != nil { + return fmt.Errorf("failed to sweep runner group runners for %s: %w", rg.GetName(), err) + } + + if resp, err := client.Actions.DeleteOrganizationRunnerGroup(ctx, owner, rg.GetID()); err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + continue + } + return err + } + } + } + + return nil +} + +func sweepRunnerGroupRunners(ctx context.Context, rg *github.RunnerGroup) error { + client := testAccConf.meta.v3client + owner := testAccConf.meta.name + + fmt.Printf("cleanup: removing organization hosted runners of runner group '%s'\n", rg.GetName()) + runners, _, err := client.Actions.ListRunnerGroupHostedRunners(ctx, owner, rg.GetID(), &github.ListOptions{}) + if err != nil { + return err + } + + for _, r := range runners.GetRunners() { + _, resp, err := client.Actions.DeleteHostedRunner(ctx, owner, r.GetID()) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + continue + } + if _, ok := errors.AsType[*github.AcceptedError](err); ok { + if err := waitForRunnerDeletion(ctx, client, owner, r.GetID(), 5*time.Minute); err != nil { + return fmt.Errorf("failed to wait for runner deletion %s: %w", r.GetName(), err) + } + continue + } + return fmt.Errorf("failed to delete hosted runner %s: %w", r.GetName(), err) + } + + if resp != nil && resp.StatusCode == http.StatusAccepted { + if err := waitForRunnerDeletion(ctx, client, owner, r.GetID(), 5*time.Minute); err != nil { + return fmt.Errorf("failed to wait for runner deletion %s: %w", r.GetName(), err) + } + } + } + + return nil +} + func skipUnauthenticated(t *testing.T) { if testAccConf.authMode == anonymous { t.Skip("Skipping as test mode not authenticated") diff --git a/github/resource_github_actions_hosted_runner.go b/github/resource_github_actions_hosted_runner.go index 027fb648a2..3657063d8f 100644 --- a/github/resource_github_actions_hosted_runner.go +++ b/github/resource_github_actions_hosted_runner.go @@ -4,13 +4,15 @@ import ( "context" "errors" "fmt" - "log" "net/http" "regexp" "strconv" "time" "github.com/google/go-github/v88/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/retry" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -18,10 +20,10 @@ import ( func resourceGithubActionsHostedRunner() *schema.Resource { return &schema.Resource{ - Create: resourceGithubActionsHostedRunnerCreate, - Read: resourceGithubActionsHostedRunnerRead, - Update: resourceGithubActionsHostedRunnerUpdate, - Delete: resourceGithubActionsHostedRunnerDelete, + CreateContext: resourceGithubActionsHostedRunnerCreate, + ReadContext: resourceGithubActionsHostedRunnerRead, + UpdateContext: resourceGithubActionsHostedRunnerUpdate, + DeleteContext: resourceGithubActionsHostedRunnerDelete, Importer: &schema.ResourceImporter{ StateContext: schema.ImportStatePassthroughContext, }, @@ -30,6 +32,10 @@ func resourceGithubActionsHostedRunner() *schema.Resource { Delete: schema.DefaultTimeout(10 * time.Minute), }, + Description: "This resource allows you to create and manage GitHub-hosted runners within your GitHub organization. You must have admin access to an organization to use this resource.", + + CustomizeDiff: customdiff.All(resourceGithubActionsHostedRunnerValidation), + Schema: map[string]*schema.Schema{ "name": { Type: schema.TypeString, @@ -44,10 +50,11 @@ func resourceGithubActionsHostedRunner() *schema.Resource { Description: "Name of the hosted runner. Must be between 1 and 64 characters and may only contain upper and lowercase letters a-z, numbers 0-9, '.', '-', and '_'.", }, "image": { - Type: schema.TypeList, - Required: true, - ForceNew: true, - MaxItems: 1, + Type: schema.TypeList, + Required: true, + ForceNew: true, + MaxItems: 1, + Description: "Image configuration for the hosted runner. Cannot be changed after creation.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { @@ -69,35 +76,34 @@ func resourceGithubActionsHostedRunner() *schema.Resource { }, }, }, - Description: "Image configuration for the hosted runner. Cannot be changed after creation.", }, "size": { Type: schema.TypeString, Required: true, - Description: "Machine size (e.g., '4-core', '8-core'). Can be updated to scale the runner.", + Description: "Machine size for the hosted runner (e.g., `4-core`, `8-core`). Can be updated to scale the runner. To list available sizes, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/machine-sizes`.", }, "runner_group_id": { Type: schema.TypeInt, Required: true, - Description: "The runner group ID.", + Description: "The ID of the runner group to assign this runner to.", }, "maximum_runners": { Type: schema.TypeInt, Optional: true, Computed: true, ValidateDiagFunc: validation.ToDiagFunc(validation.IntAtLeast(1)), - Description: "Maximum number of runners to scale up to.", + Description: "Maximum number of runners to scale up to. Runners will not auto-scale above this number. Use this setting to limit costs.", }, "public_ip_enabled": { Type: schema.TypeBool, Optional: true, Default: false, - Description: "Whether to enable static public IP.", + Description: "Whether to enable static public IP for the runner. Note there are account limits. To list limits, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/limits`. Defaults to false.", }, "image_version": { Type: schema.TypeString, Optional: true, - Description: "The version of the runner image to deploy. This is relevant only for runners using custom images.", + Description: "The version of the runner image to deploy. This is only relevant for runners using custom images.", }, "image_gen": { Type: schema.TypeBool, @@ -116,14 +122,10 @@ func resourceGithubActionsHostedRunner() *schema.Resource { Computed: true, Description: "Current status of the runner.", }, - "platform": { - Type: schema.TypeString, - Computed: true, - Description: "Platform of the runner.", - }, "machine_size_details": { - Type: schema.TypeList, - Computed: true, + Type: schema.TypeList, + Computed: true, + Description: "Detailed machine size specifications.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { @@ -148,11 +150,16 @@ func resourceGithubActionsHostedRunner() *schema.Resource { }, }, }, - Description: "Detailed machine size specifications.", + }, + "platform": { + Type: schema.TypeString, + Computed: true, + Description: "Platform of the runner.", }, "public_ips": { - Type: schema.TypeList, - Computed: true, + Type: schema.TypeList, + Computed: true, + Description: "List of public IP ranges assigned to this runner.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "enabled": { @@ -172,7 +179,6 @@ func resourceGithubActionsHostedRunner() *schema.Resource { }, }, }, - Description: "List of public IP ranges assigned to this runner.", }, "last_active_on": { Type: schema.TypeString, @@ -183,366 +189,271 @@ func resourceGithubActionsHostedRunner() *schema.Resource { } } -func expandImage(imageList []any) map[string]any { - if len(imageList) == 0 { - return nil - } - - imageMap := imageList[0].(map[string]any) - result := make(map[string]any) - - if id, ok := imageMap["id"].(string); ok { - result["id"] = id - } - if source, ok := imageMap["source"].(string); ok { - result["source"] = source - } - - return result -} - -func flattenImage(image map[string]any) []any { - if image == nil { - return []any{} - } - - result := make(map[string]any) - - // Handle id as either string or number - if id, ok := image["id"].(string); ok { - result["id"] = id - } else if id, ok := image["id"].(float64); ok { - result["id"] = fmt.Sprintf("%.0f", id) - } - - if source, ok := image["source"].(string); ok { - result["source"] = source - } - if size, ok := image["size"].(float64); ok { - result["size_gb"] = int(size) +func resourceGithubActionsHostedRunnerCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + err := checkOrganization(m) + if err != nil { + return diag.FromErr(err) } - return []any{result} -} + meta, _ := m.(*Owner) + client := meta.v3client + orgName := meta.name -func flattenMachineSizeDetails(details map[string]any) []any { - if details == nil { - return []any{} + runnerName, _ := d.Get("name").(string) + runnerImageDetails, _ := d.Get("image").([]any) + runnerSize, _ := d.Get("size").(string) + runnerGroupID, _ := d.Get("runner_group_id").(int) + expandedImageDetails, err := expandImage(runnerImageDetails) + if err != nil { + return diag.FromErr(err) } - result := make(map[string]any) - if id, ok := details["id"].(string); ok { - result["id"] = id - } - if cpuCores, ok := details["cpu_cores"].(float64); ok { - result["cpu_cores"] = int(cpuCores) - } - if memoryGB, ok := details["memory_gb"].(float64); ok { - result["memory_gb"] = int(memoryGB) - } - if storageGB, ok := details["storage_gb"].(float64); ok { - result["storage_gb"] = int(storageGB) + req := github.CreateHostedRunnerRequest{ + Name: runnerName, + Image: *expandedImageDetails, + Size: runnerSize, + RunnerGroupID: int64(runnerGroupID), } - return []any{result} -} - -func flattenPublicIPs(ips []any) []any { - if ips == nil { - return []any{} + if v, ok := d.GetOk("maximum_runners"); ok { + maxRunners, _ := v.(int) + req.MaximumRunners = new(int64(maxRunners)) } - result := make([]any, 0, len(ips)) - for _, ip := range ips { - ipMap, ok := ip.(map[string]any) - if !ok { - continue - } + publicIPEnabled, _ := d.Get("public_ip_enabled").(bool) + req.EnableStaticIP = new(publicIPEnabled) - ipResult := make(map[string]any) - if enabled, ok := ipMap["enabled"].(bool); ok { - ipResult["enabled"] = enabled - } - if prefix, ok := ipMap["prefix"].(string); ok { - ipResult["prefix"] = prefix - } - if length, ok := ipMap["length"].(float64); ok { - ipResult["length"] = int(length) - } - result = append(result, ipResult) + if v, ok := d.GetOk("image_version"); ok { + runnerImageVersion, _ := v.(string) + req.Image.Version = new(runnerImageVersion) } - return result -} + useRunnerForImageGen, _ := d.Get("image_gen").(bool) + req.ImageGen = new(useRunnerForImageGen) -func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta any) error { - err := checkOrganization(meta) + runner, _, err := client.Actions.CreateHostedRunner(ctx, orgName, req) if err != nil { - return err - } - - client := meta.(*Owner).v3client - orgName := meta.(*Owner).name - ctx := context.Background() - - // Build request payload - payload := map[string]any{ - "name": d.Get("name").(string), - "image": expandImage(d.Get("image").([]any)), - "size": d.Get("size").(string), - "runner_group_id": d.Get("runner_group_id").(int), - } - - if v, ok := d.GetOk("maximum_runners"); ok { - payload["maximum_runners"] = v.(int) + return diag.FromErr(err) } - if v, ok := d.GetOk("public_ip_enabled"); ok { - payload["enable_static_ip"] = v.(bool) - } + d.SetId(strconv.Itoa(int(runner.GetID()))) - if v, ok := d.GetOk("image_version"); ok { - payload["image_version"] = v.(string) + if err := d.Set("status", runner.GetStatus()); err != nil { + return diag.FromErr(err) } - - if v, ok := d.GetOk("image_gen"); ok { - payload["image_gen"] = v.(bool) + if err := d.Set("platform", runner.GetPlatform()); err != nil { + return diag.FromErr(err) } - - // Create HTTP request - req, err := client.NewRequest(ctx, "POST", fmt.Sprintf("orgs/%s/actions/hosted-runners", orgName), payload) - if err != nil { - return err + if err := d.Set("last_active_on", runner.GetLastActiveOn().Format(time.RFC3339)); err != nil { + return diag.FromErr(err) } - var runner map[string]any - _, err = client.Do(req, &runner) - if err != nil { - var acceptedErr *github.AcceptedError - if !errors.As(err, &acceptedErr) { - return err + if machineSizeDetails := runner.GetMachineSizeDetails(); machineSizeDetails != nil { + if err := d.Set("machine_size_details", flattenMachineSizeDetails(machineSizeDetails)); err != nil { + return diag.FromErr(err) } } - if runner == nil { - return fmt.Errorf("no runner data returned from API") + if err := d.Set("maximum_runners", runner.GetMaximumRunners()); err != nil { + return diag.FromErr(err) } - // Set the ID - if id, ok := runner["id"].(float64); ok { - d.SetId(strconv.Itoa(int(id))) - } else { - return fmt.Errorf("failed to get runner ID from response: %+v", runner) + if err := d.Set("public_ips", flattenPublicIPs(runner.GetPublicIPs())); err != nil { + return diag.FromErr(err) } - return resourceGithubActionsHostedRunnerRead(d, meta) + return nil } -func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta any) error { - err := checkOrganization(meta) +func resourceGithubActionsHostedRunnerRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + err := checkOrganization(m) if err != nil { - return err + return diag.FromErr(err) } - client := meta.(*Owner).v3client - orgName := meta.(*Owner).name - runnerID := d.Id() - ctx := context.WithValue(context.Background(), ctxId, runnerID) + meta, _ := m.(*Owner) + client := meta.v3client + orgName := meta.name - // Create GET request - req, err := client.NewRequest(ctx, "GET", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil) + runnerIDStr := d.Id() + runnerID, err := strconv.ParseInt(runnerIDStr, 10, 64) if err != nil { - return err + return diag.FromErr(err) } - var runner map[string]any - _, err = client.Do(req, &runner) + runner, _, err := client.Actions.GetHostedRunner(ctx, orgName, runnerID) if err != nil { - var ghErr *github.ErrorResponse - if errors.As(err, &ghErr) { - if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[WARN] Removing hosted runner %s from state because it no longer exists in GitHub", runnerID) - d.SetId("") - return nil - } + if err, ok := errors.AsType[*github.ErrorResponse](err); ok && err.Response.StatusCode == http.StatusNotFound { + tflog.Warn(ctx, "Removing hosted runner from state because it no longer exists in GitHub", map[string]any{"runner_id": runnerIDStr}) + d.SetId("") + return nil } - return err + return diag.FromErr(err) } - if runner == nil { - return fmt.Errorf("no runner data returned from API") + if err := d.Set("name", runner.GetName()); err != nil { + return diag.FromErr(err) } - - if name, ok := runner["name"].(string); ok { - if err := d.Set("name", name); err != nil { - return err - } - } - if status, ok := runner["status"].(string); ok { - if err := d.Set("status", status); err != nil { - return err - } + if err := d.Set("status", runner.GetStatus()); err != nil { + return diag.FromErr(err) } - if platform, ok := runner["platform"].(string); ok { - if err := d.Set("platform", platform); err != nil { - return err - } + if err := d.Set("platform", runner.GetPlatform()); err != nil { + return diag.FromErr(err) } - if lastActiveOn, ok := runner["last_active_on"].(string); ok { - if err := d.Set("last_active_on", lastActiveOn); err != nil { - return err - } + if err := d.Set("last_active_on", runner.GetLastActiveOn().Format(time.RFC3339)); err != nil { + return diag.FromErr(err) } - if publicIPEnabled, ok := runner["public_ip_enabled"].(bool); ok { - if err := d.Set("public_ip_enabled", publicIPEnabled); err != nil { - return err - } + if err := d.Set("public_ip_enabled", runner.GetPublicIPEnabled()); err != nil { + return diag.FromErr(err) } - if image, ok := runner["image"].(map[string]any); ok { - if err := d.Set("image", flattenImage(image)); err != nil { - return err - } + if err := d.Set("image", flattenImage(runner.GetImageDetails())); err != nil { + return diag.FromErr(err) } - if machineSizeDetails, ok := runner["machine_size_details"].(map[string]any); ok { - if err := d.Set("size", machineSizeDetails["id"]); err != nil { - return err + if machineSizeDetails := runner.GetMachineSizeDetails(); machineSizeDetails != nil { + if err := d.Set("size", machineSizeDetails.GetID()); err != nil { + return diag.FromErr(err) } if err := d.Set("machine_size_details", flattenMachineSizeDetails(machineSizeDetails)); err != nil { - return err + return diag.FromErr(err) } } - if runnerGroupID, ok := runner["runner_group_id"].(float64); ok { - if err := d.Set("runner_group_id", int(runnerGroupID)); err != nil { - return err - } + if err := d.Set("runner_group_id", runner.GetRunnerGroupID()); err != nil { + return diag.FromErr(err) } - if maxRunners, ok := runner["maximum_runners"].(float64); ok { - if err := d.Set("maximum_runners", int(maxRunners)); err != nil { - return err - } + if err := d.Set("maximum_runners", runner.GetMaximumRunners()); err != nil { + return diag.FromErr(err) } - if publicIPs, ok := runner["public_ips"].([]any); ok { - if err := d.Set("public_ips", flattenPublicIPs(publicIPs)); err != nil { - return err - } + if err := d.Set("public_ips", flattenPublicIPs(runner.GetPublicIPs())); err != nil { + return diag.FromErr(err) } - if imageGen, ok := runner["image_gen"].(bool); ok { - if err := d.Set("image_gen", imageGen); err != nil { - return err - } - } + // TODO: Uncomment when go-github supports image_gen field in the HostedRunner struct + // if err := d.Set("image_gen", runner.GetImageGen()); err != nil { + // return diag.FromErr(err) + // } return nil } -func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta any) error { - err := checkOrganization(meta) +func resourceGithubActionsHostedRunnerUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + err := checkOrganization(m) if err != nil { - return err + return diag.FromErr(err) } - client := meta.(*Owner).v3client - orgName := meta.(*Owner).name - runnerID := d.Id() - ctx := context.WithValue(context.Background(), ctxId, runnerID) + // Only update the runner if any of the configured fields have changed + if !d.HasChanges("name", "size", "runner_group_id", "maximum_runners", "public_ip_enabled", "image_version") { + return nil + } - payload := make(map[string]any) + meta, _ := m.(*Owner) + client := meta.v3client + orgName := meta.name - if d.HasChange("name") { - payload["name"] = d.Get("name").(string) + runnerID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return diag.FromErr(err) } - if d.HasChange("size") { - payload["size"] = d.Get("size").(string) + + name, _ := d.Get("name").(string) + size, _ := d.Get("size").(string) + runnerGroupID, _ := d.Get("runner_group_id").(int) + maximumRunners, _ := d.Get("maximum_runners").(int) + publicIPEnabled, _ := d.Get("public_ip_enabled").(bool) + + req := github.UpdateHostedRunnerRequest{ + Name: new(name), + Size: new(size), + RunnerGroupID: new(int64(runnerGroupID)), + MaximumRunners: new(int64(maximumRunners)), + EnableStaticIP: new(publicIPEnabled), } - if d.HasChange("runner_group_id") { - payload["runner_group_id"] = d.Get("runner_group_id").(int) + + // image_version is only settable for runners with a custom image, so we only include it in the request if it has changed + if d.HasChange("image_version") { + imageVersion, _ := d.Get("image_version").(string) + req.ImageVersion = new(imageVersion) } - if d.HasChange("maximum_runners") { - payload["maximum_runners"] = d.Get("maximum_runners").(int) + + runner, _, err := client.Actions.UpdateHostedRunner(ctx, orgName, runnerID, req) + if err != nil { + if _, ok := errors.AsType[*github.AcceptedError](err); !ok { + return diag.FromErr(err) + } } - if d.HasChange("public_ip_enabled") { - payload["enable_static_ip"] = d.Get("public_ip_enabled").(bool) + + if err := d.Set("status", runner.GetStatus()); err != nil { + return diag.FromErr(err) } - if d.HasChange("image_version") { - payload["image_version"] = d.Get("image_version").(string) + if err := d.Set("platform", runner.GetPlatform()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("last_active_on", runner.GetLastActiveOn().Format(time.RFC3339)); err != nil { + return diag.FromErr(err) } - if len(payload) == 0 { - return resourceGithubActionsHostedRunnerRead(d, meta) + if machineSizeDetails := runner.GetMachineSizeDetails(); machineSizeDetails != nil { + if err := d.Set("machine_size_details", flattenMachineSizeDetails(machineSizeDetails)); err != nil { + return diag.FromErr(err) + } } - // Create PATCH request - req, err := client.NewRequest(ctx, "PATCH", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), payload) - if err != nil { - return err + if err := d.Set("maximum_runners", runner.GetMaximumRunners()); err != nil { + return diag.FromErr(err) } - var runner map[string]any - _, err = client.Do(req, &runner) - if err != nil { - var acceptedErr *github.AcceptedError - if !errors.As(err, &acceptedErr) { - return err - } + if err := d.Set("public_ips", flattenPublicIPs(runner.GetPublicIPs())); err != nil { + return diag.FromErr(err) } - return resourceGithubActionsHostedRunnerRead(d, meta) + return nil } -func resourceGithubActionsHostedRunnerDelete(d *schema.ResourceData, meta any) error { - err := checkOrganization(meta) +func resourceGithubActionsHostedRunnerDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + err := checkOrganization(m) if err != nil { - return err + return diag.FromErr(err) } - client := meta.(*Owner).v3client - orgName := meta.(*Owner).name - ctx := context.Background() + meta, _ := m.(*Owner) + client := meta.v3client + orgName := meta.name - runnerID := d.Id() - - // Send DELETE request - req, err := client.NewRequest(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil) + runnerID, err := strconv.ParseInt(d.Id(), 10, 64) if err != nil { - return err + return diag.FromErr(err) } - resp, err := client.Do(req, nil) + _, resp, err := client.Actions.DeleteHostedRunner(ctx, orgName, runnerID) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { return nil } - var acceptedErr *github.AcceptedError - if errors.As(err, &acceptedErr) { - return waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete)) + if _, ok := errors.AsType[*github.AcceptedError](err); ok { + return diag.FromErr(waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete))) } - return err + return diag.FromErr(err) } if resp != nil && resp.StatusCode == http.StatusAccepted { - return waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete)) + return diag.FromErr(waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete))) } return nil } -func waitForRunnerDeletion(ctx context.Context, client *github.Client, orgName, runnerID string, timeout time.Duration) error { +func waitForRunnerDeletion(ctx context.Context, client *github.Client, orgName string, runnerID int64, timeout time.Duration) error { conf := &retry.StateChangeConf{ - Pending: []string{"deleting", "active"}, + Pending: []string{"deleting", "active", "ready", "provisioning"}, Target: []string{"deleted"}, Refresh: func() (any, string, error) { - req, err := client.NewRequest(ctx, "GET", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil) - if err != nil { - return nil, "", err - } + _, resp, err := client.Actions.GetHostedRunner(ctx, orgName, runnerID) - resp, err := client.Do(req, nil) if resp != nil && resp.StatusCode == http.StatusNotFound { return "deleted", "deleted", nil } @@ -561,3 +472,91 @@ func waitForRunnerDeletion(ctx context.Context, client *github.Client, orgName, _, err := conf.WaitForStateContext(ctx) return err } + +func expandImage(imageList []any) (*github.HostedRunnerImage, error) { + if len(imageList) == 0 { + return &github.HostedRunnerImage{}, nil + } + runnerImage := &github.HostedRunnerImage{} + + imageMap, ok := imageList[0].(map[string]any) + if !ok { + return nil, fmt.Errorf("expected map[string]any, got %T", imageList[0]) + } + + if id, ok := imageMap["id"].(string); ok { + runnerImage.ID = id + } + if source, ok := imageMap["source"].(string); ok { + runnerImage.Source = source + } + + return runnerImage, nil +} + +func flattenImage(image *github.HostedRunnerImageDetail) []any { + if image == nil { + return []any{} + } + + result := make(map[string]any) + + result["id"] = image.GetID() + result["source"] = image.GetSource() + result["size_gb"] = image.GetSizeGB() + + return []any{result} +} + +func flattenMachineSizeDetails(details *github.HostedRunnerMachineSpec) []any { + if details == nil { + return []any{} + } + + result := make(map[string]any) + result["id"] = details.GetID() + result["cpu_cores"] = details.GetCPUCores() + result["memory_gb"] = details.GetMemoryGB() + result["storage_gb"] = details.GetStorageGB() + + return []any{result} +} + +func flattenPublicIPs(ips []*github.HostedRunnerPublicIP) []any { + if ips == nil { + return []any{} + } + + result := make([]any, 0, len(ips)) + for _, ip := range ips { + ipResult := make(map[string]any) + ipResult["enabled"] = ip.GetEnabled() + ipResult["prefix"] = ip.GetPrefix() + ipResult["length"] = ip.GetLength() + result = append(result, ipResult) + } + + return result +} + +func resourceGithubActionsHostedRunnerValidation(ctx context.Context, d *schema.ResourceDiff, meta any) error { + // validates that image_version is only set for images with source "custom" + if d.HasChange("image_version") { + imageList, ok := d.GetOk("image") + imageSeq, okSeq := imageList.([]any) + if !ok || !okSeq || len(imageSeq) == 0 { + return fmt.Errorf("`image_version` can only be set when `image` is configured") + } + + imageMap, ok := imageSeq[0].(map[string]any) + if !ok { + return fmt.Errorf("unexpected type for image: %T", imageSeq[0]) + } + + source, ok := imageMap["source"].(string) + if !ok || source != "custom" { + return fmt.Errorf("`image_version` can only be set when `image[0].source` is 'custom'") + } + } + return nil +} diff --git a/github/resource_github_actions_hosted_runner_test.go b/github/resource_github_actions_hosted_runner_test.go index 7b6a7f83fc..2a8f2c8c09 100644 --- a/github/resource_github_actions_hosted_runner_test.go +++ b/github/resource_github_actions_hosted_runner_test.go @@ -2,28 +2,31 @@ package github import ( "fmt" + "regexp" "testing" + "github.com/hashicorp/terraform-plugin-testing/compare" "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/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) func TestAccGithubActionsHostedRunner(t *testing.T) { t.Parallel() - randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - - t.Run("creates hosted runners without error", func(t *testing.T) { + t.Run("creates_hosted_runners_without_error", func(t *testing.T) { t.Parallel() - config := fmt.Sprintf(` - resource "github_actions_runner_group" "test" { - name = "tf-acc-test-group-%s" - visibility = "all" - } + runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t) + randomID := acctest.RandString(5) + hostedRunnerName := fmt.Sprintf("%srunner-%s", testResourcePrefix, randomID) + config := fmt.Sprintf(` resource "github_actions_hosted_runner" "test" { - name = "tf-acc-test-%s" + name = "%s" image { id = "2306" @@ -31,52 +34,9 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { } size = "4-core" - runner_group_id = github_actions_runner_group.test.id + runner_group_id = "%d" } - `, randomID, randomID) - - check := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "name", - fmt.Sprintf("tf-acc-test-%s", randomID), - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "size", - "4-core", - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "image.0.id", - "2306", - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "image.0.source", - "github", - ), - resource.TestCheckResourceAttrSet( - "github_actions_hosted_runner.test", "id", - ), - resource.TestCheckResourceAttrSet( - "github_actions_hosted_runner.test", "status", - ), - resource.TestCheckResourceAttrSet( - "github_actions_hosted_runner.test", "platform", - ), - resource.TestCheckResourceAttrSet( - "github_actions_hosted_runner.test", "image.0.size_gb", - ), - resource.TestCheckResourceAttrSet( - "github_actions_hosted_runner.test", "machine_size_details.0.id", - ), - resource.TestCheckResourceAttrSet( - "github_actions_hosted_runner.test", "machine_size_details.0.cpu_cores", - ), - resource.TestCheckResourceAttrSet( - "github_actions_hosted_runner.test", "machine_size_details.0.memory_gb", - ), - resource.TestCheckResourceAttrSet( - "github_actions_hosted_runner.test", "machine_size_details.0.storage_gb", - ), - ) + `, hostedRunnerName, runnerGroup.GetID()) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasPaidOrgs(t) }, @@ -84,23 +44,31 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: check, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("status"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("platform"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("image").AtSliceIndex(0).AtMapKey("size_gb"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("cpu_cores"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("memory_gb"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("storage_gb"), knownvalue.NotNull()), + }, }, }, }) }) - t.Run("creates hosted runner with optional parameters", func(t *testing.T) { + t.Run("creates_hosted_runner_with_optional_parameters", func(t *testing.T) { t.Parallel() - config := fmt.Sprintf(` - resource "github_actions_runner_group" "test" { - name = "tf-acc-test-group-%s" - visibility = "all" - } + runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t) + randomID := acctest.RandString(5) + hostedRunnerName := fmt.Sprintf("%srunner-optional-%s", testResourcePrefix, randomID) + config := fmt.Sprintf(` resource "github_actions_hosted_runner" "test" { - name = "tf-acc-test-optional-%s" + name = "%s" image { id = "2306" @@ -108,30 +76,11 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { } size = "2-core" - runner_group_id = github_actions_runner_group.test.id - maximum_runners = 5 + runner_group_id = "%d" + maximum_runners = 2 public_ip_enabled = true } - `, randomID, randomID) - - check := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "name", - fmt.Sprintf("tf-acc-test-optional-%s", randomID), - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "size", - "2-core", - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "maximum_runners", - "5", - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "public_ip_enabled", - "true", - ), - ) + `, hostedRunnerName, runnerGroup.GetID()) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasPaidOrgs(t) }, @@ -139,23 +88,21 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: check, }, }, }) }) - t.Run("updates hosted runner configuration", func(t *testing.T) { + t.Run("updates_hosted_runner_configuration", func(t *testing.T) { t.Parallel() - configBefore := fmt.Sprintf(` - resource "github_actions_runner_group" "test" { - name = "tf-acc-test-group-%s" - visibility = "all" - } + runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t) + randomID := acctest.RandString(5) + hostedRunnerName := fmt.Sprintf("%srunner-update-%s", testResourcePrefix, randomID) + configTmpl := ` resource "github_actions_hosted_runner" "test" { - name = "tf-acc-test-update-%s" + name = "%s" image { id = "2306" @@ -163,167 +110,97 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { } size = "4-core" - runner_group_id = github_actions_runner_group.test.id - maximum_runners = 3 - } - `, randomID, randomID) - - configAfter := fmt.Sprintf(` - resource "github_actions_runner_group" "test" { - name = "tf-acc-test-group-%s" - visibility = "all" + runner_group_id = "%d" + maximum_runners = %d } + ` - resource "github_actions_hosted_runner" "test" { - name = "tf-acc-test-update-%s-updated" - - image { - id = "2306" - source = "github" - } - - size = "4-core" - runner_group_id = github_actions_runner_group.test.id - maximum_runners = 5 - } - `, randomID, randomID) - - checkBefore := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "name", - fmt.Sprintf("tf-acc-test-update-%s", randomID), - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "size", - "4-core", - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "maximum_runners", - "3", - ), - ) - - checkAfter := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "name", - fmt.Sprintf("tf-acc-test-update-%s-updated", randomID), - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "size", - "4-core", - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "maximum_runners", - "5", - ), - ) - + compareMaxRunnersUpdated := statecheck.CompareValue(compare.ValuesDiffer()) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasPaidOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: configBefore, - Check: checkBefore, + Config: fmt.Sprintf(configTmpl, hostedRunnerName, runnerGroup.GetID(), 2), + ConfigStateChecks: []statecheck.StateCheck{ + compareMaxRunnersUpdated.AddStateValue("github_actions_hosted_runner.test", tfjsonpath.New("maximum_runners")), + }, }, { - Config: configAfter, - Check: checkAfter, + Config: fmt.Sprintf(configTmpl, fmt.Sprintf("%s-updated", hostedRunnerName), runnerGroup.GetID(), 3), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_actions_hosted_runner.test", plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("name"), knownvalue.StringExact(fmt.Sprintf("%s-updated", hostedRunnerName))), + compareMaxRunnersUpdated.AddStateValue("github_actions_hosted_runner.test", tfjsonpath.New("maximum_runners")), + }, }, }, }) }) - t.Run("updates size field", func(t *testing.T) { + t.Run("updates_size_field", func(t *testing.T) { t.Parallel() - configBefore := fmt.Sprintf(` - resource "github_actions_runner_group" "test" { - name = "tf-acc-test-group-%s" - visibility = "all" - } - - resource "github_actions_hosted_runner" "test" { - name = "tf-acc-test-size-%s" - - image { - id = "2306" - source = "github" - } - - size = "4-core" - runner_group_id = github_actions_runner_group.test.id - } - `, randomID, randomID) - - configAfter := fmt.Sprintf(` - resource "github_actions_runner_group" "test" { - name = "tf-acc-test-group-%s" - visibility = "all" - } + runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t) + randomID := acctest.RandString(5) + hostedRunnerName := fmt.Sprintf("%srunner-size-%s", testResourcePrefix, randomID) + configTmpl := ` resource "github_actions_hosted_runner" "test" { - name = "tf-acc-test-size-%s" + name = "%s" image { id = "2306" source = "github" } - size = "8-core" - runner_group_id = github_actions_runner_group.test.id + size = "%s" + runner_group_id = "%d" } - `, randomID, randomID) - - checkBefore := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "size", - "4-core", - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "machine_size_details.0.cpu_cores", - "4", - ), - ) - - checkAfter := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "size", - "8-core", - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "machine_size_details.0.cpu_cores", - "8", - ), - ) + ` + compareSizeUpdated := statecheck.CompareValue(compare.ValuesDiffer()) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasPaidOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: configBefore, - Check: checkBefore, + Config: fmt.Sprintf(configTmpl, hostedRunnerName, "4-core", runnerGroup.GetID()), + ConfigStateChecks: []statecheck.StateCheck{ + compareSizeUpdated.AddStateValue("github_actions_hosted_runner.test", tfjsonpath.New("size")), + statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("cpu_cores"), knownvalue.Int64Exact(4)), + }, }, { - Config: configAfter, - Check: checkAfter, + Config: fmt.Sprintf(configTmpl, hostedRunnerName, "8-core", runnerGroup.GetID()), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_actions_hosted_runner.test", plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + compareSizeUpdated.AddStateValue("github_actions_hosted_runner.test", tfjsonpath.New("size")), + statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("cpu_cores"), knownvalue.Int64Exact(8)), + }, }, }, }) }) - t.Run("imports hosted runner", func(t *testing.T) { + t.Run("imports_hosted_runner", func(t *testing.T) { t.Parallel() - config := fmt.Sprintf(` - resource "github_actions_runner_group" "test" { - name = "tf-acc-test-group-%s" - visibility = "all" - } + runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t) + randomID := acctest.RandString(5) + hostedRunnerName := fmt.Sprintf("%srunner-import-%s", testResourcePrefix, randomID) + config := fmt.Sprintf(` resource "github_actions_hosted_runner" "test" { - name = "tf-acc-test-import-%s" + name = "%s" image { id = "2306" @@ -331,19 +208,9 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { } size = "4-core" - runner_group_id = github_actions_runner_group.test.id + runner_group_id = "%d" } - `, randomID, randomID) - - check := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet( - "github_actions_hosted_runner.test", "id", - ), - resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "name", - fmt.Sprintf("tf-acc-test-import-%s", randomID), - ), - ) + `, hostedRunnerName, runnerGroup.GetID()) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasPaidOrgs(t) }, @@ -351,29 +218,27 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: check, }, { ResourceName: "github_actions_hosted_runner.test", ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"image", "image_gen"}, + ImportStateVerifyIgnore: []string{"status", "image.0.size_gb", "image_gen"}, }, }, }) }) - t.Run("deletes hosted runner", func(t *testing.T) { + t.Run("deletes_hosted_runner", func(t *testing.T) { t.Parallel() - config := fmt.Sprintf(` - resource "github_actions_runner_group" "test" { - name = "tf-acc-test-group-%s" - visibility = "all" - } + runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t) + randomID := acctest.RandString(5) + hostedRunnerName := fmt.Sprintf("%srunner-delete-%s", testResourcePrefix, randomID) + config := fmt.Sprintf(` resource "github_actions_hosted_runner" "test" { - name = "tf-acc-test-delete-%s" + name = "%s" image { id = "2306" @@ -381,9 +246,9 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { } size = "4-core" - runner_group_id = github_actions_runner_group.test.id + runner_group_id = "%d" } - `, randomID, randomID) + `, hostedRunnerName, runnerGroup.GetID()) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasPaidOrgs(t) }, @@ -391,20 +256,42 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet( - "github_actions_hosted_runner.test", "id", - ), - ), }, - // This step should successfully delete the runner { - Config: fmt.Sprintf(` - resource "github_actions_runner_group" "test" { - name = "tf-acc-test-group-%s" - visibility = "all" - } - `, randomID), + Config: config, + Destroy: true, + }, + }, + }) + }) + + t.Run("validates_image_version_only_allowed_custom_image", func(t *testing.T) { + t.Parallel() + + runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t) + randomID := acctest.RandString(5) + hostedRunnerName := fmt.Sprintf("%simage-version-%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` +resource "github_actions_hosted_runner" "test" { + name = "%s" + + image { + id = "2306" + source = "github" + } + image_version = "1.0.0" + size = "4-core" + runner_group_id = "%d" +}`, hostedRunnerName, runnerGroup.GetID()) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("`image_version` can only be set when `image\\[0\\].source` is 'custom'"), }, }, }) diff --git a/templates/resources/actions_hosted_runner.md.tmpl b/templates/resources/actions_hosted_runner.md.tmpl index 166b5c73e7..23de6892ba 100644 --- a/templates/resources/actions_hosted_runner.md.tmpl +++ b/templates/resources/actions_hosted_runner.md.tmpl @@ -1,86 +1,20 @@ --- page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +subcategory: "" description: |- - Creates and manages GitHub-hosted runners within a GitHub organization +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} --- # {{.Name}} ({{.Type}}) -This resource allows you to create and manage GitHub-hosted runners within your GitHub organization. You must have admin access to an organization to use this resource. - -GitHub-hosted runners are fully managed virtual machines that run your GitHub Actions workflows. Unlike self-hosted runners, GitHub handles the infrastructure, maintenance, and scaling. - -## Example Usage - -### Basic Usage - -{{ tffile "examples/resources/actions_hosted_runner/example_1.tf" }} - -### Advanced Usage with Optional Parameters - -{{ tffile "examples/resources/actions_hosted_runner/example_2.tf" }} - -## Argument Reference - -The following arguments are supported: - -- `name` - (Required) Name of the hosted runner. Must be between 1 and 64 characters and may only contain alphanumeric characters, '.', '-', and '_'. -- `image` - (Required) Image configuration for the hosted runner. Cannot be changed after creation. Block supports: - - `id` - (Required) The image ID. For GitHub-owned images, use numeric IDs like "2306" for Ubuntu Latest 24.04. To get available images, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/images/github-owned`. - - `source` - (Optional) The image source. Valid values are "github", "partner", or "custom". Defaults to "github". -- `size` - (Required) Machine size for the hosted runner (e.g., "4-core", "8-core"). Can be updated to scale the runner. To list available sizes, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/machine-sizes`. -- `runner_group_id` - (Required) The ID of the runner group to assign this runner to. -- `maximum_runners` - (Optional) Maximum number of runners to scale up to. Runners will not auto-scale above this number. Use this setting to limit costs. -- `public_ip_enabled` - (Optional) Whether to enable static public IP for the runner. Note there are account limits. To list limits, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/limits`. Defaults to false. -- `image_version` - (Optional) The version of the runner image to deploy. This is only relevant for runners using custom images. - -## Timeouts - -The `timeouts` block allows you to specify timeouts for certain actions: - -- `delete` - (Defaults to 10 minutes) Used for waiting for the hosted runner deletion to complete. - -Example: - -{{ tffile "examples/resources/actions_hosted_runner/example_3.tf" }} - -## Attributes Reference - -In addition to the arguments above, the following attributes are exported: - -- `id` - The ID of the hosted runner. -- `status` - Current status of the runner (e.g., "Ready", "Provisioning"). -- `platform` - Platform of the runner (e.g., "linux-x64", "win-x64"). -- `image` - In addition to the arguments above, the image block exports: - - `size_gb` - The size of the image in gigabytes. -- `machine_size_details` - Detailed specifications of the machine size: - - `id` - Machine size identifier. - - `cpu_cores` - Number of CPU cores. - - `memory_gb` - Amount of memory in gigabytes. - - `storage_gb` - Amount of storage in gigabytes. -- `public_ips` - List of public IP ranges assigned to this runner (only if `public_ip_enabled` is true): - - `enabled` - Whether this IP range is enabled. - - `prefix` - IP address prefix. - - `length` - Subnet length. -- `last_active_on` - Timestamp (RFC3339) when the runner was last active. - -## Import - -Hosted runners can be imported using the runner ID: - -```shell -terraform import github_actions_hosted_runner.example 123456 -``` +{{ .Description | trimspace }} ## Notes - This resource is **organization-only** and cannot be used with individual accounts. -- The `image` field cannot be changed after the runner is created. Changing it will force recreation of the runner. -- The `size` field can be updated to scale the runner up or down as needed. - Image IDs for GitHub-owned images are numeric strings (e.g., "2306" for Ubuntu Latest 24.04), not names like "ubuntu-latest". - Deletion of hosted runners is asynchronous. The provider will poll for up to 10 minutes (configurable via timeouts) to confirm deletion. - Runner creation and updates may take several minutes as GitHub provisions the infrastructure. -- Static public IPs are subject to account limits. Check your organization's limits before enabling. ## Getting Available Images and Sizes @@ -99,3 +33,47 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/orgs/YOUR_ORG/actions/hosted-runners/machine-sizes ``` + +{{ if .HasExamples -}} +## Example Usage + +{{- range .ExampleFiles }} + +{{- $parts := split . "resource_" -}} +{{- $lastItem := index $parts 1 }} +{{- $fileNameParts := split $lastItem "." }} +{{- $fileName := index $fileNameParts 0 }} + +### {{ $fileName | title }} Usage + +{{ tffile . }} +{{- end }} +{{- end }} + +{{ .SchemaMarkdown | trimspace }} +{{- if or .HasImport .HasImportIDConfig .HasImportIdentityConfig }} + +## Import + +Import is supported using the following syntax: +{{- end }} +{{- if .HasImportIdentityConfig }} + +In Terraform v1.12.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `identity` attribute, for example: + +{{tffile .ImportIdentityConfigFile }} + +{{ .IdentitySchemaMarkdown | trimspace }} +{{- end }} +{{- if .HasImportIDConfig }} + +In Terraform v1.5.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `id` attribute, for example: + +{{tffile .ImportIDConfigFile }} +{{- end }} +{{- if .HasImport }} + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +{{codefile "shell" .ImportFile }} +{{- end }}