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..36eece2f46 --- /dev/null +++ b/github/resource_github_enterprise_actions_hosted_runner.go @@ -0,0 +1,536 @@ +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/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" +) + +func resourceGithubEnterpriseActionsHostedRunner() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGithubEnterpriseActionsHostedRunnerCreate, + ReadContext: resourceGithubEnterpriseActionsHostedRunnerRead, + UpdateContext: resourceGithubEnterpriseActionsHostedRunnerUpdate, + DeleteContext: 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 resourceGithubEnterpriseActionsHostedRunnerCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + 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 diag.FromErr(fmt.Errorf("error creating enterprise hosted runner: %w", err)) + } + + if runner == nil || runner.ID == nil { + 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)) + + // 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(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug, runnerIDStr, err := parseEnterpriseRunnerID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + runnerID, err := strconv.ParseInt(runnerIDStr, 10, 64) + if err != nil { + return diag.FromErr(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 diag.FromErr(fmt.Errorf("error reading enterprise hosted runner: %w", err)) + } + + if runner == nil { + return diag.Errorf("no runner data returned from API") + } + + 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 resourceGithubEnterpriseActionsHostedRunnerUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug, runnerIDStr, err := parseEnterpriseRunnerID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + runnerID, err := strconv.ParseInt(runnerIDStr, 10, 64) + if err != nil { + return diag.FromErr(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 nil + } + + _, _, err = client.Enterprise.UpdateHostedRunner(ctx, enterpriseSlug, runnerID, *request) + if err != nil { + return diag.FromErr(fmt.Errorf("error updating enterprise hosted runner: %w", err)) + } + + return resourceGithubEnterpriseActionsHostedRunnerRead(ctx, d, meta) +} + +func resourceGithubEnterpriseActionsHostedRunnerDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug, runnerIDStr, err := parseEnterpriseRunnerID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + runnerID, err := strconv.ParseInt(runnerIDStr, 10, 64) + if err != nil { + return diag.FromErr(fmt.Errorf("invalid runner ID %q: %w", runnerIDStr, err)) + } + + _, resp, err := client.Enterprise.DeleteHostedRunner(ctx, enterpriseSlug, runnerID) + + // If runner doesn't exist, deletion is successful + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil + } + + // If we got a 202 Accepted, the deletion is async - wait for it to complete + if resp != nil && resp.StatusCode == http.StatusAccepted { + 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 +} + +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..b7bffada38 --- /dev/null +++ b/github/resource_github_enterprise_actions_hosted_runner_test.go @@ -0,0 +1,310 @@ +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) + + // 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" { + 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" # GitHub-owned Ubuntu Latest 24.04 + 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", + ), + ) + + 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, enterprise) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + }) + }) + + 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", + ), + ) + + 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, enterprise) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + Config: configUpdated, + Check: checkUpdated, + }, + }, + }) + }) + }) + + 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", + ), + ) + + 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, enterprise) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + ResourceName: "github_enterprise_actions_hosted_runner.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + }) +} 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 new file mode 100644 index 0000000000..63e94ac561 --- /dev/null +++ b/website/docs/r/enterprise_actions_hosted_runner.html.markdown @@ -0,0 +1,190 @@ +--- +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. + +## 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. + +## 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