diff --git a/examples/hosted_runner/main.tf b/examples/hosted_runner/main.tf new file mode 100644 index 0000000000..0f1838cb40 --- /dev/null +++ b/examples/hosted_runner/main.tf @@ -0,0 +1,34 @@ +resource "github_actions_runner_group" "example" { + name = "example-runner-group" + 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 = "2306" # Ubuntu Latest (24.04) - query your org for available IDs + 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 = "2306" # Ubuntu Latest (24.04) - query your org for available IDs + 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 fac0fafa83..54a56c59c2 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..3276983cc2 --- /dev/null +++ b/github/resource_github_actions_hosted_runner.go @@ -0,0 +1,534 @@ +package github + +import ( + "context" + "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 { + return &schema.Resource{ + Create: resourceGithubActionsHostedRunnerCreate, + Read: resourceGithubActionsHostedRunnerRead, + Update: resourceGithubActionsHostedRunnerUpdate, + Delete: resourceGithubActionsHostedRunnerDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Timeouts: &schema.ResourceTimeout{ + Delete: schema.DefaultTimeout(10 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "name": { + 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{ + "id": { + Type: schema.TypeString, + Required: true, + Description: "The image ID.", + }, + "source": { + 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, + Computed: true, + Description: "The size of the image in GB.", + }, + }, + }, + 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.", + }, + "runner_group_id": { + Type: schema.TypeInt, + Required: true, + Description: "The runner group ID.", + }, + "maximum_runners": { + 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, + 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 this runner should be used to generate custom images. Cannot be changed after creation.", + }, + "id": { + Type: schema.TypeString, + 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.", + }, + "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.", + }, + }, + } +} + +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 +} + +func flattenImage(image map[string]interface{}) []interface{} { + if image == nil { + return []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 + } + 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 { + 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("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 { + return err + } + + var runner map[string]interface{} + _, err = client.Do(ctx, req, &runner) + if err != nil { + if _, ok := err.(*github.AcceptedError); !ok { + return err + } + } + + 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: %+v", runner) + } + + 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 + 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) + if err != nil { + return err + } + + var runner map[string]interface{} + _, err = client.Do(ctx, req, &runner) + if err != 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 + } + + 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)) + } + + if machineSizeDetails, ok := runner["machine_size_details"].(map[string]interface{}); ok { + 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 publicIPs, ok := runner["public_ips"].([]interface{}); ok { + d.Set("public_ips", flattenPublicIPs(publicIPs)) + } + + if imageGen, ok := runner["image_gen"].(bool); ok { + d.Set("image_gen", imageGen) + } + + 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 + runnerID := d.Id() + ctx := context.WithValue(context.Background(), ctxId, runnerID) + + 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) + } + if d.HasChange("maximum_runners") { + payload["maximum_runners"] = d.Get("maximum_runners").(int) + } + 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) + } + + 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 { + return err + } + + var runner map[string]interface{} + _, err = client.Do(ctx, req, &runner) + if err != nil { + if _, ok := err.(*github.AcceptedError); !ok { + return err + } + } + + 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 { + return nil + } + if _, ok := err.(*github.AcceptedError); ok { + return waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete)) + } + return err + } + + if resp != nil && resp.StatusCode == http.StatusAccepted { + return 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 { + conf := &retry.StateChangeConf{ + Pending: []string{"deleting", "active"}, + Target: []string{"deleted"}, + Refresh: func() (interface{}, string, error) { + req, err := client.NewRequest("GET", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil) + if err != nil { + return nil, "", err + } + + resp, err := client.Do(ctx, req, nil) + if resp != nil && resp.StatusCode == http.StatusNotFound { + return "deleted", "deleted", nil + } + + if err != nil { + return nil, "deleting", err + } + + return "deleting", "deleting", nil + }, + Timeout: timeout, + Delay: 10 * time.Second, + MinTimeout: 5 * time.Second, + } + + _, 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 new file mode 100644 index 0000000000..29a2542959 --- /dev/null +++ b/github/resource_github_actions_hosted_runner_test.go @@ -0,0 +1,442 @@ +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 = "2306" + 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", + "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", + ), + ) + + 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 = "2306" + source = "github" + } + + size = "2-core" + runner_group_id = github_actions_runner_group.test.id + maximum_runners = 5 + 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", + ), + ) + + 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 = "2306" + 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 = "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", + ), + ) + + 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("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" { + name = "tf-acc-test-group-%s" + visibility = "all" + } + + resource "github_actions_hosted_runner" "test" { + name = "tf-acc-test-import-%s" + + image { + id = "2306" + 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, + ImportStateVerifyIgnore: []string{"image", "image_gen"}, + }, + }, + }) + } + + 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 = "2306" + 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) + }) + }) +} 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..a78b0188f7 --- /dev/null +++ b/website/docs/r/actions_hosted_runner.html.markdown @@ -0,0 +1,153 @@ +--- +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 = "2306" + 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 = "2306" + source = "github" + } + + size = "8-core" + runner_group_id = github_actions_runner_group.advanced.id + maximum_runners = 10 + public_ip_enabled = true +} +``` + +## 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: + +```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 (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: + +``` +$ terraform import github_actions_hosted_runner.example 123456 +``` + +## 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 + +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 +``` 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 @@