diff --git a/README.md b/README.md index d7b77ba3a5..fa697d8dcd 100644 --- a/README.md +++ b/README.md @@ -68,14 +68,14 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | -| [aws](#requirement\_aws) | >= 6.21 | +| [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | ## Providers | Name | Version | |------|---------| -| [aws](#provider\_aws) | >= 6.21 | +| [aws](#provider\_aws) | >= 6.33 | | [random](#provider\_random) | ~> 3.0 | ## Modules @@ -144,7 +144,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no | | [instance\_target\_capacity\_type](#input\_instance\_target\_capacity\_type) | Default lifecycle used for runner instances, can be either `spot` or `on-demand`. | `string` | `"spot"` | no | | [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the instance termination watcher. This feature is Beta, changes will not trigger a major release as long in beta.

`enable`: Enable or disable the spot termination watcher.
'features': Enable or disable features of the termination watcher.
`memory_size`: Memory size limit in MB of the lambda.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`timeout`: Time out of the lambda in seconds.
`zip`: File location of the lambda zip file. |
object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
})
| `{}` | no | -| [instance\_types](#input\_instance\_types) | List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux and Windows Server Core for win). | `list(string)` |
[
"m5.large",
"c5.large"
]
| no | +| [instance\_types](#input\_instance\_types) | List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux, macOS Sequoia for osx, Windows Server Core for win). | `list(string)` |
[
"m5.large",
"c5.large"
]
| no | | [job\_queue\_retention\_in\_seconds](#input\_job\_queue\_retention\_in\_seconds) | The number of seconds the job is held in the queue before it is purged. | `number` | `86400` | no | | [job\_retry](#input\_job\_retry) | Experimental! Can be removed / changed without trigger a major release.Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the instances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the rate limit of the GitHub app.

`enable`: Enable or disable the job retry feature.
`delay_in_seconds`: The delay in seconds before the job retry check lambda will check the job status.
`delay_backoff`: The backoff factor for the delay.
`lambda_memory_size`: Memory size limit in MB for the job retry check lambda.
`lambda_timeout`: Time out of the job retry check lambda in seconds.
`max_attempts`: The maximum number of attempts to retry the job. |
object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
})
| `{}` | no | | [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no | @@ -189,7 +189,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [runner\_binaries\_syncer\_lambda\_timeout](#input\_runner\_binaries\_syncer\_lambda\_timeout) | Time out of the binaries sync lambda in seconds. | `number` | `300` | no | | [runner\_binaries\_syncer\_lambda\_zip](#input\_runner\_binaries\_syncer\_lambda\_zip) | File location of the binaries sync lambda zip file. | `string` | `null` | no | | [runner\_boot\_time\_in\_minutes](#input\_runner\_boot\_time\_in\_minutes) | The minimum time for an EC2 runner to boot and register as a runner. | `number` | `5` | no | -| [runner\_cpu\_options](#input\_runner\_cpu\_options) | The CPU options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#cpu-options for details. Note that not all instance types support CPU options, see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-optimize-cpu.html#instance-cpu-options |
object({
core_count = number
threads_per_core = number
})
| `null` | no | +| [runner\_cpu\_options](#input\_runner\_cpu\_options) | The CPU options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#cpu-options for details. Note that not all instance types support CPU options, see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-optimize-cpu.html#instance-cpu-options |
object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
})
| `null` | no | | [runner\_credit\_specification](#input\_runner\_credit\_specification) | The credit option for CPU usage of a T instance. Can be unset, "standard" or "unlimited". | `string` | `null` | no | | [runner\_disable\_default\_labels](#input\_runner\_disable\_default\_labels) | Disable default labels for the runners (os, architecture and `self-hosted`). If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM. | `bool` | `false` | no | | [runner\_ec2\_tags](#input\_runner\_ec2\_tags) | Map of tags that will be added to the launch template instance tag specifications. | `map(string)` | `{}` | no | @@ -199,10 +199,11 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [runner\_hook\_job\_completed](#input\_runner\_hook\_job\_completed) | Script to be ran in the runner environment at the end of every job | `string` | `""` | no | | [runner\_hook\_job\_started](#input\_runner\_hook\_job\_started) | Script to be ran in the runner environment at the beginning of every job | `string` | `""` | no | | [runner\_iam\_role\_managed\_policy\_arns](#input\_runner\_iam\_role\_managed\_policy\_arns) | Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role | `list(string)` | `[]` | no | +| [runner\_license\_specifications](#input\_runner\_license\_specifications) | Optional EC2 License Manager license configuration ARNs for the runner launch template. Required for macOS dedicated-host runners when the host resource group uses a Mac dedicated host license configuration. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#license_specification for details. |
list(object({
license_configuration_arn = string
}))
| `[]` | no | | [runner\_log\_files](#input\_runner\_log\_files) | (optional) List of logfiles to send to CloudWatch, will only be used if `enable_cloudwatch_agent` is set to true. Object description: `log_group_name`: Name of the log group, `prefix_log_group`: If true, the log group name will be prefixed with `/github-self-hosted-runners/`, `file_path`: path to the log file, `log_stream_name`: name of the log stream, `log_class`: The log class of the log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. Defaults to `STANDARD`. |
list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
}))
| `null` | no | | [runner\_metadata\_options](#input\_runner\_metadata\_options) | Metadata options for the ec2 runner instances. By default, the module uses metadata tags for bootstrapping the runner, only disable `instance_metadata_tags` when using custom scripts for starting the runner. | `map(any)` |
{
"http_endpoint": "enabled",
"http_put_response_hop_limit": 1,
"http_tokens": "required",
"instance_metadata_tags": "enabled"
}
| no | | [runner\_name\_prefix](#input\_runner\_name\_prefix) | The prefix used for the GitHub runner name. The prefix will be used in the default start script to prefix the instance name when register the runner in GitHub. The value is available via an EC2 tag 'ghr:runner\_name\_prefix'. | `string` | `""` | no | -| [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux,windows). | `string` | `"linux"` | no | +| [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux, osx, windows). | `string` | `"linux"` | no | | [runner\_placement](#input\_runner\_placement) | The placement options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#placement for details. |
object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
})
| `null` | no | | [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | | [runners\_ebs\_optimized](#input\_runners\_ebs\_optimized) | Enable EBS optimization for the runner instances. | `bool` | `false` | no | @@ -225,6 +226,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [syncer\_lambda\_s3\_object\_version](#input\_syncer\_lambda\_s3\_object\_version) | S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no | | [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no | | [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. |
object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
})
| `{}` | no | +| [use\_dedicated\_host](#input\_use\_dedicated\_host) | Use a dedicated host for the runner instances. | `bool` | `false` | no | | [user\_agent](#input\_user\_agent) | User agent used for API calls by lambda functions. | `string` | `"github-aws-runners"` | no | | [userdata\_content](#input\_userdata\_content) | Alternative user-data content, replacing the templated one. By providing your own user\_data you have to take care of installing all required software, including the action runner and registering the runner. Be-aware configuration parameters in SSM as well as tags are treated as internals. Changes will not trigger a breaking release. | `string` | `null` | no | | [userdata\_post\_install](#input\_userdata\_post\_install) | Script to be ran after the GitHub Actions runner is installed on the EC2 instances | `string` | `""` | no | diff --git a/docs/configuration.md b/docs/configuration.md index 1776e30c76..6c8f33aaf9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -282,6 +282,10 @@ In case the setup does not work as intended, trace the events through this seque ## Experimental features +### macOS Runners + +This feature is in early stage and should be considered experimental. The module supports macOS-based GitHub Actions self-hosted runners on AWS EC2 Mac instances (`mac1.metal`, `mac2.metal`, `mac2-m2.metal`). macOS runners require dedicated hosts due to Apple's licensing requirements and have longer boot times (6–20 minutes). Set `runner_os = "osx"` and `use_dedicated_host = true` to enable. See the full [macOS Runners documentation](mac-runners.md) for details. + ### Termination watcher This feature is in early stage and therefore disabled by default. To enable the watcher, set `instance_termination_watcher.enable = true`. diff --git a/docs/examples/dedicated-mac-hosts.md b/docs/examples/dedicated-mac-hosts.md new file mode 100644 index 0000000000..fd2caa4291 --- /dev/null +++ b/docs/examples/dedicated-mac-hosts.md @@ -0,0 +1 @@ +--8<-- "examples/dedicated-mac-hosts/README.md" diff --git a/docs/examples/index.md b/docs/examples/index.md index ac611575fc..f095e68ad9 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -9,4 +9,5 @@ Examples are located in the [examples](https://github.com/github-aws-runners/ter - _[Prebuilt Images](prebuilt.md)_: Example usages of deploying runners with a custom prebuilt image. - _[Windows](windows.md)_: Example usage of creating a runner using Windows as the OS. - _[Termination watcher](termination-watcher.md)_: Example usages of termination watcher. +- _[Dedicated Mac Hosts](dedicated-mac-hosts.md)_: Example usage of setting up dedicated hosts for macOS runners. - _[Externally managed SSM secrets](external-managed-ssm-secrets.md)_: Example usage of externally managed SSM secrets for the GitHub App credentials. diff --git a/docs/mac-runners.md b/docs/mac-runners.md new file mode 100644 index 0000000000..a68dfdbadc --- /dev/null +++ b/docs/mac-runners.md @@ -0,0 +1,190 @@ +# macOS Runners (Experimental) + +!!! warning + This feature is in early stage and should be considered experimental. macOS runners on AWS have unique constraints compared to Linux and Windows runners. Please review all sections below before deploying. + +## Overview + +The module supports provisioning macOS-based GitHub Actions self-hosted runners on AWS using [Amazon EC2 Mac instances](https://aws.amazon.com/ec2/instance-types/mac/). macOS runners use the `osx` value for the `runner_os` variable and require **dedicated hosts** due to Apple's macOS licensing requirements. + +Key differences from Linux/Windows runners: + +- **Dedicated hosts required.** EC2 Mac instances must run on dedicated hosts. Each dedicated host can run **only one Mac VM at a time** (1:1 ratio). The module uses `RunInstances` directly instead of the `CreateFleet` API when `use_dedicated_host` is enabled. +- **Longer boot times.** macOS instances can take 6–20 minutes to launch, significantly longer than Linux (~1 min) or Windows (~5 min). The default `minimum_running_time_in_minutes` for `osx` is set to 20 minutes to prevent premature scale-down. +- **~50 minute host recycle time.** After an EC2 Mac instance is terminated, AWS performs install cleanup and software upgrades on the dedicated host before it becomes available again. This process takes approximately **50 minutes**, during which the host cannot launch a new instance. +- **ARM64 (Apple Silicon) and x64 (Intel) support.** Both `mac1.metal` (Intel), `mac2.metal` (M1), and `mac2-m2.metal` (M2) instance types are supported. Set `runner_architecture` accordingly (`x64` or `arm64`). +- **Only ephemeral mode is recommended.** Due to the long host allocation time and dedicated host cost model, we recommend using ephemeral runners. + +## Scaling caveats + +Running macOS runners at scale introduces challenges that do not exist with Linux or Windows runners: + +1. **1:1 host-to-VM ratio.** Unlike Linux where many instances share underlying hardware, each Mac VM requires its own dedicated host. To run N concurrent macOS jobs, you need at least N dedicated hosts. +2. **Host recycle delay.** After a Mac instance is terminated, the dedicated host enters a ~50 minute cleanup cycle (scrubbing, software updates). During this window the host is unavailable. For bursty workloads, you need additional hosts to absorb demand while others recycle. +3. **Capacity planning.** Dedicated hosts must be allocated ahead of time and are limited by your AWS account quota. Reserve enough hosts for your maximum expected concurrent macOS jobs. After a job finishes and the Mac instance is terminated, add the job runtime plus approximately 50 minutes before that host can run another Mac instance. For example, a 30 minute job keeps its host unavailable for about 80 minutes total. + +## Prerequisites + +Before deploying macOS runners, you must set up dedicated host infrastructure. There are two approaches: + +### Option A: Single dedicated host + +The simplest setup — allocate a single dedicated host and reference it directly. This works for low-scale or testing scenarios, but you must update the Terraform configuration whenever you replace the host. + +1. **Dedicated Host** — Allocate an EC2 dedicated host for your Mac instance type in the target availability zone. + +### Option B: Host resource group (recommended for scale) + +A host resource group allows you to associate **multiple dedicated hosts within an availability zone** into a logical group. When launching a Mac instance, AWS randomly selects an available host from the group. This means you can add, release, or replace individual dedicated hosts **without changing Terraform state or module inputs** — you only reference the group ARN, not individual host ARNs. + +This approach requires three resources: + +1. **Dedicated Hosts** — Allocate one or more EC2 dedicated hosts for Mac instance types in your target availability zones. +2. **Host Resource Group** — Create an AWS Resource Groups group of type `AWS::EC2::HostManagement` and add your dedicated hosts as members. +3. **License Configuration** — Create an AWS License Manager license configuration for Mac dedicated hosts (counting type: `Socket`). Associate it with the macOS AMI and the host resource group. The license configuration ARN is passed to the module via the `license_specifications` input. + +The [dedicated-mac-hosts example](examples/dedicated-mac-hosts.md) provides a ready-to-use Terraform configuration for all three resources. + +## Configuration + +### Basic setup + +```hcl +module "runners" { + source = "github-aws-runners/github-runners/aws" + + # macOS-specific settings + runner_os = "osx" + runner_architecture = "arm64" # or "x64" for Intel Mac instances + instance_types = ["mac2.metal"] + instance_target_capacity_type = "on-demand" + + # Dedicated host settings (required for macOS) + use_dedicated_host = true + placement = { + host_resource_group_arn = "" + } + license_specifications = [""] + + # Recommended: ephemeral mode with a pool + enable_ephemeral_runners = true + delay_webhook_event = 0 + enable_job_queued_check = true + + # ...other common settings... +} +``` + +### AMI selection + +By default, the module selects an Amazon EC2 macOS Sequoia (macOS 15) AMI: + +- **ARM64:** `amzn-ec2-macos-15.*-arm64` +- **x64:** `amzn-ec2-macos-15.*` + +You can override the AMI using filters or an SSM parameter: + +```hcl +# Custom AMI filter +ami = { + filter = { + name = ["amzn-ec2-macos-14.*-arm64"] + state = ["available"] + } + owners = ["amazon"] +} + +# Or via SSM parameter +ami = { + id_ssm_parameter_arn = "arn:aws:ssm:region:account:parameter/path/to/mac/ami" +} +``` + +### Multi-runner setup + +When using the multi-runner module, you can add a macOS runner configuration alongside Linux and Windows runners: + +```hcl +multi_runner_config = { + "mac-arm64" = { + runner_config = { + runner_os = "osx" + runner_architecture = "arm64" + instance_types = ["mac2.metal"] + instance_target_capacity_type = "on-demand" + use_dedicated_host = true + placement = { + host_resource_group_arn = "" + } + license_specifications = [""] + runner_extra_labels = ["osx", "arm64"] + } + matcherConfig = { + labelMatchers = [["self-hosted", "osx", "arm64"]] + exactMatch = false + } + } +} +``` + +## Instance launch behavior + +Because EC2 Fleet (`CreateFleet`) does not support launching instances onto dedicated hosts for `mac*.metal` instance types, the scale-up lambda automatically falls back to using `RunInstances` when `use_dedicated_host` is `true`. This is handled transparently — no additional configuration is needed. + +## User data and scripts + +The module uses macOS-specific templates for provisioning: + +| Script | Description | +| --- | --- | +| `user-data-osx.sh` | Boot script for macOS instances. Uses `ec2-user` and supports Homebrew. | +| `install-runner-osx.sh` | Downloads and installs the GitHub Actions runner agent to `/opt/actions-runner`. | +| `start-runner-osx.sh` | Registers the runner with GitHub and handles ephemeral cleanup. | + +Custom pre/post install scripts and job hooks (`hook_job_started`, `hook_job_completed`) work the same as on Linux. + +## Scale-down considerations + +macOS instances have a default minimum running time of **20 minutes** (vs. 5 for Linux, 15 for Windows) to account for the longer boot cycle. Adjust `minimum_running_time_in_minutes` if needed, but setting it too low risks terminating instances before they can execute a job. + +Additionally, remember that after an instance is terminated, the dedicated host enters a **~50 minute cleanup cycle** before it can launch a new instance. Aggressive scale-down can leave you with no available hosts during this window. + +```hcl +# Override the minimum running time (not recommended to go below 20 for macOS) +minimum_running_time_in_minutes = 25 +``` + +## Cost considerations + +!!! note + macOS dedicated hosts have a **minimum allocation period of 24 hours**. You are billed for the dedicated host for the full 24-hour period, regardless of instance usage. Plan your host allocation accordingly. + +- **Dedicated host costs**: Billed per-host, per-hour with a 24-hour minimum. Each host supports only one Mac VM at a time. See [EC2 Dedicated Hosts Pricing](https://aws.amazon.com/ec2/dedicated-hosts/pricing/). +- **Instance costs**: Mac instances are billed on-demand only (no spot pricing available for Mac instances). +- **Over-provisioning for recycle time**: Because hosts are unavailable for ~50 minutes after instance termination, you may need more dedicated hosts than your peak concurrency to avoid queuing. Factor this into your cost model. +- **Pool sizing**: Keep pool sizes minimal to control costs, but large enough to avoid cold-start delays. + +## Known limitations + +- **No spot instance support.** EC2 Mac instances do not support the spot lifecycle. Runners always use on-demand pricing. +- **1:1 host-to-VM ratio.** Each dedicated host can run only one Mac instance at a time. +- **~50 minute host recycle time.** After instance termination, AWS performs cleanup and software upgrades on the dedicated host. The host is unavailable for approximately 50 minutes during this process. +- **24-hour minimum host allocation.** Dedicated hosts cannot be released within 24 hours of allocation. +- **Limited instance types.** Only `mac1.metal` (Intel x86), `mac2.metal` (M1 ARM64), and `mac2-m2.metal` (M2 ARM64) are available. Instance type availability varies by region. +- **Longer startup.** Boot times of 6–20 minutes mean jobs will queue longer when no warm runners are available. +- **No SSM Session Manager.** Unlike Linux instances, connecting via AWS Session Manager may not be available depending on your AMI. +- **GHES limited testing.** macOS runner support has only been validated against GitHub Enterprise Server 3.17.3. + +## Debugging + +- Check `/var/log/user-data.log` on the macOS instance for boot script output. +- CloudWatch log streams under `/runners` will contain runner agent logs if CloudWatch logging is enabled. +- Verify your dedicated host has available capacity in the EC2 console under **Dedicated Hosts**. +- Ensure the host resource group ARN and license configuration ARN match what is configured in Terraform. +- If runners fail to register, verify the GitHub App has the correct permissions and the SSM token path is accessible. + +## Example + +A complete example for setting up the dedicated host infrastructure is available at: + +- [Dedicated Mac Hosts example](examples/dedicated-mac-hosts.md) diff --git a/examples/dedicated-mac-hosts/README.md b/examples/dedicated-mac-hosts/README.md new file mode 100644 index 0000000000..ad00c2533e --- /dev/null +++ b/examples/dedicated-mac-hosts/README.md @@ -0,0 +1,42 @@ + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | >= 6.21 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | >= 6.21 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_ec2_host.mac_dedicated_host](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ec2_host) | resource | +| [aws_licensemanager_license_configuration.mac_dedicated_host_license_configuration](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/licensemanager_license_configuration) | resource | +| [aws_resourcegroups_group.mac_host_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/resourcegroups_group) | resource | +| [aws_resourcegroups_resource.mac_host_membership](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/resourcegroups_resource) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | +| [environment](#input\_environment) | Environment name, used as prefix. | `string` | `null` | no | +| [host\_groups](#input\_host\_groups) | Map of host groups, each with a name, host instance type, and a list of hosts (name + AZ). |
map(object({
name = string
host_instance_type = string
hosts = list(object({
name = string
availability_zone = string
}))
}))
| n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [license\_specification\_arn](#output\_license\_specification\_arn) | ARN of the License Manager configuration used for Mac dedicated hosts. | +| [resource\_group\_arns](#output\_resource\_group\_arns) | Map of resource group names to their ARNs. | + diff --git a/examples/dedicated-mac-hosts/main.tf b/examples/dedicated-mac-hosts/main.tf new file mode 100644 index 0000000000..f0b1537859 --- /dev/null +++ b/examples/dedicated-mac-hosts/main.tf @@ -0,0 +1,105 @@ +locals { + + environment = var.environment != null ? var.environment : "default" + aws_region = var.aws_region + + # Flatten host_groups into a map of individual host definitions keyed by + # "groupKey-hostName" so we can create one aws_ec2_host per host. + mac_dedicated_hosts = merge([ + for group_key, group in var.host_groups : { + for host in group.hosts : + "${group_key}-${host.name}" => { + instance_type = group.host_instance_type + availability_zone = host.availability_zone + group_name = group.name + host_name = host.name + } + } + ]...) +} + +resource "aws_ec2_host" "mac_dedicated_host" { + for_each = local.mac_dedicated_hosts + + instance_type = each.value.instance_type + availability_zone = each.value.availability_zone + auto_placement = "on" + + tags = { + "Name" = each.value.host_name + "HostGroup" = each.value.group_name + } +} + +resource "aws_resourcegroups_group" "mac_host_group" { + for_each = { for _, group in var.host_groups : group.name => group } + + name = each.value.name + + configuration { + type = "AWS::EC2::HostManagement" + + parameters { + name = "any-host-based-license-configuration" + values = ["true"] + } + + parameters { + name = "auto-allocate-host" + values = [ + "false", + ] + } + parameters { + name = "auto-host-recovery" + values = [ + "false", + ] + } + parameters { + name = "auto-release-host" + values = [ + "false", + ] + } + } + + configuration { + type = "AWS::ResourceGroups::Generic" + parameters { + name = "allowed-resource-types" + values = [ + "AWS::EC2::Host", + ] + } + + parameters { + name = "deletion-protection" + values = [ + "UNLESS_EMPTY", + ] + } + } + + tags = { + "Name" = each.value.name + } +} + +resource "aws_resourcegroups_resource" "mac_host_membership" { + for_each = local.mac_dedicated_hosts + + group_arn = aws_resourcegroups_group.mac_host_group[each.value.group_name].arn + resource_arn = aws_ec2_host.mac_dedicated_host[each.key].arn +} + + +resource "aws_licensemanager_license_configuration" "mac_dedicated_host_license_configuration" { + name = "mac-dedicated-host-license-configuration" + description = "Mac dedicated host license configuration" + license_counting_type = "Socket" + + tags = { + "Name" = "mac-dedicated-host-license-configuration" + } +} diff --git a/examples/dedicated-mac-hosts/outputs.tf b/examples/dedicated-mac-hosts/outputs.tf new file mode 100644 index 0000000000..4aa7dda086 --- /dev/null +++ b/examples/dedicated-mac-hosts/outputs.tf @@ -0,0 +1,12 @@ +output "resource_group_arns" { + description = "Map of resource group names to their ARNs." + value = { + for k, rg in aws_resourcegroups_group.mac_host_group : + rg.name => rg.arn + } +} + +output "license_specification_arn" { + description = "ARN of the License Manager configuration used for Mac dedicated hosts." + value = aws_licensemanager_license_configuration.mac_dedicated_host_license_configuration.arn +} diff --git a/examples/dedicated-mac-hosts/providers.tf b/examples/dedicated-mac-hosts/providers.tf new file mode 100644 index 0000000000..eca2fe96a7 --- /dev/null +++ b/examples/dedicated-mac-hosts/providers.tf @@ -0,0 +1,9 @@ +provider "aws" { + region = local.aws_region + + default_tags { + tags = { + Example = local.environment + } + } +} diff --git a/examples/dedicated-mac-hosts/variables.tf b/examples/dedicated-mac-hosts/variables.tf new file mode 100644 index 0000000000..3efed4af38 --- /dev/null +++ b/examples/dedicated-mac-hosts/variables.tf @@ -0,0 +1,23 @@ +variable "aws_region" { + description = "AWS region." + type = string +} + +variable "environment" { + description = "Environment name, used as prefix." + + type = string + default = null +} + +variable "host_groups" { + description = "Map of host groups, each with a name, host instance type, and a list of hosts (name + AZ)." + type = map(object({ + name = string + host_instance_type = string + hosts = list(object({ + name = string + availability_zone = string + })) + })) +} diff --git a/examples/dedicated-mac-hosts/versions.tf b/examples/dedicated-mac-hosts/versions.tf new file mode 100644 index 0000000000..af69406fbd --- /dev/null +++ b/examples/dedicated-mac-hosts/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.21" + } + } + + required_version = ">= 1.3.0" +} diff --git a/examples/default/README.md b/examples/default/README.md index 2eae797fd7..c75e37831f 100644 --- a/examples/default/README.md +++ b/examples/default/README.md @@ -34,7 +34,7 @@ terraform output -raw webhook_secret | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | -| [aws](#requirement\_aws) | >= 6.21 | +| [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | @@ -42,7 +42,7 @@ terraform output -raw webhook_secret | Name | Version | |------|---------| -| [random](#provider\_random) | 3.7.2 | +| [random](#provider\_random) | 3.8.1 | ## Modules diff --git a/examples/ephemeral/README.md b/examples/ephemeral/README.md index 04f2177d7e..e6bf4961b4 100644 --- a/examples/ephemeral/README.md +++ b/examples/ephemeral/README.md @@ -33,7 +33,7 @@ terraform output webhook_secret | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | -| [aws](#requirement\_aws) | >= 6.21 | +| [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | @@ -41,7 +41,7 @@ terraform output webhook_secret | Name | Version | |------|---------| -| [random](#provider\_random) | 3.7.2 | +| [random](#provider\_random) | 3.8.1 | ## Modules diff --git a/examples/external-managed-ssm-secrets/README.md b/examples/external-managed-ssm-secrets/README.md index 5a9a725dd3..af9c95a38c 100644 --- a/examples/external-managed-ssm-secrets/README.md +++ b/examples/external-managed-ssm-secrets/README.md @@ -80,7 +80,7 @@ terraform output -raw webhook_secret | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | -| [aws](#requirement\_aws) | >= 6.21 | +| [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | diff --git a/examples/multi-runner/README.md b/examples/multi-runner/README.md index 8f14b48503..f0a737b790 100644 --- a/examples/multi-runner/README.md +++ b/examples/multi-runner/README.md @@ -53,7 +53,7 @@ terraform output -raw webhook_secret | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | -| [aws](#requirement\_aws) | >= 6.21 | +| [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | @@ -61,8 +61,8 @@ terraform output -raw webhook_secret | Name | Version | |------|---------| -| [aws](#provider\_aws) | 6.22.1 | -| [random](#provider\_random) | 3.7.2 | +| [aws](#provider\_aws) | 6.35.1 | +| [random](#provider\_random) | 3.8.1 | ## Modules diff --git a/examples/multi-runner/variables.tf b/examples/multi-runner/variables.tf index 009c3643db..bc490ae273 100644 --- a/examples/multi-runner/variables.tf +++ b/examples/multi-runner/variables.tf @@ -19,4 +19,4 @@ variable "aws_region" { type = string default = "eu-west-1" -} +} \ No newline at end of file diff --git a/examples/permissions-boundary/README.md b/examples/permissions-boundary/README.md index a5b1857d62..523e10edda 100644 --- a/examples/permissions-boundary/README.md +++ b/examples/permissions-boundary/README.md @@ -35,7 +35,7 @@ terraform apply | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | -| [aws](#requirement\_aws) | >= 6.21 | +| [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | diff --git a/examples/prebuilt/README.md b/examples/prebuilt/README.md index b24f47a01d..f90a47261e 100644 --- a/examples/prebuilt/README.md +++ b/examples/prebuilt/README.md @@ -78,7 +78,7 @@ terraform output webhook_secret | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | -| [aws](#requirement\_aws) | >= 6.21 | +| [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | @@ -86,8 +86,8 @@ terraform output webhook_secret | Name | Version | |------|---------| -| [aws](#provider\_aws) | 6.22.1 | -| [random](#provider\_random) | 3.7.2 | +| [aws](#provider\_aws) | 6.35.1 | +| [random](#provider\_random) | 3.8.1 | ## Modules @@ -112,7 +112,7 @@ terraform output webhook_secret | [aws\_region](#input\_aws\_region) | AWS region. | `string` | `"eu-west-1"` | no | | [environment](#input\_environment) | Environment name, used as prefix. | `string` | `null` | no | | [github\_app](#input\_github\_app) | GitHub for API usages. |
object({
id = string
key_base64 = string
})
| n/a | yes | -| [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux,windows). | `string` | `"linux"` | no | +| [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux, osx, windows). | `string` | `"linux"` | no | ## Outputs diff --git a/examples/prebuilt/variables.tf b/examples/prebuilt/variables.tf index 643072a163..11670a5d2e 100644 --- a/examples/prebuilt/variables.tf +++ b/examples/prebuilt/variables.tf @@ -22,7 +22,7 @@ variable "aws_region" { } variable "runner_os" { - description = "The EC2 Operating System type to use for action runner instances (linux,windows)." + description = "The EC2 Operating System type to use for action runner instances (linux, osx, windows)." type = string default = "linux" diff --git a/lambdas/functions/control-plane/src/aws/runners.d.ts b/lambdas/functions/control-plane/src/aws/runners.d.ts index f57652d491..01cd4c1459 100644 --- a/lambdas/functions/control-plane/src/aws/runners.d.ts +++ b/lambdas/functions/control-plane/src/aws/runners.d.ts @@ -70,4 +70,5 @@ export interface RunnerInputParameters { tracingEnabled?: boolean; onDemandFailoverOnError?: string[]; scaleErrors: string[]; + useDedicatedHost?: boolean; } diff --git a/lambdas/functions/control-plane/src/aws/runners.test.ts b/lambdas/functions/control-plane/src/aws/runners.test.ts index 2f1b05792e..e9f6c13969 100644 --- a/lambdas/functions/control-plane/src/aws/runners.test.ts +++ b/lambdas/functions/control-plane/src/aws/runners.test.ts @@ -10,6 +10,7 @@ import { DescribeInstancesCommand, type DescribeInstancesResult, EC2Client, + RunInstancesCommand, SpotAllocationStrategy, TerminateInstancesCommand, } from '@aws-sdk/client-ec2'; @@ -964,6 +965,7 @@ interface RunnerConfig { onDemandFailoverOnError?: string[]; scaleErrors: string[]; source: LambdaRunnerSource; + useDedicatedHost?: boolean; } function createRunnerConfig(runnerConfig: RunnerConfig): RunnerInputParameters { @@ -985,6 +987,7 @@ function createRunnerConfig(runnerConfig: RunnerConfig): RunnerInputParameters { onDemandFailoverOnError: runnerConfig.onDemandFailoverOnError, scaleErrors: runnerConfig.scaleErrors, source: runnerConfig.source, + useDedicatedHost: runnerConfig.useDedicatedHost, }; } @@ -1073,3 +1076,217 @@ function expectedCreateFleetRequest(expectedValues: ExpectedFleetRequestValues): return request; } + +describe('create runner with useDedicatedHost', () => { + const dedicatedHostRunnerConfig: RunnerConfig = { + allocationStrategy: SpotAllocationStrategy.CAPACITY_OPTIMIZED, + capacityType: 'on-demand', + type: 'Org', + scaleErrors: [], + useDedicatedHost: true, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockEC2Client.reset(); + mockSSMClient.reset(); + + mockEC2Client.on(RunInstancesCommand).resolves({ + Instances: [{ InstanceId: 'i-dedicated-1' }], + }); + mockSSMClient.on(GetParameterCommand).resolves({}); + }); + + it('uses RunInstances instead of CreateFleet when useDedicatedHost is true', async () => { + const result = await createRunner(createRunnerConfig(dedicatedHostRunnerConfig)); + + expect(result).toEqual(['i-dedicated-1']); + expect(mockEC2Client).toHaveReceivedCommand(RunInstancesCommand); + expect(mockEC2Client).not.toHaveReceivedCommand(CreateFleetCommand); + }); + + it('uses CreateFleet when useDedicatedHost is false', async () => { + mockEC2Client.on(CreateFleetCommand).resolves({ Instances: [{ InstanceIds: ['i-fleet-1'] }] }); + + const result = await createRunner( + createRunnerConfig({ + ...dedicatedHostRunnerConfig, + useDedicatedHost: false, + }), + ); + + expect(result).toEqual(['i-fleet-1']); + expect(mockEC2Client).toHaveReceivedCommand(CreateFleetCommand); + expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand); + }); + + it('uses CreateFleet when useDedicatedHost is undefined', async () => { + mockEC2Client.on(CreateFleetCommand).resolves({ Instances: [{ InstanceIds: ['i-fleet-1'] }] }); + + const result = await createRunner( + createRunnerConfig({ + ...dedicatedHostRunnerConfig, + useDedicatedHost: undefined, + }), + ); + + expect(result).toEqual(['i-fleet-1']); + expect(mockEC2Client).toHaveReceivedCommand(CreateFleetCommand); + expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand); + }); + + it('passes correct parameters to RunInstances', async () => { + await createRunner(createRunnerConfig(dedicatedHostRunnerConfig)); + + expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, { + LaunchTemplate: { + LaunchTemplateName: LAUNCH_TEMPLATE, + Version: '$Default', + }, + InstanceType: 'm5.large', + MinCount: 1, + MaxCount: 1, + SubnetId: 'subnet-123', + TagSpecifications: [ + { + ResourceType: 'instance', + Tags: [ + { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:created_by', Value: 'scale-up-lambda' }, + { Key: 'ghr:Type', Value: 'Org' }, + { Key: 'ghr:Owner', Value: REPO_NAME }, + ], + }, + { + ResourceType: 'volume', + Tags: [ + { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:created_by', Value: 'scale-up-lambda' }, + { Key: 'ghr:Type', Value: 'Org' }, + { Key: 'ghr:Owner', Value: REPO_NAME }, + ], + }, + ], + }); + }); + + it('creates multiple instances via RunInstances', async () => { + mockEC2Client.on(RunInstancesCommand).resolves({ + Instances: [{ InstanceId: 'i-dedicated-1' }, { InstanceId: 'i-dedicated-2' }], + }); + + const result = await createRunner({ + ...createRunnerConfig(dedicatedHostRunnerConfig), + numberOfRunners: 2, + }); + + expect(result).toEqual(['i-dedicated-1', 'i-dedicated-2']); + expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, { + LaunchTemplate: { + LaunchTemplateName: LAUNCH_TEMPLATE, + Version: '$Default', + }, + InstanceType: 'm5.large', + MinCount: 2, + MaxCount: 2, + SubnetId: 'subnet-123', + TagSpecifications: [ + { + ResourceType: 'instance', + Tags: [ + { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:created_by', Value: 'pool-lambda' }, + { Key: 'ghr:Type', Value: 'Org' }, + { Key: 'ghr:Owner', Value: REPO_NAME }, + ], + }, + { + ResourceType: 'volume', + Tags: [ + { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:created_by', Value: 'pool-lambda' }, + { Key: 'ghr:Type', Value: 'Org' }, + { Key: 'ghr:Owner', Value: REPO_NAME }, + ], + }, + ], + }); + }); + + it('throws error when spot is used with dedicated host', async () => { + await expect( + createRunner( + createRunnerConfig({ + ...dedicatedHostRunnerConfig, + capacityType: 'spot', + }), + ), + ).rejects.toThrow('Spot instances are not supported with RunInstances'); + expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand); + }); + + it('throws error when RunInstances returns no instances', async () => { + mockEC2Client.on(RunInstancesCommand).resolves({ Instances: [] }); + + await expect(createRunner(createRunnerConfig(dedicatedHostRunnerConfig))).rejects.toThrow( + 'RunInstances returned no instances for dedicated host.', + ); + }); + + it('throws error when RunInstances fails', async () => { + mockEC2Client.on(RunInstancesCommand).rejects(new Error('EC2 error')); + + await expect(createRunner(createRunnerConfig(dedicatedHostRunnerConfig))).rejects.toThrow('EC2 error'); + }); + + it('uses ami id override from ssm parameter', async () => { + const paramValue: GetParameterResult = { + Parameter: { + Value: 'ami-dedicated', + }, + }; + mockSSMClient.on(GetParameterCommand).resolves(paramValue); + + await createRunner( + createRunnerConfig({ + ...dedicatedHostRunnerConfig, + amiIdSsmParameterName: 'my-ami-id-param', + }), + ); + + expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, { + LaunchTemplate: { + LaunchTemplateName: LAUNCH_TEMPLATE, + Version: '$Default', + }, + InstanceType: 'm5.large', + MinCount: 1, + MaxCount: 1, + SubnetId: 'subnet-123', + ImageId: 'ami-dedicated', + TagSpecifications: [ + { + ResourceType: 'instance', + Tags: [ + { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:created_by', Value: 'scale-up-lambda' }, + { Key: 'ghr:Type', Value: 'Org' }, + { Key: 'ghr:Owner', Value: REPO_NAME }, + ], + }, + { + ResourceType: 'volume', + Tags: [ + { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:created_by', Value: 'scale-up-lambda' }, + { Key: 'ghr:Type', Value: 'Org' }, + { Key: 'ghr:Owner', Value: REPO_NAME }, + ], + }, + ], + }); + expect(mockSSMClient).toHaveReceivedCommandWith(GetParameterCommand, { + Name: 'my-ami-id-param', + }); + }); +}); diff --git a/lambdas/functions/control-plane/src/aws/runners.ts b/lambdas/functions/control-plane/src/aws/runners.ts index 61edf99af6..0240d86a77 100644 --- a/lambdas/functions/control-plane/src/aws/runners.ts +++ b/lambdas/functions/control-plane/src/aws/runners.ts @@ -5,6 +5,7 @@ import { DeleteTagsCommand, DescribeInstancesCommand, DescribeInstancesResult, + RunInstancesCommand, EC2Client, FleetLaunchTemplateOverridesRequest, Tag, @@ -160,6 +161,15 @@ export async function createRunner(runnerParameters: Runners.RunnerInputParamete const ec2Client = getTracedAWSV3Client(new EC2Client({ region: process.env.AWS_REGION })); const amiIdOverride = await getAmiIdOverride(runnerParameters); + // EC2 Fleet (CreateFleet) does not support launching instances onto dedicated hosts + // for instance types like mac*.metal. Use RunInstances directly instead. + if (runnerParameters.useDedicatedHost) { + logger.info('Using RunInstances for dedicated host placement (CreateFleet does not support dedicated hosts).'); + const instances = await createInstancesWithRunInstances(runnerParameters, amiIdOverride, ec2Client); + logger.info(`Created instance(s) via RunInstances: ${instances.join(',')}`); + return instances; + } + const fleet: CreateFleetResult = await createInstances(runnerParameters, amiIdOverride, ec2Client); const instances: string[] = await processFleetResult(fleet, runnerParameters); @@ -297,6 +307,7 @@ async function createInstances( ], Type: 'instant', }); + logger.debug('CreateFleet request payload.', { payload: createFleetCommand.input }); fleet = await ec2Client.send(createFleetCommand); } catch (e) { logger.warn('Create fleet request failed.', { error: e as Error }); @@ -305,6 +316,68 @@ async function createInstances( return fleet; } +async function createInstancesWithRunInstances( + runnerParameters: Runners.RunnerInputParameters, + amiIdOverride: string | undefined, + ec2Client: EC2Client, +): Promise { + const tags = [ + { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:created_by', Value: runnerParameters.numberOfRunners === 1 ? 'scale-up-lambda' : 'pool-lambda' }, + { Key: 'ghr:Type', Value: runnerParameters.runnerType }, + { Key: 'ghr:Owner', Value: runnerParameters.runnerOwner }, + ]; + + if (runnerParameters.tracingEnabled) { + const traceId = tracer.getRootXrayTraceId(); + tags.push({ Key: 'ghr:trace_id', Value: traceId! }); + } + + try { + if (runnerParameters.ec2instanceCriteria.targetCapacityType === 'spot') { + throw new Error( + 'Spot instances are not supported with RunInstances. Please set targetCapacityType to on-demand for dedicated hosts.', + ); + } + + const instanceType = runnerParameters.ec2instanceCriteria.instanceTypes[0] as _InstanceType; + const runInstancesCommand = new RunInstancesCommand({ + LaunchTemplate: { + LaunchTemplateName: runnerParameters.launchTemplateName, + Version: '$Default', + }, + InstanceType: instanceType, + MinCount: runnerParameters.numberOfRunners, + MaxCount: runnerParameters.numberOfRunners, + SubnetId: runnerParameters.subnets[0], + ...(amiIdOverride ? { ImageId: amiIdOverride } : {}), + TagSpecifications: [ + { + ResourceType: 'instance', + Tags: tags, + }, + { + ResourceType: 'volume', + Tags: tags, + }, + ], + }); + + logger.debug('RunInstances request payload.', { payload: runInstancesCommand.input }); + const result = await ec2Client.send(runInstancesCommand); + const instanceIds = result.Instances?.map((i) => i.InstanceId!).filter(Boolean) || []; + + if (instanceIds.length === 0) { + throw new Error('RunInstances returned no instances for dedicated host.'); + } + + return instanceIds; + } catch (e) { + logger.warn('RunInstances request failed for dedicated host.', { error: e as Error }); + throw e; + } +} + // If launchTime is undefined, this will return false export function bootTimeExceeded(ec2Runner: { launchTime?: Date }): boolean { const runnerBootTimeInMinutes = process.env.RUNNER_BOOT_TIME_IN_MINUTES; diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts index 2245f29b90..7277355f2c 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts @@ -114,6 +114,7 @@ const EXPECTED_RUNNER_PARAMS: RunnerInputParameters = { onDemandFailoverOnError: [], scaleErrors: ['UnfulfillableCapacity', 'MaxSpotInstanceCountExceeded', 'TargetCapacityLimitExceededException'], source: 'scale-up-lambda', + useDedicatedHost: false, }; let expectedRunnerParams: RunnerInputParameters; @@ -3084,6 +3085,36 @@ describe('parseEc2OverrideConfig', () => { }); }); +describe('useDedicatedHost', () => { + beforeEach(() => { + process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; + process.env.RUNNER_NAME_PREFIX = 'unit-test-'; + process.env.RUNNER_GROUP_NAME = 'Default'; + process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config'; + process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; + process.env.RUNNER_LABELS = 'label1,label2'; + }); + + it('defaults to false when USE_DEDICATED_HOST env var is not set', async () => { + delete process.env.USE_DEDICATED_HOST; + await scaleUpModule.scaleUp(TEST_DATA); + expect(createRunner).toHaveBeenCalledWith(expect.objectContaining({ useDedicatedHost: false })); + }); + + it('is true when USE_DEDICATED_HOST is "true"', async () => { + process.env.USE_DEDICATED_HOST = 'true'; + await scaleUpModule.scaleUp(TEST_DATA); + expect(createRunner).toHaveBeenCalledWith(expect.objectContaining({ useDedicatedHost: true })); + }); + + it('is false when USE_DEDICATED_HOST is "false"', async () => { + process.env.USE_DEDICATED_HOST = 'false'; + await scaleUpModule.scaleUp(TEST_DATA); + expect(createRunner).toHaveBeenCalledWith(expect.objectContaining({ useDedicatedHost: false })); + }); +}); + function defaultOctokitMockImpl() { mockOctokit.actions.getJobForWorkflowRun.mockImplementation(() => ({ data: { diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts index b51731f180..4f6f9bd42d 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts @@ -99,6 +99,7 @@ interface CreateEC2RunnerConfig { tracingEnabled?: boolean; onDemandFailoverOnError?: string[]; scaleErrors: string[]; + useDedicatedHost?: boolean; } function generateRunnerServiceConfig(githubRunnerConfig: CreateGitHubRunnerConfig, token: string) { @@ -354,6 +355,7 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise [terraform](#requirement\_terraform) | >= 1.3 | -| [aws](#requirement\_aws) | >= 6.21 | +| [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | ## Providers | Name | Version | |------|---------| -| [aws](#provider\_aws) | >= 6.21 | +| [aws](#provider\_aws) | >= 6.33 | | [random](#provider\_random) | ~> 3.0 | ## Modules @@ -152,7 +152,7 @@ module "multi-runner" { | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `180` | no | | [matcher\_config\_parameter\_store\_tier](#input\_matcher\_config\_parameter\_store\_tier) | The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`. | `string` | `"Standard"` | no | | [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. |
object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
})
| `{}` | no | -| [multi\_runner\_config](#input\_multi\_runner\_config) | multi\_runner\_config = {
runner\_config: {
runner\_os: "The EC2 Operating System type to use for action runner instances (linux,windows)."
runner\_architecture: "The platform architecture of the runner instance\_type."
runner\_metadata\_options: "(Optional) Metadata options for the ec2 runner instances."
ami: "(Optional) AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place."
create\_service\_linked\_role\_spot: (Optional) create the serviced linked role for spot instances that is required by the scale-up lambda.
credit\_specification: "(Optional) The credit specification of the runner instance\_type. Can be unset, `standard` or `unlimited`.
delay\_webhook\_event: "The number of seconds the event accepted by the webhook is invisible on the queue before the scale up lambda will receive the event."
disable\_runner\_autoupdate: "Disable the auto update of the github runner agent. Be aware there is a grace period of 30 days, see also the [GitHub article](https://github.blog/changelog/2022-02-01-github-actions-self-hosted-runners-can-now-disable-automatic-updates/)"
ebs\_optimized: "The EC2 EBS optimized configuration."
enable\_ephemeral\_runners: "Enable ephemeral runners, runners will only be used once."
enable\_dynamic\_labels: "Experimental! Can be removed / changed without trigger a major release. Enable dynamic labels with 'ghr-' prefix. When enabled, jobs can use 'ghr-ec2-:' labels to dynamically configure EC2 instances (e.g., 'ghr-ec2-instance-type:t3.large') and 'ghr-run-