From 1705f92eac8bf64a62ab0e318b9214acdab9b0f5 Mon Sep 17 00:00:00 2001 From: Nicolas Fidel Date: Sat, 10 Jan 2026 10:02:20 +0100 Subject: [PATCH 1/2] feat: add github_enterprise_actions_hosted_runner --- github/provider.go | 1 + ...github_enterprise_actions_hosted_runner.go | 551 ++++++++++++++++++ ...b_enterprise_actions_hosted_runner_test.go | 319 ++++++++++ ...rprise_actions_hosted_runner.html.markdown | 174 ++++++ 4 files changed, 1045 insertions(+) create mode 100644 github/resource_github_enterprise_actions_hosted_runner.go create mode 100644 github/resource_github_enterprise_actions_hosted_runner_test.go create mode 100644 website/docs/r/enterprise_actions_hosted_runner.html.markdown diff --git a/github/provider.go b/github/provider.go index 3a3b24863c..8f5fdad86c 100644 --- a/github/provider.go +++ b/github/provider.go @@ -211,6 +211,7 @@ func Provider() *schema.Provider { "github_user_ssh_key": resourceGithubUserSshKey(), "github_enterprise_organization": resourceGithubEnterpriseOrganization(), "github_enterprise_actions_runner_group": resourceGithubActionsEnterpriseRunnerGroup(), + "github_enterprise_actions_hosted_runner": resourceGithubEnterpriseActionsHostedRunner(), "github_enterprise_actions_workflow_permissions": resourceGithubEnterpriseActionsWorkflowPermissions(), "github_enterprise_security_analysis_settings": resourceGithubEnterpriseSecurityAnalysisSettings(), "github_workflow_repository_permissions": resourceGithubWorkflowRepositoryPermissions(), diff --git a/github/resource_github_enterprise_actions_hosted_runner.go b/github/resource_github_enterprise_actions_hosted_runner.go new file mode 100644 index 0000000000..0a5431cb68 --- /dev/null +++ b/github/resource_github_enterprise_actions_hosted_runner.go @@ -0,0 +1,551 @@ +package github + +import ( + "context" + "fmt" + "log" + "net/http" + "regexp" + "strconv" + "time" + + "github.com/google/go-github/v81/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 resourceGithubEnterpriseActionsHostedRunner() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubEnterpriseActionsHostedRunnerCreate, + Read: resourceGithubEnterpriseActionsHostedRunnerRead, + Update: resourceGithubEnterpriseActionsHostedRunnerUpdate, + Delete: resourceGithubEnterpriseActionsHostedRunnerDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Timeouts: &schema.ResourceTimeout{ + Delete: schema.DefaultTimeout(10 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise. This is used to identify the enterprise in GitHub URLs and APIs.", + }, + "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, + Description: "Image configuration for the hosted runner. This defines the operating system and software stack that will run on the runner. Cannot be changed after creation.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Required: true, + Description: "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 /enterprises/{enterprise}/actions/hosted-runners/images/github-owned.", + }, + "source": { + Type: schema.TypeString, + Optional: true, + Default: "github", + ValidateFunc: validation.StringInSlice([]string{"github", "partner", "custom"}, false), + Description: "The image source. Valid values are 'github' for GitHub-owned images, 'partner' for partner-provided images, or 'custom' for custom images. Defaults to 'github'.", + }, + "version": { + Type: schema.TypeString, + Optional: true, + Default: "latest", + Description: "The version of the runner image to deploy. For GitHub-owned images, this must be 'latest' (default). For custom images, you can specify a specific version.", + }, + "size_gb": { + Type: schema.TypeInt, + Computed: true, + Description: "The size of the image in GB. This is computed by the GitHub API and indicates the disk space required for the image.", + }, + "display_name": { + Type: schema.TypeString, + Computed: true, + Description: "Human-readable display name for this image. For example, '20.04' for Ubuntu 20.04.", + }, + }, + }, + }, + "size": { + Type: schema.TypeString, + Required: true, + Description: "Machine size for the hosted runner (e.g., '4-core', '8-core'). This determines the CPU, memory, and storage resources allocated to the runner. Can be updated to scale the runner. To list available sizes, use the GitHub API: GET /enterprises/{enterprise}/actions/hosted-runners/machine-sizes.", + }, + "runner_group_id": { + Type: schema.TypeInt, + Required: true, + Description: "The ID of the runner group to assign this runner to. Runner groups help organize runners and control which repositories or workflows can use them. You can get runner group IDs from the github_enterprise_actions_runner_group resource or data source.", + }, + "maximum_runners": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + ValidateFunc: validation.IntAtLeast(1), + Description: "Maximum number of runners to scale up to. Runners will not auto-scale above this number. Use this setting to limit costs. If not specified, GitHub will use a default limit.", + }, + "public_ip_enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Whether to enable static public IP for the runner. When enabled, the runner will be assigned a stable public IP address. Note that there are account-level limits for public IPs. To check limits, use the GitHub API: GET /enterprises/{enterprise}/actions/hosted-runners/limits. Defaults to false.", + }, + "image_gen": { + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + Default: false, + Description: "Whether this runner should be used to generate custom images. This is used for organizations that build their own custom runner images. Cannot be changed after creation. Defaults to false.", + }, + "id": { + Type: schema.TypeString, + Computed: true, + Description: "The hosted runner ID in the format {enterprise_slug}/{runner_id}. This is the unique identifier for the runner resource in Terraform.", + }, + "runner_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The numeric ID of the hosted runner. This is the ID used in GitHub's API.", + }, + "status": { + Type: schema.TypeString, + Computed: true, + Description: "Current status of the runner. Possible values include 'Ready', 'Provisioning', 'Deleting', etc. This indicates the operational state of the runner.", + }, + "platform": { + Type: schema.TypeString, + Computed: true, + Description: "Platform of the runner. Examples: 'linux-x64', 'win-x64', 'macos-arm64'. This indicates the operating system and architecture of the runner.", + }, + "machine_size_details": { + Type: schema.TypeList, + Computed: true, + Description: "Detailed specifications of the machine size, including CPU cores, memory, and storage. This information is returned by the GitHub API and shows the actual resources allocated to the runner.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Computed: true, + Description: "Machine size identifier. This matches the 'size' parameter used when creating the runner (e.g., '4-core', '8-core').", + }, + "cpu_cores": { + Type: schema.TypeInt, + Computed: true, + Description: "Number of CPU cores allocated to the runner. For example, a '4-core' runner has 4 CPU cores.", + }, + "memory_gb": { + Type: schema.TypeInt, + Computed: true, + Description: "Amount of memory in gigabytes allocated to the runner. For example, a '4-core' runner typically has 16 GB of RAM.", + }, + "storage_gb": { + Type: schema.TypeInt, + Computed: true, + Description: "Amount of SSD storage in gigabytes allocated to the runner. For example, a '4-core' runner typically has 150 GB of storage.", + }, + }, + }, + }, + "public_ips": { + Type: schema.TypeList, + Computed: true, + Description: "List of public IP ranges assigned to this runner. Only populated if 'public_ip_enabled' is true. These are the static IP addresses that will be used for outbound connections from the runner.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether this IP range is enabled and active for the runner.", + }, + "prefix": { + Type: schema.TypeString, + Computed: true, + Description: "IP address prefix for the public IP range. Example: '20.80.208.150'. This is the base IP address.", + }, + "length": { + Type: schema.TypeInt, + Computed: true, + Description: "Subnet length for the IP range (CIDR notation length). Example: 28. This defines how many IP addresses are in the range.", + }, + }, + }, + }, + "last_active_on": { + Type: schema.TypeString, + Computed: true, + Description: "RFC3339 timestamp indicating when the runner was last active. This helps track runner usage and can be used to identify idle runners.", + }, + }, + } +} + +func expandHostedRunnerImage(imageList []any) *github.HostedRunnerImage { + if len(imageList) == 0 { + return nil + } + + imageMap := imageList[0].(map[string]any) + image := &github.HostedRunnerImage{} + + if id, ok := imageMap["id"].(string); ok { + image.ID = id + } + if source, ok := imageMap["source"].(string); ok { + image.Source = source + } + if version, ok := imageMap["version"].(string); ok && version != "" { + image.Version = version + } else { + // Default to 'latest' for GitHub-owned images as required by the API + image.Version = "latest" + } + + return image +} + +func flattenHostedRunnerImage(image *github.HostedRunnerImageDetail) []any { + if image == nil { + return []any{} + } + + result := make(map[string]any) + + if image.ID != nil { + result["id"] = *image.ID + } + if image.Source != nil { + result["source"] = *image.Source + } + if image.Version != nil { + result["version"] = *image.Version + } + if image.SizeGB != nil { + result["size_gb"] = int(*image.SizeGB) + } + if image.DisplayName != nil { + result["display_name"] = *image.DisplayName + } + + return []any{result} +} + +func flattenHostedRunnerMachineSpec(spec *github.HostedRunnerMachineSpec) []any { + if spec == nil { + return []any{} + } + + result := make(map[string]any) + result["id"] = spec.ID + result["cpu_cores"] = spec.CPUCores + result["memory_gb"] = spec.MemoryGB + result["storage_gb"] = spec.StorageGB + + return []any{result} +} + +func flattenHostedRunnerPublicIPs(ips []*github.HostedRunnerPublicIP) []any { + if ips == nil { + return []any{} + } + + result := make([]any, 0, len(ips)) + for _, ip := range ips { + if ip == nil { + continue + } + + ipResult := make(map[string]any) + ipResult["enabled"] = ip.Enabled + ipResult["prefix"] = ip.Prefix + ipResult["length"] = ip.Length + result = append(result, ipResult) + } + + return result +} + +func resourceGithubEnterpriseActionsHostedRunnerCreate(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + ctx := context.Background() + enterpriseSlug := d.Get("enterprise_slug").(string) + + // Build request using SDK struct + request := &github.HostedRunnerRequest{ + Name: d.Get("name").(string), + Size: d.Get("size").(string), + RunnerGroupID: int64(d.Get("runner_group_id").(int)), + } + + if image := expandHostedRunnerImage(d.Get("image").([]any)); image != nil { + request.Image = *image + } + + if v, ok := d.GetOk("maximum_runners"); ok { + request.MaximumRunners = int64(v.(int)) + } + + if v, ok := d.GetOk("public_ip_enabled"); ok { + request.EnableStaticIP = v.(bool) + } + + runner, _, err := client.Enterprise.CreateHostedRunner(ctx, enterpriseSlug, request) + if err != nil { + return fmt.Errorf("error creating enterprise hosted runner: %w", err) + } + + if runner == nil || runner.ID == nil { + return fmt.Errorf("no runner data returned from API") + } + + // Set the ID in the format enterprise_slug/runner_id + d.SetId(fmt.Sprintf("%s/%d", enterpriseSlug, *runner.ID)) + + return resourceGithubEnterpriseActionsHostedRunnerRead(d, meta) +} + +func resourceGithubEnterpriseActionsHostedRunnerRead(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + ctx := context.Background() + + enterpriseSlug, runnerIDStr, err := parseEnterpriseRunnerID(d.Id()) + if err != nil { + return err + } + + runnerID, err := strconv.ParseInt(runnerIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid runner ID %q: %w", runnerIDStr, err) + } + + runner, resp, err := client.Enterprise.GetHostedRunner(ctx, enterpriseSlug, runnerID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Removing enterprise hosted runner %s from state because it no longer exists in GitHub", d.Id()) + d.SetId("") + return nil + } + return fmt.Errorf("error reading enterprise hosted runner: %w", err) + } + + if runner == nil { + return fmt.Errorf("no runner data returned from API") + } + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return err + } + + if runner.ID != nil { + if err := d.Set("runner_id", int(*runner.ID)); err != nil { + return err + } + } + + if runner.Name != nil { + if err := d.Set("name", *runner.Name); err != nil { + return err + } + } + if runner.Status != nil { + if err := d.Set("status", *runner.Status); err != nil { + return err + } + } + if runner.Platform != nil { + if err := d.Set("platform", *runner.Platform); err != nil { + return err + } + } + if runner.LastActiveOn != nil { + if err := d.Set("last_active_on", runner.LastActiveOn.Format(time.RFC3339)); err != nil { + return err + } + } + if runner.PublicIPEnabled != nil { + if err := d.Set("public_ip_enabled", *runner.PublicIPEnabled); err != nil { + return err + } + } + + if runner.ImageDetails != nil { + if err := d.Set("image", flattenHostedRunnerImage(runner.ImageDetails)); err != nil { + return err + } + } + + if runner.MachineSizeDetails != nil { + if err := d.Set("size", runner.MachineSizeDetails.ID); err != nil { + return err + } + if err := d.Set("machine_size_details", flattenHostedRunnerMachineSpec(runner.MachineSizeDetails)); err != nil { + return err + } + } + + if runner.RunnerGroupID != nil { + if err := d.Set("runner_group_id", int(*runner.RunnerGroupID)); err != nil { + return err + } + } + + if runner.MaximumRunners != nil { + if err := d.Set("maximum_runners", int(*runner.MaximumRunners)); err != nil { + return err + } + } + + if runner.PublicIPs != nil { + if err := d.Set("public_ips", flattenHostedRunnerPublicIPs(runner.PublicIPs)); err != nil { + return err + } + } + + return nil +} + +func resourceGithubEnterpriseActionsHostedRunnerUpdate(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + ctx := context.Background() + + enterpriseSlug, runnerIDStr, err := parseEnterpriseRunnerID(d.Id()) + if err != nil { + return err + } + + runnerID, err := strconv.ParseInt(runnerIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid runner ID %q: %w", runnerIDStr, err) + } + + request := &github.HostedRunnerRequest{} + hasChanges := false + + if d.HasChange("name") { + request.Name = d.Get("name").(string) + hasChanges = true + } + if d.HasChange("size") { + request.Size = d.Get("size").(string) + hasChanges = true + } + if d.HasChange("runner_group_id") { + request.RunnerGroupID = int64(d.Get("runner_group_id").(int)) + hasChanges = true + } + if d.HasChange("maximum_runners") { + request.MaximumRunners = int64(d.Get("maximum_runners").(int)) + hasChanges = true + } + if d.HasChange("public_ip_enabled") { + request.EnableStaticIP = d.Get("public_ip_enabled").(bool) + hasChanges = true + } + + if !hasChanges { + return resourceGithubEnterpriseActionsHostedRunnerRead(d, meta) + } + + _, _, err = client.Enterprise.UpdateHostedRunner(ctx, enterpriseSlug, runnerID, *request) + if err != nil { + return fmt.Errorf("error updating enterprise hosted runner: %w", err) + } + + return resourceGithubEnterpriseActionsHostedRunnerRead(d, meta) +} + +func resourceGithubEnterpriseActionsHostedRunnerDelete(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + ctx := context.Background() + + enterpriseSlug, runnerIDStr, err := parseEnterpriseRunnerID(d.Id()) + if err != nil { + return err + } + + runnerID, err := strconv.ParseInt(runnerIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid runner ID %q: %w", runnerIDStr, err) + } + + _, resp, err := client.Enterprise.DeleteHostedRunner(ctx, enterpriseSlug, runnerID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil + } + // Check if it's an async deletion (202 Accepted) + if resp != nil && resp.StatusCode == http.StatusAccepted { + return waitForEnterpriseRunnerDeletion(ctx, client, enterpriseSlug, runnerID, d.Timeout(schema.TimeoutDelete)) + } + return fmt.Errorf("error deleting enterprise hosted runner: %w", err) + } + + // If we got a 202, wait for deletion to complete + if resp != nil && resp.StatusCode == http.StatusAccepted { + return waitForEnterpriseRunnerDeletion(ctx, client, enterpriseSlug, runnerID, d.Timeout(schema.TimeoutDelete)) + } + + return nil +} + +func waitForEnterpriseRunnerDeletion(ctx context.Context, client *github.Client, enterpriseSlug string, runnerID int64, timeout time.Duration) error { + conf := &retry.StateChangeConf{ + Pending: []string{"deleting", "active"}, + Target: []string{"deleted"}, + Refresh: func() (any, string, error) { + _, resp, err := client.Enterprise.GetHostedRunner(ctx, enterpriseSlug, runnerID) + 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 +} + +func parseEnterpriseRunnerID(id string) (string, string, error) { + parts := make([]string, 0, 2) + for i, part := range regexp.MustCompile(`/`).Split(id, -1) { + if i == 0 { + parts = append(parts, part) + } else { + parts = append(parts, part) + break + } + } + + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid ID format: %s (expected: enterprise_slug/runner_id)", id) + } + + return parts[0], parts[1], nil +} diff --git a/github/resource_github_enterprise_actions_hosted_runner_test.go b/github/resource_github_enterprise_actions_hosted_runner_test.go new file mode 100644 index 0000000000..7dee515558 --- /dev/null +++ b/github/resource_github_enterprise_actions_hosted_runner_test.go @@ -0,0 +1,319 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubEnterpriseActionsHostedRunner(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("creates enterprise hosted runners without error", func(t *testing.T) { + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_actions_runner_group" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-group-%s" + visibility = "all" + } + + resource "github_enterprise_actions_hosted_runner" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-%s" + + image { + id = "2306" + source = "github" + } + + size = "4-core" + runner_group_id = github_enterprise_actions_runner_group.test.id + } + `, testAccConf.enterpriseSlug, randomID, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "enterprise_slug", + testAccConf.enterpriseSlug, + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "name", + fmt.Sprintf("tf-acc-test-%s", randomID), + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "size", + "4-core", + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "image.0.id", + "2306", + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "image.0.source", + "github", + ), + resource.TestCheckResourceAttrSet( + "github_enterprise_actions_hosted_runner.test", "id", + ), + resource.TestCheckResourceAttrSet( + "github_enterprise_actions_hosted_runner.test", "status", + ), + resource.TestCheckResourceAttrSet( + "github_enterprise_actions_hosted_runner.test", "platform", + ), + resource.TestCheckResourceAttrSet( + "github_enterprise_actions_hosted_runner.test", "image.0.size_gb", + ), + resource.TestCheckResourceAttrSet( + "github_enterprise_actions_hosted_runner.test", "machine_size_details.0.id", + ), + resource.TestCheckResourceAttrSet( + "github_enterprise_actions_hosted_runner.test", "machine_size_details.0.cpu_cores", + ), + resource.TestCheckResourceAttrSet( + "github_enterprise_actions_hosted_runner.test", "machine_size_details.0.memory_gb", + ), + resource.TestCheckResourceAttrSet( + "github_enterprise_actions_hosted_runner.test", "machine_size_details.0.storage_gb", + ), + ) + + testCase := func(t *testing.T, mode testMode) { + 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 enterprise hosted runners") + }) + + t.Run("with an organization account", func(t *testing.T) { + t.Skip("organization account not supported for enterprise operations") + }) + + t.Run("with an enterprise account", func(t *testing.T) { + testCase(t, enterprise) + }) + }) + + t.Run("updates enterprise hosted runners without error", func(t *testing.T) { + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_actions_runner_group" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-group-%s" + visibility = "all" + } + + resource "github_enterprise_actions_hosted_runner" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-%s" + + image { + id = "2306" + source = "github" + } + + size = "4-core" + runner_group_id = github_enterprise_actions_runner_group.test.id + maximum_runners = 5 + public_ip_enabled = false + } + `, testAccConf.enterpriseSlug, randomID, randomID) + + configUpdated := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_actions_runner_group" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-group-%s" + visibility = "all" + } + + resource "github_enterprise_actions_hosted_runner" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-updated-%s" + + image { + id = "2306" + source = "github" + } + + size = "8-core" + runner_group_id = github_enterprise_actions_runner_group.test.id + maximum_runners = 10 + public_ip_enabled = true + } + `, testAccConf.enterpriseSlug, randomID, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "name", + fmt.Sprintf("tf-acc-test-%s", randomID), + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "size", + "4-core", + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "maximum_runners", + "5", + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "public_ip_enabled", + "false", + ), + ) + + checkUpdated := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "name", + fmt.Sprintf("tf-acc-test-updated-%s", randomID), + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "size", + "8-core", + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "maximum_runners", + "10", + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "public_ip_enabled", + "true", + ), + ) + + testCase := func(t *testing.T, mode testMode) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + Config: configUpdated, + Check: checkUpdated, + }, + }, + }) + } + + 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 enterprise hosted runners") + }) + + t.Run("with an organization account", func(t *testing.T) { + t.Skip("organization account not supported for enterprise operations") + }) + + t.Run("with an enterprise account", func(t *testing.T) { + testCase(t, enterprise) + }) + }) + + t.Run("imports enterprise hosted runners without error", func(t *testing.T) { + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_actions_runner_group" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-group-%s" + visibility = "all" + } + + resource "github_enterprise_actions_hosted_runner" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-%s" + + image { + id = "2306" + source = "github" + } + + size = "4-core" + runner_group_id = github_enterprise_actions_runner_group.test.id + } + `, testAccConf.enterpriseSlug, randomID, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "enterprise_slug", + testAccConf.enterpriseSlug, + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "name", + fmt.Sprintf("tf-acc-test-%s", randomID), + ), + resource.TestCheckResourceAttr( + "github_enterprise_actions_hosted_runner.test", "size", + "4-core", + ), + ) + + testCase := func(t *testing.T, mode testMode) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + ResourceName: "github_enterprise_actions_hosted_runner.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for enterprise hosted runners") + }) + + t.Run("with an organization account", func(t *testing.T) { + t.Skip("organization account not supported for enterprise operations") + }) + + t.Run("with an enterprise account", func(t *testing.T) { + testCase(t, enterprise) + }) + }) +} diff --git a/website/docs/r/enterprise_actions_hosted_runner.html.markdown b/website/docs/r/enterprise_actions_hosted_runner.html.markdown new file mode 100644 index 0000000000..54172c7db0 --- /dev/null +++ b/website/docs/r/enterprise_actions_hosted_runner.html.markdown @@ -0,0 +1,174 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_actions_hosted_runner" +description: |- + Creates and manages GitHub-hosted runners within a GitHub enterprise +--- + +# github_enterprise_actions_hosted_runner + +This resource allows you to create and manage GitHub-hosted runners within your GitHub enterprise. +You must have admin access to an enterprise 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 +data "github_enterprise" "example" { + slug = "my-enterprise" +} + +resource "github_enterprise_actions_runner_group" "example" { + enterprise_slug = data.github_enterprise.example.slug + name = "example-runner-group" + visibility = "all" +} + +resource "github_enterprise_actions_hosted_runner" "example" { + enterprise_slug = data.github_enterprise.example.slug + name = "example-hosted-runner" + + image { + id = "2306" + source = "github" + } + + size = "4-core" + runner_group_id = github_enterprise_actions_runner_group.example.id +} +``` + +### Advanced Usage with Optional Parameters + +```hcl +data "github_enterprise" "example" { + slug = "my-enterprise" +} + +resource "github_enterprise_actions_runner_group" "advanced" { + enterprise_slug = data.github_enterprise.example.slug + name = "advanced-runner-group" + visibility = "selected" +} + +resource "github_enterprise_actions_hosted_runner" "advanced" { + enterprise_slug = data.github_enterprise.example.slug + name = "advanced-hosted-runner" + + image { + id = "2306" + source = "github" + } + + size = "8-core" + runner_group_id = github_enterprise_actions_runner_group.advanced.id + maximum_runners = 10 + public_ip_enabled = true +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. Cannot be changed after creation. +* `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 /enterprises/{enterprise}/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 /enterprises/{enterprise}/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 /enterprises/{enterprise}/actions/hosted-runners/limits`. Defaults to false. +* `image_version` - (Optional) The version of the runner image to deploy. This is only relevant for runners using custom images. +* `image_gen` - (Optional) Whether this runner should be used to generate custom images. Cannot be changed after creation. Defaults to false. + +## 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_enterprise_actions_hosted_runner" "example" { + enterprise_slug = "my-enterprise" + name = "example-hosted-runner" + + image { + id = "2306" + source = "github" + } + + size = "4-core" + runner_group_id = github_enterprise_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 in the format `{enterprise_slug}/{runner_id}`. +* `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 + +Enterprise hosted runners can be imported using the format `{enterprise_slug}/{runner_id}`: + +``` +$ terraform import github_enterprise_actions_hosted_runner.example my-enterprise/123456 +``` + +## Notes + +* This resource is **enterprise-only** and requires enterprise admin permissions. +* The `image` field cannot be changed after the runner is created. Changing it will force recreation of the runner. +* The `enterprise_slug` 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 enterprise'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/enterprises/YOUR_ENTERPRISE/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/enterprises/YOUR_ENTERPRISE/actions/hosted-runners/machine-sizes +``` + +## Related Resources + +* [github_enterprise_actions_runner_group](enterprise_actions_runner_group.html) - Manage enterprise runner groups +* [github_actions_hosted_runner](actions_hosted_runner.html) - Organization-level hosted runners From 29a447b142fa2f759245fbc4fa64fe9a8833bfcd Mon Sep 17 00:00:00 2001 From: Nicolas Fidel Date: Sat, 10 Jan 2026 23:17:16 +0100 Subject: [PATCH 2/2] fix: review for github_enterprise_actions_hosted_runner --- ...github_enterprise_actions_hosted_runner.go | 255 +++++++++--------- ...b_enterprise_actions_hosted_runner_test.go | 95 +++---- github/util_runners.go | 90 +++++++ ...rprise_actions_hosted_runner.html.markdown | 18 +- 4 files changed, 270 insertions(+), 188 deletions(-) create mode 100644 github/util_runners.go diff --git a/github/resource_github_enterprise_actions_hosted_runner.go b/github/resource_github_enterprise_actions_hosted_runner.go index 0a5431cb68..36eece2f46 100644 --- a/github/resource_github_enterprise_actions_hosted_runner.go +++ b/github/resource_github_enterprise_actions_hosted_runner.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "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" @@ -17,10 +18,10 @@ import ( func resourceGithubEnterpriseActionsHostedRunner() *schema.Resource { return &schema.Resource{ - Create: resourceGithubEnterpriseActionsHostedRunnerCreate, - Read: resourceGithubEnterpriseActionsHostedRunnerRead, - Update: resourceGithubEnterpriseActionsHostedRunnerUpdate, - Delete: resourceGithubEnterpriseActionsHostedRunnerDelete, + CreateContext: resourceGithubEnterpriseActionsHostedRunnerCreate, + ReadContext: resourceGithubEnterpriseActionsHostedRunnerRead, + UpdateContext: resourceGithubEnterpriseActionsHostedRunnerUpdate, + DeleteContext: resourceGithubEnterpriseActionsHostedRunnerDelete, Importer: &schema.ResourceImporter{ StateContext: schema.ImportStatePassthroughContext, }, @@ -199,94 +200,8 @@ func resourceGithubEnterpriseActionsHostedRunner() *schema.Resource { } } -func expandHostedRunnerImage(imageList []any) *github.HostedRunnerImage { - if len(imageList) == 0 { - return nil - } - - imageMap := imageList[0].(map[string]any) - image := &github.HostedRunnerImage{} - - if id, ok := imageMap["id"].(string); ok { - image.ID = id - } - if source, ok := imageMap["source"].(string); ok { - image.Source = source - } - if version, ok := imageMap["version"].(string); ok && version != "" { - image.Version = version - } else { - // Default to 'latest' for GitHub-owned images as required by the API - image.Version = "latest" - } - - return image -} - -func flattenHostedRunnerImage(image *github.HostedRunnerImageDetail) []any { - if image == nil { - return []any{} - } - - result := make(map[string]any) - - if image.ID != nil { - result["id"] = *image.ID - } - if image.Source != nil { - result["source"] = *image.Source - } - if image.Version != nil { - result["version"] = *image.Version - } - if image.SizeGB != nil { - result["size_gb"] = int(*image.SizeGB) - } - if image.DisplayName != nil { - result["display_name"] = *image.DisplayName - } - - return []any{result} -} - -func flattenHostedRunnerMachineSpec(spec *github.HostedRunnerMachineSpec) []any { - if spec == nil { - return []any{} - } - - result := make(map[string]any) - result["id"] = spec.ID - result["cpu_cores"] = spec.CPUCores - result["memory_gb"] = spec.MemoryGB - result["storage_gb"] = spec.StorageGB - - return []any{result} -} - -func flattenHostedRunnerPublicIPs(ips []*github.HostedRunnerPublicIP) []any { - if ips == nil { - return []any{} - } - - result := make([]any, 0, len(ips)) - for _, ip := range ips { - if ip == nil { - continue - } - - ipResult := make(map[string]any) - ipResult["enabled"] = ip.Enabled - ipResult["prefix"] = ip.Prefix - ipResult["length"] = ip.Length - result = append(result, ipResult) - } - - return result -} - -func resourceGithubEnterpriseActionsHostedRunnerCreate(d *schema.ResourceData, meta any) error { +func resourceGithubEnterpriseActionsHostedRunnerCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client - ctx := context.Background() enterpriseSlug := d.Get("enterprise_slug").(string) // Build request using SDK struct @@ -310,31 +225,100 @@ func resourceGithubEnterpriseActionsHostedRunnerCreate(d *schema.ResourceData, m runner, _, err := client.Enterprise.CreateHostedRunner(ctx, enterpriseSlug, request) if err != nil { - return fmt.Errorf("error creating enterprise hosted runner: %w", err) + return diag.FromErr(fmt.Errorf("error creating enterprise hosted runner: %w", err)) } if runner == nil || runner.ID == nil { - return fmt.Errorf("no runner data returned from API") + return diag.Errorf("no runner data returned from API") } // Set the ID in the format enterprise_slug/runner_id d.SetId(fmt.Sprintf("%s/%d", enterpriseSlug, *runner.ID)) - return resourceGithubEnterpriseActionsHostedRunnerRead(d, meta) + // Populate computed fields directly from API response + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + + if runner.ID != nil { + if err := d.Set("runner_id", int(*runner.ID)); err != nil { + return diag.FromErr(err) + } + } + + if runner.Name != nil { + if err := d.Set("name", *runner.Name); err != nil { + return diag.FromErr(err) + } + } + if runner.Status != nil { + if err := d.Set("status", *runner.Status); err != nil { + return diag.FromErr(err) + } + } + if runner.Platform != nil { + if err := d.Set("platform", *runner.Platform); err != nil { + return diag.FromErr(err) + } + } + if runner.LastActiveOn != nil { + if err := d.Set("last_active_on", runner.LastActiveOn.Format(time.RFC3339)); err != nil { + return diag.FromErr(err) + } + } + if runner.PublicIPEnabled != nil { + if err := d.Set("public_ip_enabled", *runner.PublicIPEnabled); err != nil { + return diag.FromErr(err) + } + } + + if runner.ImageDetails != nil { + if err := d.Set("image", flattenHostedRunnerImage(runner.ImageDetails)); err != nil { + return diag.FromErr(err) + } + } + + if runner.MachineSizeDetails != nil { + if err := d.Set("size", runner.MachineSizeDetails.ID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("machine_size_details", flattenHostedRunnerMachineSpec(runner.MachineSizeDetails)); err != nil { + return diag.FromErr(err) + } + } + + if runner.RunnerGroupID != nil { + if err := d.Set("runner_group_id", int(*runner.RunnerGroupID)); err != nil { + return diag.FromErr(err) + } + } + + if runner.MaximumRunners != nil { + if err := d.Set("maximum_runners", int(*runner.MaximumRunners)); err != nil { + return diag.FromErr(err) + } + } + + if runner.PublicIPs != nil { + if err := d.Set("public_ips", flattenHostedRunnerPublicIPs(runner.PublicIPs)); err != nil { + return diag.FromErr(err) + } + } + + return nil } -func resourceGithubEnterpriseActionsHostedRunnerRead(d *schema.ResourceData, meta any) error { +func resourceGithubEnterpriseActionsHostedRunnerRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client - ctx := context.Background() enterpriseSlug, runnerIDStr, err := parseEnterpriseRunnerID(d.Id()) if err != nil { - return err + return diag.FromErr(err) } runnerID, err := strconv.ParseInt(runnerIDStr, 10, 64) if err != nil { - return fmt.Errorf("invalid runner ID %q: %w", runnerIDStr, err) + return diag.FromErr(fmt.Errorf("invalid runner ID %q: %w", runnerIDStr, err)) } runner, resp, err := client.Enterprise.GetHostedRunner(ctx, enterpriseSlug, runnerID) @@ -344,97 +328,96 @@ func resourceGithubEnterpriseActionsHostedRunnerRead(d *schema.ResourceData, met d.SetId("") return nil } - return fmt.Errorf("error reading enterprise hosted runner: %w", err) + return diag.FromErr(fmt.Errorf("error reading enterprise hosted runner: %w", err)) } if runner == nil { - return fmt.Errorf("no runner data returned from API") + return diag.Errorf("no runner data returned from API") } if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { - return err + return diag.FromErr(err) } if runner.ID != nil { if err := d.Set("runner_id", int(*runner.ID)); err != nil { - return err + return diag.FromErr(err) } } if runner.Name != nil { if err := d.Set("name", *runner.Name); err != nil { - return err + return diag.FromErr(err) } } if runner.Status != nil { if err := d.Set("status", *runner.Status); err != nil { - return err + return diag.FromErr(err) } } if runner.Platform != nil { if err := d.Set("platform", *runner.Platform); err != nil { - return err + return diag.FromErr(err) } } if runner.LastActiveOn != nil { if err := d.Set("last_active_on", runner.LastActiveOn.Format(time.RFC3339)); err != nil { - return err + return diag.FromErr(err) } } if runner.PublicIPEnabled != nil { if err := d.Set("public_ip_enabled", *runner.PublicIPEnabled); err != nil { - return err + return diag.FromErr(err) } } if runner.ImageDetails != nil { if err := d.Set("image", flattenHostedRunnerImage(runner.ImageDetails)); err != nil { - return err + return diag.FromErr(err) } } if runner.MachineSizeDetails != nil { if err := d.Set("size", runner.MachineSizeDetails.ID); err != nil { - return err + return diag.FromErr(err) } if err := d.Set("machine_size_details", flattenHostedRunnerMachineSpec(runner.MachineSizeDetails)); err != nil { - return err + return diag.FromErr(err) } } if runner.RunnerGroupID != nil { if err := d.Set("runner_group_id", int(*runner.RunnerGroupID)); err != nil { - return err + return diag.FromErr(err) } } if runner.MaximumRunners != nil { if err := d.Set("maximum_runners", int(*runner.MaximumRunners)); err != nil { - return err + return diag.FromErr(err) } } if runner.PublicIPs != nil { if err := d.Set("public_ips", flattenHostedRunnerPublicIPs(runner.PublicIPs)); err != nil { - return err + return diag.FromErr(err) } } return nil } -func resourceGithubEnterpriseActionsHostedRunnerUpdate(d *schema.ResourceData, meta any) error { +func resourceGithubEnterpriseActionsHostedRunnerUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client - ctx := context.Background() enterpriseSlug, runnerIDStr, err := parseEnterpriseRunnerID(d.Id()) if err != nil { - return err + return diag.FromErr(err) } runnerID, err := strconv.ParseInt(runnerIDStr, 10, 64) if err != nil { - return fmt.Errorf("invalid runner ID %q: %w", runnerIDStr, err) + return diag.FromErr(fmt.Errorf("invalid runner ID %q: %w", runnerIDStr, err)) } request := &github.HostedRunnerRequest{} @@ -462,46 +445,48 @@ func resourceGithubEnterpriseActionsHostedRunnerUpdate(d *schema.ResourceData, m } if !hasChanges { - return resourceGithubEnterpriseActionsHostedRunnerRead(d, meta) + return nil } _, _, err = client.Enterprise.UpdateHostedRunner(ctx, enterpriseSlug, runnerID, *request) if err != nil { - return fmt.Errorf("error updating enterprise hosted runner: %w", err) + return diag.FromErr(fmt.Errorf("error updating enterprise hosted runner: %w", err)) } - return resourceGithubEnterpriseActionsHostedRunnerRead(d, meta) + return resourceGithubEnterpriseActionsHostedRunnerRead(ctx, d, meta) } -func resourceGithubEnterpriseActionsHostedRunnerDelete(d *schema.ResourceData, meta any) error { +func resourceGithubEnterpriseActionsHostedRunnerDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client - ctx := context.Background() enterpriseSlug, runnerIDStr, err := parseEnterpriseRunnerID(d.Id()) if err != nil { - return err + return diag.FromErr(err) } runnerID, err := strconv.ParseInt(runnerIDStr, 10, 64) if err != nil { - return fmt.Errorf("invalid runner ID %q: %w", runnerIDStr, err) + return diag.FromErr(fmt.Errorf("invalid runner ID %q: %w", runnerIDStr, err)) } _, resp, err := client.Enterprise.DeleteHostedRunner(ctx, enterpriseSlug, runnerID) - if err != nil { - if resp != nil && resp.StatusCode == http.StatusNotFound { - return nil - } - // Check if it's an async deletion (202 Accepted) - if resp != nil && resp.StatusCode == http.StatusAccepted { - return waitForEnterpriseRunnerDeletion(ctx, client, enterpriseSlug, runnerID, d.Timeout(schema.TimeoutDelete)) - } - return fmt.Errorf("error deleting enterprise hosted runner: %w", err) + + // If runner doesn't exist, deletion is successful + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil } - // If we got a 202, wait for deletion to complete + // If we got a 202 Accepted, the deletion is async - wait for it to complete if resp != nil && resp.StatusCode == http.StatusAccepted { - return waitForEnterpriseRunnerDeletion(ctx, client, enterpriseSlug, runnerID, d.Timeout(schema.TimeoutDelete)) + if err := waitForEnterpriseRunnerDeletion(ctx, client, enterpriseSlug, runnerID, d.Timeout(schema.TimeoutDelete)); err != nil { + return diag.FromErr(err) + } + return nil + } + + // For any other error, return it + if err != nil { + return diag.FromErr(fmt.Errorf("error deleting enterprise hosted runner: %w", err)) } return nil diff --git a/github/resource_github_enterprise_actions_hosted_runner_test.go b/github/resource_github_enterprise_actions_hosted_runner_test.go index 7dee515558..b7bffada38 100644 --- a/github/resource_github_enterprise_actions_hosted_runner_test.go +++ b/github/resource_github_enterprise_actions_hosted_runner_test.go @@ -11,6 +11,9 @@ import ( func TestAccGithubEnterpriseActionsHostedRunner(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + // Image ID "2306" is the GitHub-owned Ubuntu Latest 24.04 image + // This is a stable image ID used for acceptance testing + // To list available images: GET /enterprises/{enterprise}/actions/hosted-runners/images/github-owned t.Run("creates enterprise hosted runners without error", func(t *testing.T) { config := fmt.Sprintf(` data "github_enterprise" "enterprise" { @@ -26,9 +29,9 @@ func TestAccGithubEnterpriseActionsHostedRunner(t *testing.T) { resource "github_enterprise_actions_hosted_runner" "test" { enterprise_slug = data.github_enterprise.enterprise.slug name = "tf-acc-test-%s" - + image { - id = "2306" + id = "2306" # GitHub-owned Ubuntu Latest 24.04 source = "github" } @@ -84,19 +87,6 @@ func TestAccGithubEnterpriseActionsHostedRunner(t *testing.T) { ), ) - testCase := func(t *testing.T, mode testMode) { - 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") }) @@ -110,7 +100,16 @@ func TestAccGithubEnterpriseActionsHostedRunner(t *testing.T) { }) t.Run("with an enterprise account", func(t *testing.T) { - testCase(t, enterprise) + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) }) }) @@ -207,23 +206,6 @@ func TestAccGithubEnterpriseActionsHostedRunner(t *testing.T) { ), ) - testCase := func(t *testing.T, mode testMode) { - resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnlessMode(t, mode) }, - Providers: testAccProviders, - Steps: []resource.TestStep{ - { - Config: config, - Check: check, - }, - { - Config: configUpdated, - Check: checkUpdated, - }, - }, - }) - } - t.Run("with an anonymous account", func(t *testing.T) { t.Skip("anonymous account not supported for this operation") }) @@ -237,7 +219,20 @@ func TestAccGithubEnterpriseActionsHostedRunner(t *testing.T) { }) t.Run("with an enterprise account", func(t *testing.T) { - testCase(t, enterprise) + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + Config: configUpdated, + Check: checkUpdated, + }, + }, + }) }) }) @@ -282,9 +277,21 @@ func TestAccGithubEnterpriseActionsHostedRunner(t *testing.T) { ), ) - testCase := func(t *testing.T, mode testMode) { + 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 enterprise hosted runners") + }) + + t.Run("with an organization account", func(t *testing.T) { + t.Skip("organization account not supported for enterprise operations") + }) + + t.Run("with an enterprise account", func(t *testing.T) { resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnlessMode(t, mode) }, + PreCheck: func() { skipUnlessMode(t, enterprise) }, Providers: testAccProviders, Steps: []resource.TestStep{ { @@ -298,22 +305,6 @@ func TestAccGithubEnterpriseActionsHostedRunner(t *testing.T) { }, }, }) - } - - 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 enterprise hosted runners") - }) - - t.Run("with an organization account", func(t *testing.T) { - t.Skip("organization account not supported for enterprise operations") - }) - - t.Run("with an enterprise account", func(t *testing.T) { - testCase(t, enterprise) }) }) } diff --git a/github/util_runners.go b/github/util_runners.go new file mode 100644 index 0000000000..e8c3109e86 --- /dev/null +++ b/github/util_runners.go @@ -0,0 +1,90 @@ +package github + +import ( + "github.com/google/go-github/v81/github" +) + +func expandHostedRunnerImage(imageList []any) *github.HostedRunnerImage { + if len(imageList) == 0 { + return nil + } + + imageMap := imageList[0].(map[string]any) + image := &github.HostedRunnerImage{} + + if id, ok := imageMap["id"].(string); ok { + image.ID = id + } + if source, ok := imageMap["source"].(string); ok { + image.Source = source + } + if version, ok := imageMap["version"].(string); ok && version != "" { + image.Version = version + } else { + // Default to 'latest' for GitHub-owned images as required by the API + image.Version = "latest" + } + + return image +} + +func flattenHostedRunnerImage(image *github.HostedRunnerImageDetail) []any { + if image == nil { + return []any{} + } + + result := make(map[string]any) + + if image.ID != nil { + result["id"] = *image.ID + } + if image.Source != nil { + result["source"] = *image.Source + } + if image.Version != nil { + result["version"] = *image.Version + } + if image.SizeGB != nil { + result["size_gb"] = int(*image.SizeGB) + } + if image.DisplayName != nil { + result["display_name"] = *image.DisplayName + } + + return []any{result} +} + +func flattenHostedRunnerMachineSpec(spec *github.HostedRunnerMachineSpec) []any { + if spec == nil { + return []any{} + } + + result := make(map[string]any) + result["id"] = spec.ID + result["cpu_cores"] = spec.CPUCores + result["memory_gb"] = spec.MemoryGB + result["storage_gb"] = spec.StorageGB + + return []any{result} +} + +func flattenHostedRunnerPublicIPs(ips []*github.HostedRunnerPublicIP) []any { + if ips == nil { + return []any{} + } + + result := make([]any, 0, len(ips)) + for _, ip := range ips { + if ip == nil { + continue + } + + ipResult := make(map[string]any) + ipResult["enabled"] = ip.Enabled + ipResult["prefix"] = ip.Prefix + ipResult["length"] = ip.Length + result = append(result, ipResult) + } + + return result +} diff --git a/website/docs/r/enterprise_actions_hosted_runner.html.markdown b/website/docs/r/enterprise_actions_hosted_runner.html.markdown index 54172c7db0..63e94ac561 100644 --- a/website/docs/r/enterprise_actions_hosted_runner.html.markdown +++ b/website/docs/r/enterprise_actions_hosted_runner.html.markdown @@ -8,7 +8,23 @@ description: |- # github_enterprise_actions_hosted_runner This resource allows you to create and manage GitHub-hosted runners within your GitHub enterprise. -You must have admin access to an enterprise to use this resource. + +## Authentication Requirements + +This resource requires: +* **Enterprise admin permissions** - You must have admin access to the enterprise +* **Personal Access Token (PAT)** with the following scopes: + * `admin:enterprise` - Manage enterprise runners and runner groups + * `manage_runners:enterprise` - Create, update, and delete enterprise-level runners + +Configure your provider with the appropriate token: + +```hcl +provider "github" { + token = var.github_token # Token must have admin:enterprise scope + owner = "my-enterprise" # Enterprise slug +} +``` 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.