From 6e0afdfa0b9411d59582cdc57e040a98d8bdac84 Mon Sep 17 00:00:00 2001 From: Austen Stone Date: Thu, 13 Nov 2025 12:21:47 -0500 Subject: [PATCH 1/8] feat: Add GitHub Actions hosted runner resource and examples --- examples/hosted_runner/main.tf | 31 ++ github/provider.go | 1 + .../resource_github_actions_hosted_runner.go | 372 ++++++++++++++++++ ...ource_github_actions_hosted_runner_test.go | 343 ++++++++++++++++ 4 files changed, 747 insertions(+) create mode 100644 examples/hosted_runner/main.tf create mode 100644 github/resource_github_actions_hosted_runner.go create mode 100644 github/resource_github_actions_hosted_runner_test.go diff --git a/examples/hosted_runner/main.tf b/examples/hosted_runner/main.tf new file mode 100644 index 0000000000..d3b59f6259 --- /dev/null +++ b/examples/hosted_runner/main.tf @@ -0,0 +1,31 @@ +resource "github_actions_runner_group" "example" { + name = "example-runner-group" + visibility = "all" +} + +resource "github_actions_hosted_runner" "example" { + name = "example-hosted-runner" + + image { + id = "ubuntu-latest" + source = "github" + } + + size = "4-core" + runner_group_id = github_actions_runner_group.example.id +} + +# Advanced example with optional parameters +resource "github_actions_hosted_runner" "advanced" { + name = "advanced-hosted-runner" + + image { + id = "ubuntu-latest" + source = "github" + } + + size = "8-core" + runner_group_id = github_actions_runner_group.example.id + maximum_runners = 10 + enable_static_ip = true +} diff --git a/github/provider.go b/github/provider.go index 89ad8702a5..dc3881d62f 100644 --- a/github/provider.go +++ b/github/provider.go @@ -145,6 +145,7 @@ func Provider() *schema.Provider { "github_actions_repository_oidc_subject_claim_customization_template": resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplate(), "github_actions_repository_permissions": resourceGithubActionsRepositoryPermissions(), "github_actions_runner_group": resourceGithubActionsRunnerGroup(), + "github_actions_hosted_runner": resourceGithubActionsHostedRunner(), "github_actions_secret": resourceGithubActionsSecret(), "github_actions_variable": resourceGithubActionsVariable(), "github_app_installation_repositories": resourceGithubAppInstallationRepositories(), diff --git a/github/resource_github_actions_hosted_runner.go b/github/resource_github_actions_hosted_runner.go new file mode 100644 index 0000000000..06508c2e21 --- /dev/null +++ b/github/resource_github_actions_hosted_runner.go @@ -0,0 +1,372 @@ +package github + +import ( + "context" + "fmt" + "log" + "net/http" + "strconv" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubActionsHostedRunner() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubActionsHostedRunnerCreate, + Read: resourceGithubActionsHostedRunnerRead, + Update: resourceGithubActionsHostedRunnerUpdate, + Delete: resourceGithubActionsHostedRunnerDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the hosted runner.", + }, + "image": { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Required: true, + Description: "The image ID.", + }, + "source": { + Type: schema.TypeString, + Optional: true, + Default: "github", + Description: "The image source.", + }, + }, + }, + Description: "Image configuration for the hosted runner.", + }, + "size": { + Type: schema.TypeString, + Required: true, + Description: "Machine size (e.g., '4-core', '8-core').", + }, + "runner_group_id": { + Type: schema.TypeInt, + Required: true, + Description: "The runner group ID.", + }, + "maximum_runners": { + Type: schema.TypeInt, + Optional: true, + Description: "Maximum number of runners to scale up to.", + }, + "enable_static_ip": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Whether to enable static IP.", + }, + // Computed fields + "id": { + Type: schema.TypeInt, + Computed: true, + Description: "The hosted runner ID.", + }, + "status": { + Type: schema.TypeString, + Computed: true, + Description: "Current status of the runner.", + }, + "platform": { + Type: schema.TypeString, + Computed: true, + Description: "Platform of the runner.", + }, + }, + } +} + +// expandImage converts the Terraform image configuration to a map for API calls +func expandImage(imageList []interface{}) map[string]interface{} { + if len(imageList) == 0 { + return nil + } + + imageMap := imageList[0].(map[string]interface{}) + result := make(map[string]interface{}) + + if id, ok := imageMap["id"].(string); ok { + result["id"] = id + } + if source, ok := imageMap["source"].(string); ok { + result["source"] = source + } + + return result +} + +// flattenImage converts the API image response to Terraform format +func flattenImage(image map[string]interface{}) []interface{} { + if image == nil { + return []interface{}{} + } + + result := make(map[string]interface{}) + if id, ok := image["id"].(string); ok { + result["id"] = id + } + if source, ok := image["source"].(string); ok { + result["source"] = source + } + + return []interface{}{result} +} + +func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + ctx := context.Background() + + // Build request payload + payload := map[string]interface{}{ + "name": d.Get("name").(string), + "image": expandImage(d.Get("image").([]interface{})), + "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) + } + + if v, ok := d.GetOk("enable_static_ip"); ok { + payload["enable_static_ip"] = v.(bool) + } + + // Create HTTP request + req, err := client.NewRequest("POST", fmt.Sprintf("orgs/%s/actions/hosted-runners", orgName), payload) + if err != nil { + return err + } + + var runner map[string]interface{} + resp, err := client.Do(ctx, req, &runner) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // 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") + } + + return resourceGithubActionsHostedRunnerRead(d, meta) +} + +func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + ctx := context.Background() + + runnerID := d.Id() + + // Create GET request + req, err := client.NewRequest("GET", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil) + if err != nil { + return err + } + + var runner map[string]interface{} + resp, err := client.Do(ctx, req, &runner) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Removing hosted runner %s from state because it no longer exists in GitHub", runnerID) + d.SetId("") + return nil + } + return err + } + + // Set computed attributes + if name, ok := runner["name"].(string); ok { + d.Set("name", name) + } + if status, ok := runner["status"].(string); ok { + d.Set("status", status) + } + if platform, ok := runner["platform"].(string); ok { + d.Set("platform", platform) + } + if image, ok := runner["image"].(map[string]interface{}); ok { + d.Set("image", flattenImage(image)) + } + if size, ok := runner["size"].(string); ok { + d.Set("size", size) + } + if runnerGroupID, ok := runner["runner_group_id"].(float64); ok { + d.Set("runner_group_id", int(runnerGroupID)) + } + if maxRunners, ok := runner["maximum_runners"].(float64); ok { + d.Set("maximum_runners", int(maxRunners)) + } + if staticIP, ok := runner["enable_static_ip"].(bool); ok { + d.Set("enable_static_ip", staticIP) + } + + return nil +} + +func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + ctx := context.Background() + + runnerID := d.Id() + + // Build update payload + payload := make(map[string]interface{}) + + if d.HasChange("name") { + payload["name"] = d.Get("name").(string) + } + if d.HasChange("image") { + payload["image"] = expandImage(d.Get("image").([]interface{})) + } + if d.HasChange("size") { + payload["size"] = d.Get("size").(string) + } + if d.HasChange("runner_group_id") { + payload["runner_group_id"] = d.Get("runner_group_id").(int) + } + if d.HasChange("maximum_runners") { + payload["maximum_runners"] = d.Get("maximum_runners").(int) + } + if d.HasChange("enable_static_ip") { + payload["enable_static_ip"] = d.Get("enable_static_ip").(bool) + } + + // Create PATCH request + req, err := client.NewRequest("PATCH", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), payload) + if err != nil { + return err + } + + var runner map[string]interface{} + resp, err := client.Do(ctx, req, &runner) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return resourceGithubActionsHostedRunnerRead(d, meta) +} + +func resourceGithubActionsHostedRunnerDelete(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + ctx := context.Background() + + runnerID := d.Id() + + // Send DELETE request + req, err := client.NewRequest("DELETE", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil) + if err != nil { + return err + } + + resp, err := client.Do(ctx, req, nil) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + // Already deleted + return nil + } + return err + } + + // Handle async deletion (202 Accepted) + if resp.StatusCode == http.StatusAccepted { + log.Printf("[DEBUG] Hosted runner %s deletion accepted, polling for completion", runnerID) + return waitForRunnerDeletion(client, orgName, runnerID, ctx) + } + + return nil +} + +// waitForRunnerDeletion polls the API until the runner is deleted or times out +func waitForRunnerDeletion(client *http.Client, orgName, runnerID string, ctx context.Context) error { + timeout := time.After(10 * time.Minute) + interval := 30 * time.Second + ticker := time.NewTicker(interval) + defer ticker.Stop() + + attempts := 0 + maxInterval := 2 * time.Minute + + for { + select { + case <-timeout: + return fmt.Errorf("timeout waiting for hosted runner %s to be deleted after 10 minutes", runnerID) + case <-ticker.C: + attempts++ + + // Check if runner still exists + req, err := client.NewRequest("GET", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil) + if err != nil { + return err + } + + resp, err := client.Do(ctx, req, nil) + if err != nil { + // If 404, runner is deleted successfully + if resp != nil && resp.StatusCode == http.StatusNotFound { + log.Printf("[DEBUG] Hosted runner %s successfully deleted after %d attempts", runnerID, attempts) + return nil + } + return err + } + + // Runner still exists, continue polling with exponential backoff + log.Printf("[DEBUG] Hosted runner %s still exists, continuing to poll (attempt %d)", runnerID, attempts) + + // Increase interval with exponential backoff, capped at maxInterval + newInterval := time.Duration(float64(interval) * 1.5) + if newInterval > maxInterval { + newInterval = maxInterval + } + interval = newInterval + ticker.Reset(interval) + } + } +} diff --git a/github/resource_github_actions_hosted_runner_test.go b/github/resource_github_actions_hosted_runner_test.go new file mode 100644 index 0000000000..f0bb3a06c8 --- /dev/null +++ b/github/resource_github_actions_hosted_runner_test.go @@ -0,0 +1,343 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubActionsHostedRunner(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("creates hosted runners without error", func(t *testing.T) { + config := 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-%s" + + image { + id = "ubuntu-latest" + source = "github" + } + + size = "4-core" + runner_group_id = github_actions_runner_group.test.id + } + `, 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", + "ubuntu-latest", + ), + 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", + ), + ) + + 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 hosted runners") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("creates hosted runner with optional parameters", func(t *testing.T) { + config := 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-optional-%s" + + image { + id = "ubuntu-latest" + source = "github" + } + + size = "8-core" + runner_group_id = github_actions_runner_group.test.id + maximum_runners = 5 + enable_static_ip = 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", + "8-core", + ), + resource.TestCheckResourceAttr( + "github_actions_hosted_runner.test", "maximum_runners", + "5", + ), + resource.TestCheckResourceAttr( + "github_actions_hosted_runner.test", "enable_static_ip", + "true", + ), + ) + + 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 organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("updates hosted runner configuration", func(t *testing.T) { + 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-update-%s" + + image { + id = "ubuntu-latest" + source = "github" + } + + 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" + } + + resource "github_actions_hosted_runner" "test" { + name = "tf-acc-test-update-%s-updated" + + image { + id = "ubuntu-latest" + source = "github" + } + + size = "8-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", + "8-core", + ), + resource.TestCheckResourceAttr( + "github_actions_hosted_runner.test", "maximum_runners", + "5", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: configBefore, + Check: checkBefore, + }, + { + Config: configAfter, + Check: checkAfter, + }, + }, + }) + } + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("imports hosted runner", func(t *testing.T) { + config := 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-import-%s" + + image { + id = "ubuntu-latest" + source = "github" + } + + size = "4-core" + runner_group_id = github_actions_runner_group.test.id + } + `, 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), + ), + ) + + 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_hosted_runner.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + } + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("deletes hosted runner", func(t *testing.T) { + config := 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-delete-%s" + + image { + id = "ubuntu-latest" + source = "github" + } + + size = "4-core" + runner_group_id = github_actions_runner_group.test.id + } + `, randomID, 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: 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), + }, + }, + }) + } + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) +} From b1b44986b8e112c09128155a34e9b7297ba99ff1 Mon Sep 17 00:00:00 2001 From: Austen Stone Date: Thu, 13 Nov 2025 12:29:22 -0500 Subject: [PATCH 2/8] feat: Add documentation for GitHub Actions hosted runner resource --- .../r/actions_hosted_runner.html.markdown | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 website/docs/r/actions_hosted_runner.html.markdown diff --git a/website/docs/r/actions_hosted_runner.html.markdown b/website/docs/r/actions_hosted_runner.html.markdown new file mode 100644 index 0000000000..fd7c39d9ca --- /dev/null +++ b/website/docs/r/actions_hosted_runner.html.markdown @@ -0,0 +1,94 @@ +--- +layout: "github" +page_title: "GitHub: github_actions_hosted_runner" +description: |- + Creates and manages GitHub-hosted runners within a GitHub organization +--- + +# github_actions_hosted_runner + +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 + +```hcl +resource "github_actions_runner_group" "example" { + name = "example-runner-group" + visibility = "all" +} + +resource "github_actions_hosted_runner" "example" { + name = "example-hosted-runner" + + image { + id = "ubuntu-latest" + source = "github" + } + + size = "4-core" + runner_group_id = github_actions_runner_group.example.id +} +``` + +### Advanced Usage with Optional Parameters + +```hcl +resource "github_actions_runner_group" "advanced" { + name = "advanced-runner-group" + visibility = "selected" +} + +resource "github_actions_hosted_runner" "advanced" { + name = "advanced-hosted-runner" + + image { + id = "ubuntu-latest" + source = "github" + } + + size = "8-core" + runner_group_id = github_actions_runner_group.advanced.id + maximum_runners = 10 + enable_static_ip = true +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) Name of the hosted runner. +* `image` - (Required) Image configuration for the hosted runner. Block supports: + * `id` - (Required) The image ID (e.g., "ubuntu-latest"). + * `source` - (Optional) The image source. Defaults to "github". +* `size` - (Required) Machine size for the hosted runner (e.g., "4-core", "8-core"). +* `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. +* `enable_static_ip` - (Optional) Whether to enable static IP for the runner. Defaults to false. + +## 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. +* `platform` - Platform of the runner (e.g., "linux", "windows"). + +## Import + +Hosted runners can be imported using the runner ID: + +``` +$ terraform import github_actions_hosted_runner.example 123456 +``` + +## Notes + +* This resource is **organization-only** and cannot be used with individual accounts. +* Deletion of hosted runners is asynchronous. The provider will poll for up to 10 minutes to confirm deletion. +* Runner creation and updates may take several minutes as GitHub provisions the infrastructure. From 8cabf1fa4cb63463544eb63b6a4cdef69fa3740a Mon Sep 17 00:00:00 2001 From: Austen Stone Date: Thu, 13 Nov 2025 14:35:15 -0500 Subject: [PATCH 3/8] feat: Update GitHub Actions hosted runner resource to use numeric image IDs and add new attributes --- examples/hosted_runner/main.tf | 7 +- .../resource_github_actions_hosted_runner.go | 308 ++++++++++++++---- ...ource_github_actions_hosted_runner_test.go | 43 ++- .../r/actions_hosted_runner.html.markdown | 90 ++++- 4 files changed, 348 insertions(+), 100 deletions(-) diff --git a/examples/hosted_runner/main.tf b/examples/hosted_runner/main.tf index d3b59f6259..0f1838cb40 100644 --- a/examples/hosted_runner/main.tf +++ b/examples/hosted_runner/main.tf @@ -3,11 +3,14 @@ resource "github_actions_runner_group" "example" { visibility = "all" } +# NOTE: You must first query available images using the GitHub API: +# GET /orgs/{org}/actions/hosted-runners/images/github-owned +# The image ID is numeric, not a string like "ubuntu-latest" resource "github_actions_hosted_runner" "example" { name = "example-hosted-runner" image { - id = "ubuntu-latest" + id = "2306" # Ubuntu Latest (24.04) - query your org for available IDs source = "github" } @@ -20,7 +23,7 @@ resource "github_actions_hosted_runner" "advanced" { name = "advanced-hosted-runner" image { - id = "ubuntu-latest" + id = "2306" # Ubuntu Latest (24.04) - query your org for available IDs source = "github" } diff --git a/github/resource_github_actions_hosted_runner.go b/github/resource_github_actions_hosted_runner.go index 06508c2e21..5052965290 100644 --- a/github/resource_github_actions_hosted_runner.go +++ b/github/resource_github_actions_hosted_runner.go @@ -5,10 +5,14 @@ import ( "fmt" "log" "net/http" + "regexp" "strconv" "time" + "github.com/google/go-github/v67/github" + "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" ) func resourceGithubActionsHostedRunner() *schema.Resource { @@ -21,15 +25,27 @@ func resourceGithubActionsHostedRunner() *schema.Resource { StateContext: schema.ImportStatePassthroughContext, }, + Timeouts: &schema.ResourceTimeout{ + Delete: schema.DefaultTimeout(10 * time.Minute), + }, + Schema: map[string]*schema.Schema{ "name": { - Type: schema.TypeString, - Required: true, - Description: "Name of the hosted runner.", + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.All( + validation.StringLenBetween(1, 64), + validation.StringMatch( + regexp.MustCompile(`^[a-zA-Z0-9._-]+$`), + "name may only contain alphanumeric characters, '.', '-', and '_'", + ), + ), + 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, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ @@ -42,16 +58,22 @@ func resourceGithubActionsHostedRunner() *schema.Resource { Type: schema.TypeString, Optional: true, Default: "github", - Description: "The image source.", + Description: "The image source (github, partner, or custom).", + }, + "size_gb": { + Type: schema.TypeInt, + Computed: true, + Description: "The size of the image in GB.", }, }, }, - Description: "Image configuration for the hosted runner.", + 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').", + ForceNew: true, + Description: "Machine size (e.g., '4-core', '8-core'). Cannot be changed after creation.", }, "runner_group_id": { Type: schema.TypeInt, @@ -61,17 +83,29 @@ func resourceGithubActionsHostedRunner() *schema.Resource { "maximum_runners": { Type: schema.TypeInt, Optional: true, + Computed: true, Description: "Maximum number of runners to scale up to.", }, - "enable_static_ip": { + "public_ip_enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Whether to enable static public IP.", + }, + "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.", + }, + "image_gen": { Type: schema.TypeBool, Optional: true, + ForceNew: true, Default: false, - Description: "Whether to enable static IP.", + Description: "Whether this runner should be used to generate custom images. Cannot be changed after creation.", }, - // Computed fields "id": { - Type: schema.TypeInt, + Type: schema.TypeString, Computed: true, Description: "The hosted runner ID.", }, @@ -85,11 +119,68 @@ func resourceGithubActionsHostedRunner() *schema.Resource { Computed: true, Description: "Platform of the runner.", }, + "machine_size_details": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Computed: true, + Description: "Machine size ID.", + }, + "cpu_cores": { + Type: schema.TypeInt, + Computed: true, + Description: "Number of CPU cores.", + }, + "memory_gb": { + Type: schema.TypeInt, + Computed: true, + Description: "Memory in GB.", + }, + "storage_gb": { + Type: schema.TypeInt, + Computed: true, + Description: "Storage in GB.", + }, + }, + }, + Description: "Detailed machine size specifications.", + }, + "public_ips": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether this IP range is enabled.", + }, + "prefix": { + Type: schema.TypeString, + Computed: true, + Description: "IP address prefix.", + }, + "length": { + Type: schema.TypeInt, + Computed: true, + Description: "Subnet length.", + }, + }, + }, + Description: "List of public IP ranges assigned to this runner.", + }, + "last_active_on": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the runner was last active.", + }, }, } } -// expandImage converts the Terraform image configuration to a map for API calls func expandImage(imageList []interface{}) map[string]interface{} { if len(imageList) == 0 { return nil @@ -108,7 +199,6 @@ func expandImage(imageList []interface{}) map[string]interface{} { return result } -// flattenImage converts the API image response to Terraform format func flattenImage(image map[string]interface{}) []interface{} { if image == nil { return []interface{}{} @@ -121,10 +211,63 @@ func flattenImage(image map[string]interface{}) []interface{} { if source, ok := image["source"].(string); ok { result["source"] = source } + if size, ok := image["size"].(float64); ok { + result["size_gb"] = int(size) + } + + return []interface{}{result} +} + +func flattenMachineSizeDetails(details map[string]interface{}) []interface{} { + if details == nil { + return []interface{}{} + } + + result := make(map[string]interface{}) + 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) + } return []interface{}{result} } +func flattenPublicIPs(ips []interface{}) []interface{} { + if ips == nil { + return []interface{}{} + } + + result := make([]interface{}, 0, len(ips)) + for _, ip := range ips { + ipMap, ok := ip.(map[string]interface{}) + if !ok { + continue + } + + ipResult := make(map[string]interface{}) + 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) + } + + return result +} + func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta interface{}) error { err := checkOrganization(meta) if err != nil { @@ -147,10 +290,18 @@ func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta interf payload["maximum_runners"] = v.(int) } - if v, ok := d.GetOk("enable_static_ip"); ok { + if v, ok := d.GetOk("public_ip_enabled"); ok { payload["enable_static_ip"] = v.(bool) } + if v, ok := d.GetOk("image_version"); ok { + payload["image_version"] = v.(string) + } + + if v, ok := d.GetOk("image_gen"); ok { + payload["image_gen"] = v.(bool) + } + // Create HTTP request req, err := client.NewRequest("POST", fmt.Sprintf("orgs/%s/actions/hosted-runners", orgName), payload) if err != nil { @@ -160,10 +311,19 @@ func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta interf var runner map[string]interface{} resp, err := client.Do(ctx, req, &runner) if err != nil { - return err + // Handle accepted error (202) which means the runner is being created asynchronously + if _, ok := err.(*github.AcceptedError); ok { + log.Printf("[INFO] Hosted runner is being created asynchronously") + // Continue processing if we have runner data + if runner == nil { + return fmt.Errorf("runner information not available after accepted status") + } + } else { + return err + } } - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + if resp != nil && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { return fmt.Errorf("unexpected status code: %d", resp.StatusCode) } @@ -219,8 +379,10 @@ func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta interfac if image, ok := runner["image"].(map[string]interface{}); ok { d.Set("image", flattenImage(image)) } - if size, ok := runner["size"].(string); ok { - d.Set("size", size) + if machineSizeDetails, ok := runner["machine_size_details"].(map[string]interface{}); ok { + if sizeID, ok := machineSizeDetails["id"].(string); ok { + d.Set("size", sizeID) + } } if runnerGroupID, ok := runner["runner_group_id"].(float64); ok { d.Set("runner_group_id", int(runnerGroupID)) @@ -228,8 +390,17 @@ func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta interfac if maxRunners, ok := runner["maximum_runners"].(float64); ok { d.Set("maximum_runners", int(maxRunners)) } - if staticIP, ok := runner["enable_static_ip"].(bool); ok { - d.Set("enable_static_ip", staticIP) + if publicIPEnabled, ok := runner["public_ip_enabled"].(bool); ok { + d.Set("public_ip_enabled", publicIPEnabled) + } + if machineSizeDetails, ok := runner["machine_size_details"].(map[string]interface{}); ok { + d.Set("machine_size_details", flattenMachineSizeDetails(machineSizeDetails)) + } + if publicIPs, ok := runner["public_ips"].([]interface{}); ok { + d.Set("public_ips", flattenPublicIPs(publicIPs)) + } + if lastActiveOn, ok := runner["last_active_on"].(string); ok { + d.Set("last_active_on", lastActiveOn) } return nil @@ -247,26 +418,23 @@ func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta interf runnerID := d.Id() - // Build update payload + // Build update payload (only fields that can be updated per API docs) payload := make(map[string]interface{}) if d.HasChange("name") { payload["name"] = d.Get("name").(string) } - if d.HasChange("image") { - payload["image"] = expandImage(d.Get("image").([]interface{})) - } - if d.HasChange("size") { - payload["size"] = d.Get("size").(string) - } if d.HasChange("runner_group_id") { payload["runner_group_id"] = d.Get("runner_group_id").(int) } if d.HasChange("maximum_runners") { payload["maximum_runners"] = d.Get("maximum_runners").(int) } - if d.HasChange("enable_static_ip") { - payload["enable_static_ip"] = d.Get("enable_static_ip").(bool) + if d.HasChange("public_ip_enabled") { + payload["enable_static_ip"] = d.Get("public_ip_enabled").(bool) + } + if d.HasChange("image_version") { + payload["image_version"] = d.Get("image_version").(string) } // Create PATCH request @@ -278,10 +446,16 @@ func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta interf var runner map[string]interface{} resp, err := client.Do(ctx, req, &runner) if err != nil { - return err + // Handle accepted error (202) which means the update is being processed asynchronously + if _, ok := err.(*github.AcceptedError); ok { + log.Printf("[INFO] Hosted runner update is being processed asynchronously") + // Continue to read the current state + } else { + return err + } } - if resp.StatusCode != http.StatusOK { + if resp != nil && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { return fmt.Errorf("unexpected status code: %d", resp.StatusCode) } @@ -312,61 +486,59 @@ func resourceGithubActionsHostedRunnerDelete(d *schema.ResourceData, meta interf // Already deleted return nil } + // Handle accepted error (202) which means the deletion is being processed asynchronously + if _, ok := err.(*github.AcceptedError); ok { + log.Printf("[DEBUG] Hosted runner %s deletion accepted, polling for completion", runnerID) + return waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete)) + } return err } // Handle async deletion (202 Accepted) - if resp.StatusCode == http.StatusAccepted { + if resp != nil && resp.StatusCode == http.StatusAccepted { log.Printf("[DEBUG] Hosted runner %s deletion accepted, polling for completion", runnerID) - return waitForRunnerDeletion(client, orgName, runnerID, ctx) + return waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete)) } return nil } -// waitForRunnerDeletion polls the API until the runner is deleted or times out -func waitForRunnerDeletion(client *http.Client, orgName, runnerID string, ctx context.Context) error { - timeout := time.After(10 * time.Minute) - interval := 30 * time.Second - ticker := time.NewTicker(interval) - defer ticker.Stop() - - attempts := 0 - maxInterval := 2 * time.Minute - - for { - select { - case <-timeout: - return fmt.Errorf("timeout waiting for hosted runner %s to be deleted after 10 minutes", runnerID) - case <-ticker.C: - attempts++ - - // Check if runner still exists +func waitForRunnerDeletion(ctx context.Context, client *github.Client, orgName, runnerID string, timeout time.Duration) error { + conf := &retry.StateChangeConf{ + Pending: []string{"deleting", "active"}, // Any state that is NOT the target state + Target: []string{"deleted"}, // The state we are waiting for + Refresh: func() (interface{}, string, error) { + // This function is called to check the resource's status req, err := client.NewRequest("GET", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil) if err != nil { - return err + return nil, "", err // This error stops the poller } resp, err := client.Do(ctx, req, nil) - if err != nil { - // If 404, runner is deleted successfully - if resp != nil && resp.StatusCode == http.StatusNotFound { - log.Printf("[DEBUG] Hosted runner %s successfully deleted after %d attempts", runnerID, attempts) - return nil - } - return err - } - // Runner still exists, continue polling with exponential backoff - log.Printf("[DEBUG] Hosted runner %s still exists, continuing to poll (attempt %d)", runnerID, attempts) + // 404 Not Found means it's successfully deleted + if resp != nil && resp.StatusCode == http.StatusNotFound { + log.Printf("[DEBUG] Hosted runner %s successfully deleted", runnerID) + return "deleted", "deleted", nil + } - // Increase interval with exponential backoff, capped at maxInterval - newInterval := time.Duration(float64(interval) * 1.5) - if newInterval > maxInterval { - newInterval = maxInterval + if err != nil { + // Return the error - StateChangeConf will continue retrying as long as we're in a Pending state + // If this is a transient error, it will be retried; if fatal, it will stop the poller + log.Printf("[DEBUG] Error checking runner status (will retry): %v", err) + return nil, "deleting", err } - interval = newInterval - ticker.Reset(interval) - } + + // If it's not 404, we assume it's still being deleted + log.Printf("[DEBUG] Hosted runner %s still exists, continuing to poll", runnerID) + return "deleting", "deleting", nil + }, + Timeout: timeout, // Use the timeout from the resource schema + Delay: 10 * time.Second, // Initial delay before first check + MinTimeout: 5 * time.Second, // Minimum time to wait between checks } + + // Run the poller - this will block until "deleted" is returned, an error occurs, or it times out + _, err := conf.WaitForStateContext(ctx) + return err } diff --git a/github/resource_github_actions_hosted_runner_test.go b/github/resource_github_actions_hosted_runner_test.go index f0bb3a06c8..8fcb56bff6 100644 --- a/github/resource_github_actions_hosted_runner_test.go +++ b/github/resource_github_actions_hosted_runner_test.go @@ -22,7 +22,7 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { name = "tf-acc-test-%s" image { - id = "ubuntu-latest" + id = "2306" source = "github" } @@ -42,7 +42,7 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { ), resource.TestCheckResourceAttr( "github_actions_hosted_runner.test", "image.0.id", - "ubuntu-latest", + "2306", ), resource.TestCheckResourceAttr( "github_actions_hosted_runner.test", "image.0.source", @@ -57,6 +57,21 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { 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", + ), ) testCase := func(t *testing.T, mode string) { @@ -96,14 +111,14 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { name = "tf-acc-test-optional-%s" image { - id = "ubuntu-latest" + id = "2306" source = "github" } - size = "8-core" - runner_group_id = github_actions_runner_group.test.id - maximum_runners = 5 - enable_static_ip = true + size = "8-core" + runner_group_id = github_actions_runner_group.test.id + maximum_runners = 5 + public_ip_enabled = true } `, randomID, randomID) @@ -121,7 +136,7 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { "5", ), resource.TestCheckResourceAttr( - "github_actions_hosted_runner.test", "enable_static_ip", + "github_actions_hosted_runner.test", "public_ip_enabled", "true", ), ) @@ -155,7 +170,7 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { name = "tf-acc-test-update-%s" image { - id = "ubuntu-latest" + id = "2306" source = "github" } @@ -175,11 +190,11 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { name = "tf-acc-test-update-%s-updated" image { - id = "ubuntu-latest" + id = "2306" source = "github" } - size = "8-core" + size = "4-core" runner_group_id = github_actions_runner_group.test.id maximum_runners = 5 } @@ -207,7 +222,7 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { ), resource.TestCheckResourceAttr( "github_actions_hosted_runner.test", "size", - "8-core", + "4-core", ), resource.TestCheckResourceAttr( "github_actions_hosted_runner.test", "maximum_runners", @@ -248,7 +263,7 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { name = "tf-acc-test-import-%s" image { - id = "ubuntu-latest" + id = "2306" source = "github" } @@ -301,7 +316,7 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { name = "tf-acc-test-delete-%s" image { - id = "ubuntu-latest" + id = "2306" source = "github" } diff --git a/website/docs/r/actions_hosted_runner.html.markdown b/website/docs/r/actions_hosted_runner.html.markdown index fd7c39d9ca..ec03b6faeb 100644 --- a/website/docs/r/actions_hosted_runner.html.markdown +++ b/website/docs/r/actions_hosted_runner.html.markdown @@ -26,7 +26,7 @@ resource "github_actions_hosted_runner" "example" { name = "example-hosted-runner" image { - id = "ubuntu-latest" + id = "2306" source = "github" } @@ -47,14 +47,14 @@ resource "github_actions_hosted_runner" "advanced" { name = "advanced-hosted-runner" image { - id = "ubuntu-latest" + id = "2306" source = "github" } - size = "8-core" - runner_group_id = github_actions_runner_group.advanced.id - maximum_runners = 10 - enable_static_ip = true + size = "8-core" + runner_group_id = github_actions_runner_group.advanced.id + maximum_runners = 10 + public_ip_enabled = true } ``` @@ -62,22 +62,61 @@ resource "github_actions_hosted_runner" "advanced" { The following arguments are supported: -* `name` - (Required) Name of the hosted runner. -* `image` - (Required) Image configuration for the hosted runner. Block supports: - * `id` - (Required) The image ID (e.g., "ubuntu-latest"). - * `source` - (Optional) The image source. Defaults to "github". -* `size` - (Required) Machine size for the hosted runner (e.g., "4-core", "8-core"). +* `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"). Cannot be changed after creation. 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. -* `enable_static_ip` - (Optional) Whether to enable static IP for the runner. Defaults to false. +* `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: + +```hcl +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" + } +} +``` ## 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. -* `platform` - Platform of the runner (e.g., "linux", "windows"). +* `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 @@ -90,5 +129,24 @@ $ terraform import github_actions_hosted_runner.example 123456 ## Notes * This resource is **organization-only** and cannot be used with individual accounts. -* Deletion of hosted runners is asynchronous. The provider will poll for up to 10 minutes to confirm deletion. +* The `image` and `size` fields cannot be changed after the runner is created. Changing these will force recreation of the runner. +* 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 + +To get a list of available images: +```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 +``` + +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 +``` From c734c0693ff4a1bfcdf38e827d52fbbec2571985 Mon Sep 17 00:00:00 2001 From: Austen Stone Date: Thu, 13 Nov 2025 16:54:27 -0500 Subject: [PATCH 4/8] feat: Update GitHub Actions hosted runner resource to allow size field updates and enhance validation --- .../resource_github_actions_hosted_runner.go | 137 +++++++----------- ...ource_github_actions_hosted_runner_test.go | 83 +++++++++++ .../r/actions_hosted_runner.html.markdown | 5 +- 3 files changed, 138 insertions(+), 87 deletions(-) diff --git a/github/resource_github_actions_hosted_runner.go b/github/resource_github_actions_hosted_runner.go index 5052965290..cc73838f81 100644 --- a/github/resource_github_actions_hosted_runner.go +++ b/github/resource_github_actions_hosted_runner.go @@ -55,10 +55,11 @@ func resourceGithubActionsHostedRunner() *schema.Resource { Description: "The image ID.", }, "source": { - Type: schema.TypeString, - Optional: true, - Default: "github", - Description: "The image source (github, partner, or custom).", + Type: schema.TypeString, + Optional: true, + Default: "github", + ValidateFunc: validation.StringInSlice([]string{"github", "partner", "custom"}, false), + Description: "The image source (github, partner, or custom).", }, "size_gb": { Type: schema.TypeInt, @@ -72,8 +73,7 @@ func resourceGithubActionsHostedRunner() *schema.Resource { "size": { Type: schema.TypeString, Required: true, - ForceNew: true, - Description: "Machine size (e.g., '4-core', '8-core'). Cannot be changed after creation.", + Description: "Machine size (e.g., '4-core', '8-core'). Can be updated to scale the runner.", }, "runner_group_id": { Type: schema.TypeInt, @@ -81,10 +81,11 @@ func resourceGithubActionsHostedRunner() *schema.Resource { Description: "The runner group ID.", }, "maximum_runners": { - Type: schema.TypeInt, - Optional: true, - Computed: true, - Description: "Maximum number of runners to scale up to.", + Type: schema.TypeInt, + Optional: true, + Computed: true, + ValidateFunc: validation.IntAtLeast(1), + Description: "Maximum number of runners to scale up to.", }, "public_ip_enabled": { Type: schema.TypeBool, @@ -309,29 +310,22 @@ func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta interf } var runner map[string]interface{} - resp, err := client.Do(ctx, req, &runner) + _, err = client.Do(ctx, req, &runner) if err != nil { - // Handle accepted error (202) which means the runner is being created asynchronously - if _, ok := err.(*github.AcceptedError); ok { - log.Printf("[INFO] Hosted runner is being created asynchronously") - // Continue processing if we have runner data - if runner == nil { - return fmt.Errorf("runner information not available after accepted status") - } - } else { + if _, ok := err.(*github.AcceptedError); !ok { return err } } - if resp != nil && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + if runner == nil { + return fmt.Errorf("no runner data returned from API") } // 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") + return fmt.Errorf("failed to get runner ID from response: %+v", runner) } return resourceGithubActionsHostedRunnerRead(d, meta) @@ -345,9 +339,8 @@ func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta interfac client := meta.(*Owner).v3client orgName := meta.(*Owner).name - ctx := context.Background() - runnerID := d.Id() + ctx := context.WithValue(context.Background(), ctxId, runnerID) // Create GET request req, err := client.NewRequest("GET", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil) @@ -356,52 +349,44 @@ func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta interfac } var runner map[string]interface{} - resp, err := client.Do(ctx, req, &runner) + _, err = client.Do(ctx, req, &runner) if err != nil { - if resp != nil && resp.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 ghErr, ok := err.(*github.ErrorResponse); ok { + 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 + } } return err } - // Set computed attributes - if name, ok := runner["name"].(string); ok { - d.Set("name", name) - } - if status, ok := runner["status"].(string); ok { - d.Set("status", status) - } - if platform, ok := runner["platform"].(string); ok { - d.Set("platform", platform) - } + d.Set("name", runner["name"]) + d.Set("status", runner["status"]) + d.Set("platform", runner["platform"]) + d.Set("last_active_on", runner["last_active_on"]) + d.Set("public_ip_enabled", runner["public_ip_enabled"]) + if image, ok := runner["image"].(map[string]interface{}); ok { d.Set("image", flattenImage(image)) } + if machineSizeDetails, ok := runner["machine_size_details"].(map[string]interface{}); ok { - if sizeID, ok := machineSizeDetails["id"].(string); ok { - d.Set("size", sizeID) - } + d.Set("size", machineSizeDetails["id"]) + d.Set("machine_size_details", flattenMachineSizeDetails(machineSizeDetails)) } + if runnerGroupID, ok := runner["runner_group_id"].(float64); ok { d.Set("runner_group_id", int(runnerGroupID)) } + if maxRunners, ok := runner["maximum_runners"].(float64); ok { d.Set("maximum_runners", int(maxRunners)) } - if publicIPEnabled, ok := runner["public_ip_enabled"].(bool); ok { - d.Set("public_ip_enabled", publicIPEnabled) - } - if machineSizeDetails, ok := runner["machine_size_details"].(map[string]interface{}); ok { - d.Set("machine_size_details", flattenMachineSizeDetails(machineSizeDetails)) - } + if publicIPs, ok := runner["public_ips"].([]interface{}); ok { d.Set("public_ips", flattenPublicIPs(publicIPs)) } - if lastActiveOn, ok := runner["last_active_on"].(string); ok { - d.Set("last_active_on", lastActiveOn) - } return nil } @@ -414,16 +399,17 @@ func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta interf client := meta.(*Owner).v3client orgName := meta.(*Owner).name - ctx := context.Background() - runnerID := d.Id() + ctx := context.WithValue(context.Background(), ctxId, runnerID) - // Build update payload (only fields that can be updated per API docs) payload := make(map[string]interface{}) if d.HasChange("name") { payload["name"] = d.Get("name").(string) } + if d.HasChange("size") { + payload["size"] = d.Get("size").(string) + } if d.HasChange("runner_group_id") { payload["runner_group_id"] = d.Get("runner_group_id").(int) } @@ -437,6 +423,10 @@ func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta interf payload["image_version"] = d.Get("image_version").(string) } + if len(payload) == 0 { + return resourceGithubActionsHostedRunnerRead(d, meta) + } + // Create PATCH request req, err := client.NewRequest("PATCH", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), payload) if err != nil { @@ -444,21 +434,13 @@ func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta interf } var runner map[string]interface{} - resp, err := client.Do(ctx, req, &runner) + _, err = client.Do(ctx, req, &runner) if err != nil { - // Handle accepted error (202) which means the update is being processed asynchronously - if _, ok := err.(*github.AcceptedError); ok { - log.Printf("[INFO] Hosted runner update is being processed asynchronously") - // Continue to read the current state - } else { + if _, ok := err.(*github.AcceptedError); !ok { return err } } - if resp != nil && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - return resourceGithubActionsHostedRunnerRead(d, meta) } @@ -483,20 +465,15 @@ func resourceGithubActionsHostedRunnerDelete(d *schema.ResourceData, meta interf resp, err := client.Do(ctx, req, nil) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { - // Already deleted return nil } - // Handle accepted error (202) which means the deletion is being processed asynchronously if _, ok := err.(*github.AcceptedError); ok { - log.Printf("[DEBUG] Hosted runner %s deletion accepted, polling for completion", runnerID) return waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete)) } return err } - // Handle async deletion (202 Accepted) if resp != nil && resp.StatusCode == http.StatusAccepted { - log.Printf("[DEBUG] Hosted runner %s deletion accepted, polling for completion", runnerID) return waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete)) } @@ -505,40 +482,30 @@ func resourceGithubActionsHostedRunnerDelete(d *schema.ResourceData, meta interf func waitForRunnerDeletion(ctx context.Context, client *github.Client, orgName, runnerID string, timeout time.Duration) error { conf := &retry.StateChangeConf{ - Pending: []string{"deleting", "active"}, // Any state that is NOT the target state - Target: []string{"deleted"}, // The state we are waiting for + Pending: []string{"deleting", "active"}, + Target: []string{"deleted"}, Refresh: func() (interface{}, string, error) { - // This function is called to check the resource's status req, err := client.NewRequest("GET", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil) if err != nil { - return nil, "", err // This error stops the poller + return nil, "", err } resp, err := client.Do(ctx, req, nil) - - // 404 Not Found means it's successfully deleted if resp != nil && resp.StatusCode == http.StatusNotFound { - log.Printf("[DEBUG] Hosted runner %s successfully deleted", runnerID) return "deleted", "deleted", nil } if err != nil { - // Return the error - StateChangeConf will continue retrying as long as we're in a Pending state - // If this is a transient error, it will be retried; if fatal, it will stop the poller - log.Printf("[DEBUG] Error checking runner status (will retry): %v", err) return nil, "deleting", err } - // If it's not 404, we assume it's still being deleted - log.Printf("[DEBUG] Hosted runner %s still exists, continuing to poll", runnerID) return "deleting", "deleting", nil }, - Timeout: timeout, // Use the timeout from the resource schema - Delay: 10 * time.Second, // Initial delay before first check - MinTimeout: 5 * time.Second, // Minimum time to wait between checks + Timeout: timeout, + Delay: 10 * time.Second, + MinTimeout: 5 * time.Second, } - // Run the poller - this will block until "deleted" is returned, an error occurs, or it times out _, err := conf.WaitForStateContext(ctx) return err } diff --git a/github/resource_github_actions_hosted_runner_test.go b/github/resource_github_actions_hosted_runner_test.go index 8fcb56bff6..92888cde6c 100644 --- a/github/resource_github_actions_hosted_runner_test.go +++ b/github/resource_github_actions_hosted_runner_test.go @@ -252,6 +252,89 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { }) }) + t.Run("updates size field", func(t *testing.T) { + 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" + } + + resource "github_actions_hosted_runner" "test" { + name = "tf-acc-test-size-%s" + + image { + id = "2306" + source = "github" + } + + size = "8-core" + runner_group_id = github_actions_runner_group.test.id + } + `, 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", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: configBefore, + Check: checkBefore, + }, + { + Config: configAfter, + Check: checkAfter, + }, + }, + }) + } + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + t.Run("imports hosted runner", func(t *testing.T) { config := fmt.Sprintf(` resource "github_actions_runner_group" "test" { diff --git a/website/docs/r/actions_hosted_runner.html.markdown b/website/docs/r/actions_hosted_runner.html.markdown index ec03b6faeb..a78b0188f7 100644 --- a/website/docs/r/actions_hosted_runner.html.markdown +++ b/website/docs/r/actions_hosted_runner.html.markdown @@ -66,7 +66,7 @@ The following arguments are supported: * `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"). Cannot be changed after creation. To list available sizes, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/machine-sizes`. +* `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. @@ -129,7 +129,8 @@ $ terraform import github_actions_hosted_runner.example 123456 ## Notes * This resource is **organization-only** and cannot be used with individual accounts. -* The `image` and `size` fields cannot be changed after the runner is created. Changing these will force recreation of the runner. +* 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. From 7d5319a840dc61fd3cf6cb7ae634e916d976a79b Mon Sep 17 00:00:00 2001 From: Austen Stone Date: Thu, 13 Nov 2025 17:44:07 -0500 Subject: [PATCH 5/8] feat: Enhance GitHub Actions hosted runner resource to handle image ID as number and add image_gen attribute --- github/resource_github_actions_hosted_runner.go | 9 +++++++++ github/resource_github_actions_hosted_runner_test.go | 11 ++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/github/resource_github_actions_hosted_runner.go b/github/resource_github_actions_hosted_runner.go index cc73838f81..9140844974 100644 --- a/github/resource_github_actions_hosted_runner.go +++ b/github/resource_github_actions_hosted_runner.go @@ -206,9 +206,14 @@ func flattenImage(image map[string]interface{}) []interface{} { } result := make(map[string]interface{}) + + // 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 } @@ -388,6 +393,10 @@ func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta interfac d.Set("public_ips", flattenPublicIPs(publicIPs)) } + if imageGen, ok := runner["image_gen"].(bool); ok { + d.Set("image_gen", imageGen) + } + return nil } diff --git a/github/resource_github_actions_hosted_runner_test.go b/github/resource_github_actions_hosted_runner_test.go index 92888cde6c..29a2542959 100644 --- a/github/resource_github_actions_hosted_runner_test.go +++ b/github/resource_github_actions_hosted_runner_test.go @@ -115,7 +115,7 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { source = "github" } - size = "8-core" + size = "2-core" runner_group_id = github_actions_runner_group.test.id maximum_runners = 5 public_ip_enabled = true @@ -129,7 +129,7 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { ), resource.TestCheckResourceAttr( "github_actions_hosted_runner.test", "size", - "8-core", + "2-core", ), resource.TestCheckResourceAttr( "github_actions_hosted_runner.test", "maximum_runners", @@ -375,9 +375,10 @@ func TestAccGithubActionsHostedRunner(t *testing.T) { Check: check, }, { - ResourceName: "github_actions_hosted_runner.test", - ImportState: true, - ImportStateVerify: true, + ResourceName: "github_actions_hosted_runner.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"image", "image_gen"}, }, }, }) From ec3d53162738136cb6a00c69033d1eec6089270c Mon Sep 17 00:00:00 2001 From: Austen Stone Date: Fri, 14 Nov 2025 07:49:16 -0500 Subject: [PATCH 6/8] Add link to GitHub Actions hosted runner documentation in website --- website/github.erb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/website/github.erb b/website/github.erb index e7b6c6a61b..7db02fc5fc 100644 --- a/website/github.erb +++ b/website/github.erb @@ -256,6 +256,9 @@
  • github_actions_runner_group
  • +
  • + github_actions_hosted_runner +
  • github_actions_secret
  • From 3663e387c512dd6619a9693f6239323471213dd3 Mon Sep 17 00:00:00 2001 From: Austen Stone Date: Fri, 14 Nov 2025 08:01:56 -0500 Subject: [PATCH 7/8] feat: Improve error handling and type assertion in resourceGithubActionsHostedRunnerRead function --- .../resource_github_actions_hosted_runner.go | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/github/resource_github_actions_hosted_runner.go b/github/resource_github_actions_hosted_runner.go index 9140844974..3276983cc2 100644 --- a/github/resource_github_actions_hosted_runner.go +++ b/github/resource_github_actions_hosted_runner.go @@ -366,11 +366,25 @@ func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta interfac return err } - d.Set("name", runner["name"]) - d.Set("status", runner["status"]) - d.Set("platform", runner["platform"]) - d.Set("last_active_on", runner["last_active_on"]) - d.Set("public_ip_enabled", runner["public_ip_enabled"]) + if runner == nil { + return fmt.Errorf("no runner data returned from API") + } + + if name, ok := runner["name"].(string); ok { + d.Set("name", name) + } + if status, ok := runner["status"].(string); ok { + d.Set("status", status) + } + if platform, ok := runner["platform"].(string); ok { + d.Set("platform", platform) + } + if lastActiveOn, ok := runner["last_active_on"].(string); ok { + d.Set("last_active_on", lastActiveOn) + } + if publicIPEnabled, ok := runner["public_ip_enabled"].(bool); ok { + d.Set("public_ip_enabled", publicIPEnabled) + } if image, ok := runner["image"].(map[string]interface{}); ok { d.Set("image", flattenImage(image)) From ebb73988d6affd5ea80f3282107c584f83a41999 Mon Sep 17 00:00:00 2001 From: Austen Stone Date: Sat, 22 Nov 2025 10:27:13 -0500 Subject: [PATCH 8/8] lint: Replace interface{} with any in resource_github_actions_hosted_runner.go --- .../resource_github_actions_hosted_runner.go | 121 +++++++++++------- 1 file changed, 75 insertions(+), 46 deletions(-) diff --git a/github/resource_github_actions_hosted_runner.go b/github/resource_github_actions_hosted_runner.go index 3276983cc2..2156574e56 100644 --- a/github/resource_github_actions_hosted_runner.go +++ b/github/resource_github_actions_hosted_runner.go @@ -2,6 +2,7 @@ package github import ( "context" + "errors" "fmt" "log" "net/http" @@ -182,13 +183,13 @@ func resourceGithubActionsHostedRunner() *schema.Resource { } } -func expandImage(imageList []interface{}) map[string]interface{} { +func expandImage(imageList []any) map[string]any { if len(imageList) == 0 { return nil } - imageMap := imageList[0].(map[string]interface{}) - result := make(map[string]interface{}) + imageMap := imageList[0].(map[string]any) + result := make(map[string]any) if id, ok := imageMap["id"].(string); ok { result["id"] = id @@ -200,12 +201,12 @@ func expandImage(imageList []interface{}) map[string]interface{} { return result } -func flattenImage(image map[string]interface{}) []interface{} { +func flattenImage(image map[string]any) []any { if image == nil { - return []interface{}{} + return []any{} } - result := make(map[string]interface{}) + result := make(map[string]any) // Handle id as either string or number if id, ok := image["id"].(string); ok { @@ -221,15 +222,15 @@ func flattenImage(image map[string]interface{}) []interface{} { result["size_gb"] = int(size) } - return []interface{}{result} + return []any{result} } -func flattenMachineSizeDetails(details map[string]interface{}) []interface{} { +func flattenMachineSizeDetails(details map[string]any) []any { if details == nil { - return []interface{}{} + return []any{} } - result := make(map[string]interface{}) + result := make(map[string]any) if id, ok := details["id"].(string); ok { result["id"] = id } @@ -243,22 +244,22 @@ func flattenMachineSizeDetails(details map[string]interface{}) []interface{} { result["storage_gb"] = int(storageGB) } - return []interface{}{result} + return []any{result} } -func flattenPublicIPs(ips []interface{}) []interface{} { +func flattenPublicIPs(ips []any) []any { if ips == nil { - return []interface{}{} + return []any{} } - result := make([]interface{}, 0, len(ips)) + result := make([]any, 0, len(ips)) for _, ip := range ips { - ipMap, ok := ip.(map[string]interface{}) + ipMap, ok := ip.(map[string]any) if !ok { continue } - ipResult := make(map[string]interface{}) + ipResult := make(map[string]any) if enabled, ok := ipMap["enabled"].(bool); ok { ipResult["enabled"] = enabled } @@ -274,7 +275,7 @@ func flattenPublicIPs(ips []interface{}) []interface{} { return result } -func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta interface{}) error { +func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta any) error { err := checkOrganization(meta) if err != nil { return err @@ -285,9 +286,9 @@ func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta interf ctx := context.Background() // Build request payload - payload := map[string]interface{}{ + payload := map[string]any{ "name": d.Get("name").(string), - "image": expandImage(d.Get("image").([]interface{})), + "image": expandImage(d.Get("image").([]any)), "size": d.Get("size").(string), "runner_group_id": d.Get("runner_group_id").(int), } @@ -314,10 +315,11 @@ func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta interf return err } - var runner map[string]interface{} + var runner map[string]any _, err = client.Do(ctx, req, &runner) if err != nil { - if _, ok := err.(*github.AcceptedError); !ok { + var acceptedErr *github.AcceptedError + if !errors.As(err, &acceptedErr) { return err } } @@ -336,7 +338,7 @@ func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta interf return resourceGithubActionsHostedRunnerRead(d, meta) } -func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta interface{}) error { +func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta any) error { err := checkOrganization(meta) if err != nil { return err @@ -353,10 +355,11 @@ func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta interfac return err } - var runner map[string]interface{} + var runner map[string]any _, err = client.Do(ctx, req, &runner) if err != nil { - if ghErr, ok := err.(*github.ErrorResponse); ok { + 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("") @@ -371,50 +374,74 @@ func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta interfac } if name, ok := runner["name"].(string); ok { - d.Set("name", name) + if err := d.Set("name", name); err != nil { + return err + } } if status, ok := runner["status"].(string); ok { - d.Set("status", status) + if err := d.Set("status", status); err != nil { + return err + } } if platform, ok := runner["platform"].(string); ok { - d.Set("platform", platform) + if err := d.Set("platform", platform); err != nil { + return err + } } if lastActiveOn, ok := runner["last_active_on"].(string); ok { - d.Set("last_active_on", lastActiveOn) + if err := d.Set("last_active_on", lastActiveOn); err != nil { + return err + } } if publicIPEnabled, ok := runner["public_ip_enabled"].(bool); ok { - d.Set("public_ip_enabled", publicIPEnabled) + if err := d.Set("public_ip_enabled", publicIPEnabled); err != nil { + return err + } } - if image, ok := runner["image"].(map[string]interface{}); ok { - d.Set("image", flattenImage(image)) + if image, ok := runner["image"].(map[string]any); ok { + if err := d.Set("image", flattenImage(image)); err != nil { + return err + } } - if machineSizeDetails, ok := runner["machine_size_details"].(map[string]interface{}); ok { - d.Set("size", machineSizeDetails["id"]) - d.Set("machine_size_details", flattenMachineSizeDetails(machineSizeDetails)) + if machineSizeDetails, ok := runner["machine_size_details"].(map[string]any); ok { + if err := d.Set("size", machineSizeDetails["id"]); err != nil { + return err + } + if err := d.Set("machine_size_details", flattenMachineSizeDetails(machineSizeDetails)); err != nil { + return err + } } if runnerGroupID, ok := runner["runner_group_id"].(float64); ok { - d.Set("runner_group_id", int(runnerGroupID)) + if err := d.Set("runner_group_id", int(runnerGroupID)); err != nil { + return err + } } if maxRunners, ok := runner["maximum_runners"].(float64); ok { - d.Set("maximum_runners", int(maxRunners)) + if err := d.Set("maximum_runners", int(maxRunners)); err != nil { + return err + } } - if publicIPs, ok := runner["public_ips"].([]interface{}); ok { - d.Set("public_ips", flattenPublicIPs(publicIPs)) + if publicIPs, ok := runner["public_ips"].([]any); ok { + if err := d.Set("public_ips", flattenPublicIPs(publicIPs)); err != nil { + return err + } } if imageGen, ok := runner["image_gen"].(bool); ok { - d.Set("image_gen", imageGen) + if err := d.Set("image_gen", imageGen); err != nil { + return err + } } return nil } -func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta interface{}) error { +func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta any) error { err := checkOrganization(meta) if err != nil { return err @@ -425,7 +452,7 @@ func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta interf runnerID := d.Id() ctx := context.WithValue(context.Background(), ctxId, runnerID) - payload := make(map[string]interface{}) + payload := make(map[string]any) if d.HasChange("name") { payload["name"] = d.Get("name").(string) @@ -456,10 +483,11 @@ func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta interf return err } - var runner map[string]interface{} + var runner map[string]any _, err = client.Do(ctx, req, &runner) if err != nil { - if _, ok := err.(*github.AcceptedError); !ok { + var acceptedErr *github.AcceptedError + if !errors.As(err, &acceptedErr) { return err } } @@ -467,7 +495,7 @@ func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta interf return resourceGithubActionsHostedRunnerRead(d, meta) } -func resourceGithubActionsHostedRunnerDelete(d *schema.ResourceData, meta interface{}) error { +func resourceGithubActionsHostedRunnerDelete(d *schema.ResourceData, meta any) error { err := checkOrganization(meta) if err != nil { return err @@ -490,7 +518,8 @@ func resourceGithubActionsHostedRunnerDelete(d *schema.ResourceData, meta interf if resp != nil && resp.StatusCode == http.StatusNotFound { return nil } - if _, ok := err.(*github.AcceptedError); ok { + var acceptedErr *github.AcceptedError + if errors.As(err, &acceptedErr) { return waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete)) } return err @@ -507,7 +536,7 @@ func waitForRunnerDeletion(ctx context.Context, client *github.Client, orgName, conf := &retry.StateChangeConf{ Pending: []string{"deleting", "active"}, Target: []string{"deleted"}, - Refresh: func() (interface{}, string, error) { + Refresh: func() (any, string, error) { req, err := client.NewRequest("GET", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil) if err != nil { return nil, "", err