diff --git a/modules/compute-providers/aws/ec2/README.md b/modules/compute-providers/aws/ec2/README.md
new file mode 100644
index 0000000000..aa2ad83651
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/README.md
@@ -0,0 +1,80 @@
+# EC2 runner provider
+
+This internal module owns the EC2 compute implementation used by the common runner configuration. It creates the runner launch template, security group, instance profile, EC2 bootstrap parameters, and runner log groups.
+
+The module returns one nested `provider` contract. It groups EC2-specific Lambda settings under `environment_variables`, permission requirements under `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, and EC2 artifacts under `resources`. The parent runner configuration owns the shared runner role, provider-policy attachments, Lambda functions, execution roles, schedules, queues, retry flow, and SSM housekeeper.
+
+EC2 is the only active compute provider. The parent runner configuration selects it when `aws.ec2` is the one populated typed leaf under `compute_provider`; no separate namespace or type input is required. Runner-config dispatches this module at `module.compute_aws_ec2[0]` from the `modules/compute-providers/aws/ec2` source and publishes its resources under `provider.aws.ec2`. A future provider must add its own typed namespace and provider leaf and implement the same contracts before it can be selected.
+
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.4.0 |
+| [aws](#requirement\_aws) | >= 6.33 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [aws](#provider\_aws) | >= 6.33 |
+| [terraform](#provider\_terraform) | n/a |
+
+## Modules
+
+No modules.
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [aws_cloudwatch_log_group.gh_runners](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource |
+| [aws_iam_instance_profile.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_instance_profile) | resource |
+| [aws_iam_policy.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource |
+| [aws_launch_template.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template) | resource |
+| [aws_security_group.runner_sg](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource |
+| [aws_ssm_parameter.cloudwatch_agent_config_runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
+| [aws_ssm_parameter.runner_ami_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
+| [aws_ssm_parameter.runner_config_run_as](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
+| [aws_ssm_parameter.runner_enable_cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
+| [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
+| [terraform_data.validate_runner](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
+| [aws_ami.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source |
+| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
+| [aws_iam_policy_document.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.create_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.describe_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.distribution_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.service_linked_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.session_manager](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.ssm_parameters](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.terminate_self](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM ARNs. | `string` | `"aws"` | no |
+| [aws\_region](#input\_aws\_region) | AWS region used by compute-provider resources and policy documents. | `string` | n/a | yes |
+| [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.aws.ec2` in the runner configuration.
- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `on_demand_failover_for_errors`: EC2 errors that trigger on-demand fallback after a Spot failure.
- `scale_errors`: EC2 errors treated as retryable scale-up failures.
- `use_dedicated_host`: Enables the dedicated-host launch path. |
object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
vpc_id = string
subnet_ids = list(string)
overrides = optional(object({
name_runner = optional(string, "")
name_sg = optional(string, "")
}), {})
instance_profile = optional(object({
name = string
}), null)
instance_profile_path = optional(string, null)
binaries_syncer = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
arn = string
id = string
key = string
}), null)
}), {})
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
ebs_optimized = optional(bool, false)
instance_target_capacity_type = optional(string, "spot")
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_types = list(string)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
ssm_enabled = optional(bool, false)
create_service_linked_role_spot = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
managed_security_group_enabled = optional(bool, true)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
key_name = optional(string, null)
additional_security_group_ids = optional(list(string), [])
detailed_monitoring_enabled = optional(bool, false)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), [{
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
prefix_list_ids = null
from_port = 0
protocol = "-1"
security_groups = null
self = null
to_port = 0
description = null
}])
tags = optional(map(string), {})
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
credit_specification = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(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)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
associate_public_ipv4_address = optional(bool, false)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
use_dedicated_host = optional(bool, false)
}) | n/a | yes |
+| [github](#input\_github) | GitHub Enterprise Server settings available to compute-provider bootstrap data.object({
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
}) | `{}` | no |
+| [observability](#input\_observability) | CloudWatch Logs settings available to compute-provider runner log groups.object({
logs = optional(object({
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
tags = optional(map(string), {})
}), {})
}) | `{}` | no |
+| [prefix](#input\_prefix) | Prefix used to identify resources created for the runner configuration. | `string` | `"github-actions"` | no |
+| [runner](#input\_runner) | Provider-neutral runner settings consumed by compute providers.object({
os = optional(string, "linux")
architecture = optional(string, "x64")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = object({
role = object({
arn = string
name = string
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
}) | n/a | yes |
+| [ssm](#input\_ssm) | Parameter Store paths and tag scopes available to compute-provider bootstrap resources.object({
paths = object({
root = string
tokens = string
config = string
})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
}) | n/a | yes |
+| [tags](#input\_tags) | Base tags available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. |
+| [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. |
+| [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-config. |
+| [resources](#output\_resources) | Provider-specific EC2 resources exposed by runner-config. |
+
diff --git a/modules/compute-providers/aws/ec2/control-plane.tf b/modules/compute-providers/aws/ec2/control-plane.tf
new file mode 100644
index 0000000000..1b25442032
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/control-plane.tf
@@ -0,0 +1,216 @@
+# EC2-specific IAM and environment fragments consumed by the common control
+# plane in runner-config.
+data "aws_iam_policy_document" "ami_id_ssm_parameter_read" {
+ count = local.ami_id_ssm_external ? 1 : 0
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParameter"]
+ resources = [local.ami_id_ssm_parameter_arn]
+ }
+}
+
+resource "aws_iam_policy" "ami_id_ssm_parameter_read" {
+ count = local.ami_id_ssm_external ? 1 : 0
+ name = "${var.prefix}-ami-id-ssm-parameter-read"
+ path = local.role_path
+ description = "Allows for reading ${var.prefix} GitHub runner AMI ID from an SSM parameter"
+ tags = local.provider_tags
+ policy = data.aws_iam_policy_document.ami_id_ssm_parameter_read[0].json
+}
+
+data "aws_iam_policy_document" "scale_up" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "ec2:DescribeInstances",
+ "ec2:DescribeLaunchTemplateVersions",
+ "ec2:DescribeTags",
+ "ec2:RunInstances",
+ "ec2:CreateFleet",
+ "ec2:CreateTags",
+ ]
+ resources = ["*"]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ec2:TerminateInstances"]
+ resources = ["*"]
+
+ condition {
+ test = "StringEquals"
+ variable = "ec2:ResourceTag/ghr:Application"
+ values = ["github-action-runner"]
+ }
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ec2:TerminateInstances"]
+ resources = ["*"]
+
+ condition {
+ test = "StringEquals"
+ variable = "ec2:ResourceTag/ghr:environment"
+ values = [var.prefix]
+ }
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["iam:PassRole"]
+ resources = [var.runner.iam.role.arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParameter", "ssm:GetParameters"]
+ resources = [local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : local.ami_id_ssm_parameter_arn]
+ }
+
+ dynamic "statement" {
+ for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : []
+
+ content {
+ effect = "Allow"
+ actions = ["kms:DescribeKey", "kms:ReEncrypt*", "kms:Decrypt"]
+ resources = [statement.value]
+ }
+ }
+
+ dynamic "statement" {
+ for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : []
+
+ content {
+ effect = "Allow"
+ actions = ["kms:CreateGrant"]
+ resources = [statement.value]
+
+ condition {
+ test = "Bool"
+ variable = "aws:ViaAWSService"
+ values = ["true"]
+ }
+ }
+ }
+}
+
+data "aws_iam_policy_document" "scale_down" {
+ statement {
+ effect = "Allow"
+ actions = ["ec2:DescribeInstances", "ec2:DescribeTags"]
+ resources = ["*"]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ec2:TerminateInstances", "ec2:CreateTags", "ec2:DeleteTags"]
+ resources = ["*"]
+
+ condition {
+ test = "StringEquals"
+ variable = "ec2:ResourceTag/ghr:Application"
+ values = ["github-action-runner"]
+ }
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ec2:TerminateInstances", "ec2:CreateTags", "ec2:DeleteTags"]
+ resources = ["*"]
+
+ condition {
+ test = "StringEquals"
+ variable = "ec2:ResourceTag/ghr:environment"
+ values = [var.prefix]
+ }
+ }
+}
+
+data "aws_iam_policy_document" "pool" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "ec2:DescribeInstances",
+ "ec2:DescribeTags",
+ "ec2:RunInstances",
+ "ec2:CreateFleet",
+ "ec2:CreateTags",
+ ]
+ resources = ["*"]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["iam:PassRole"]
+ resources = [var.runner.iam.role.arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParameters"]
+ resources = [local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : local.ami_id_ssm_parameter_arn]
+ }
+
+ dynamic "statement" {
+ for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : []
+
+ content {
+ effect = "Allow"
+ actions = ["kms:DescribeKey", "kms:ReEncrypt*", "kms:Decrypt"]
+ resources = [statement.value]
+ }
+ }
+
+ dynamic "statement" {
+ for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : []
+
+ content {
+ effect = "Allow"
+ actions = ["kms:CreateGrant"]
+ resources = [statement.value]
+
+ condition {
+ test = "Bool"
+ variable = "aws:ViaAWSService"
+ values = ["true"]
+ }
+ }
+ }
+}
+
+data "aws_iam_policy_document" "service_linked_role" {
+ count = var.config.create_service_linked_role_spot ? 1 : 0
+
+ statement {
+ effect = "Allow"
+ actions = ["iam:CreateServiceLinkedRole"]
+ resources = ["arn:${var.aws_partition}:iam::*:role/aws-service-role/*"]
+ }
+}
+
+locals {
+ scale_up_environment_variables = {
+ AMI_ID_SSM_PARAMETER_NAME = local.ami_id_ssm_parameter_name
+ INSTANCE_ALLOCATION_STRATEGY = var.config.instance_allocation_strategy
+ INSTANCE_MAX_SPOT_PRICE = var.config.instance_max_spot_price
+ INSTANCE_TARGET_CAPACITY_TYPE = var.config.instance_target_capacity_type
+ INSTANCE_TYPE_PRIORITIES = var.config.instance_type_priorities != null ? jsonencode(var.config.instance_type_priorities) : ""
+ INSTANCE_TYPES = join(",", var.config.instance_types)
+ LAUNCH_TEMPLATE_NAME = aws_launch_template.runner.name
+ SUBNET_IDS = join(",", var.config.subnet_ids)
+ ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.config.on_demand_failover_for_errors)
+ SCALE_ERRORS = jsonencode(var.config.scale_errors)
+ USE_DEDICATED_HOST = var.config.use_dedicated_host
+ }
+
+ scale_down_environment_variables = {}
+
+ pool_environment_variables = local.scale_up_environment_variables
+
+ scale_up_iam_policy_json = data.aws_iam_policy_document.scale_up.json
+ scale_down_iam_policy_json = data.aws_iam_policy_document.scale_down.json
+ pool_iam_policy_json = data.aws_iam_policy_document.pool.json
+ service_linked_role_policy_json = var.config.create_service_linked_role_spot ? data.aws_iam_policy_document.service_linked_role[0].json : null
+}
diff --git a/modules/compute-providers/aws/ec2/instance-profile.tf b/modules/compute-providers/aws/ec2/instance-profile.tf
new file mode 100644
index 0000000000..f01a865f73
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/instance-profile.tf
@@ -0,0 +1,9 @@
+# The common runner configuration owns the role; EC2 owns the profile consumed by its
+# launch template.
+resource "aws_iam_instance_profile" "runner" {
+ count = var.config.instance_profile == null ? 1 : 0
+ name = "${var.prefix}-runner-profile"
+ role = var.runner.iam.role.name
+ path = local.instance_profile_path
+ tags = local.provider_tags
+}
diff --git a/modules/compute-providers/aws/ec2/logging.tf b/modules/compute-providers/aws/ec2/logging.tf
new file mode 100644
index 0000000000..00ae952e4d
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/logging.tf
@@ -0,0 +1,75 @@
+# EC2 runner log collection and CloudWatch resources.
+locals {
+ runner_log_files = (
+ var.config.log_files != null
+ ? var.config.log_files
+ : [
+ {
+ "prefix_log_group" : true,
+ "file_path" : "/var/log/messages",
+ "log_group_name" : "messages",
+ "log_stream_name" : "{instance_id}",
+ "log_class" : "STANDARD"
+ },
+ {
+ "log_group_name" : "user_data",
+ "prefix_log_group" : true,
+ "file_path" : var.runner.os == "windows" ? "C:/UserData.log" : "/var/log/user-data.log",
+ "log_stream_name" : "{instance_id}",
+ "log_class" : "STANDARD"
+ },
+ {
+ "log_group_name" : "runner",
+ "prefix_log_group" : true,
+ "file_path" : var.runner.os == "windows" ? "C:/actions-runner/_diag/Runner_*.log" : "/opt/actions-runner/_diag/Runner_**.log",
+ "log_stream_name" : "{instance_id}",
+ "log_class" : "STANDARD"
+ },
+ {
+ "log_group_name" : "runner-startup",
+ "prefix_log_group" : true,
+ "file_path" : var.runner.os == "windows" ? "C:/runner-startup.log" : "/var/log/runner-startup.log",
+ "log_stream_name" : "{instance_id}",
+ "log_class" : "STANDARD"
+ }
+ ]
+ )
+ # CloudWatch agent collect_list schema expects log_group_class, not log_class
+ logfiles = var.config.cloudwatch_agent.enabled ? [for l in local.runner_log_files : {
+ "log_group_name" : l.prefix_log_group ? "/github-self-hosted-runners/${var.prefix}/${l.log_group_name}" : "/${l.log_group_name}"
+ "log_stream_name" : l.log_stream_name
+ "file_path" : l.file_path
+ "log_group_class" : l.log_class
+ }] : []
+
+ loggroups_names = distinct([for l in local.logfiles : l.log_group_name])
+ # Create a list of unique log classes corresponding to each log group name
+ # This maintains the same order as loggroups_names for use with count
+ loggroups_classes = [
+ for name in local.loggroups_names : [
+ for l in local.logfiles : l.log_group_class
+ if l.log_group_name == name
+ ][0]
+ ]
+
+}
+
+
+resource "aws_ssm_parameter" "cloudwatch_agent_config_runner" {
+ count = var.config.cloudwatch_agent.enabled ? 1 : 0
+ name = "${var.ssm.paths.root}/${var.ssm.paths.config}/cloudwatch_agent_config_runner"
+ type = "String"
+ value = var.config.cloudwatch_agent.config != null ? var.config.cloudwatch_agent.config : templatefile("${path.module}/templates/cloudwatch_config.json", {
+ logfiles = jsonencode(local.logfiles)
+ })
+ tags = local.ssm_parameter_tags
+}
+
+resource "aws_cloudwatch_log_group" "gh_runners" {
+ count = length(local.loggroups_names)
+ name = local.loggroups_names[count.index]
+ retention_in_days = var.observability.logs.retention_in_days
+ kms_key_id = var.observability.logs.kms_key_id
+ log_group_class = local.loggroups_classes[count.index]
+ tags = local.log_group_tags
+}
diff --git a/modules/compute-providers/aws/ec2/outputs.tf b/modules/compute-providers/aws/ec2/outputs.tf
new file mode 100644
index 0000000000..422383df0f
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/outputs.tf
@@ -0,0 +1,23 @@
+output "environment_variables" {
+ description = "Provider-specific Lambda environment variable fragments consumed by runner-config."
+ value = local.provider_environment_variables
+}
+
+output "policies" {
+ description = "Provider-specific IAM policy fragments consumed by runner-config."
+ value = local.provider_policies
+}
+
+output "resources" {
+ description = "Provider-specific EC2 resources exposed by runner-config."
+ value = local.provider_resources
+}
+
+output "provider" {
+ description = "Nested EC2 compute-provider contract consumed by runner-config."
+ value = {
+ environment_variables = local.provider_environment_variables
+ policies = local.provider_policies
+ resources = local.provider_resources
+ }
+}
diff --git a/modules/compute-providers/aws/ec2/policies-runner.tf b/modules/compute-providers/aws/ec2/policies-runner.tf
new file mode 100644
index 0000000000..c16077debc
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/policies-runner.tf
@@ -0,0 +1,206 @@
+# EC2 runner permission documents returned to runner-config for attachment to
+# the common runner role.
+data "aws_caller_identity" "current" {}
+
+locals {
+ ssm_parameter_arn_prefix = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter"
+ ssm_config_arn = "${local.ssm_parameter_arn_prefix}${var.ssm.paths.root}/${var.ssm.paths.config}"
+ cloudwatch_config_arn = "${local.ssm_config_arn}/cloudwatch_agent_config_runner"
+}
+
+data "aws_iam_policy_document" "ssm_parameters" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "ssm:DeleteParameter",
+ "ssm:GetParameters",
+ "ssm:GetParameter",
+ ]
+ resources = [
+ "${local.ssm_parameter_arn_prefix}${var.ssm.paths.root}/${var.ssm.paths.tokens}/*",
+ ]
+
+ condition {
+ test = "StringLike"
+ variable = "ec2:SourceInstanceARN"
+ values = ["*/&{aws:ResourceTag/InstanceId}"]
+ }
+ }
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "ssm:GetParameter",
+ "ssm:GetParameters",
+ "ssm:GetParametersByPath",
+ ]
+ resources = [
+ local.ssm_config_arn,
+ "${local.ssm_config_arn}/*",
+ ]
+ }
+}
+
+data "aws_iam_policy_document" "session_manager" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "ssm:DescribeAssociation",
+ "ssm:GetDeployablePatchSnapshotForInstance",
+ "ssm:GetDocument",
+ "ssm:DescribeDocument",
+ "ssm:GetManifest",
+ "ssm:ListAssociations",
+ "ssm:ListInstanceAssociations",
+ "ssm:PutInventory",
+ "ssm:PutComplianceItems",
+ "ssm:PutConfigurePackageResult",
+ "ssm:UpdateAssociationStatus",
+ "ssm:UpdateInstanceAssociationStatus",
+ "ssm:UpdateInstanceInformation",
+ ]
+ resources = ["*"]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "ssmmessages:CreateControlChannel",
+ "ssmmessages:CreateDataChannel",
+ "ssmmessages:OpenControlChannel",
+ "ssmmessages:OpenDataChannel",
+ ]
+ resources = ["*"]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "ec2messages:AcknowledgeMessage",
+ "ec2messages:DeleteMessage",
+ "ec2messages:FailMessage",
+ "ec2messages:GetEndpoint",
+ "ec2messages:GetMessages",
+ "ec2messages:SendReply",
+ ]
+ resources = ["*"]
+ }
+}
+
+data "aws_iam_policy_document" "distribution_bucket" {
+ count = var.config.binaries_syncer.enabled ? 1 : 0
+
+ statement {
+ sid = "githubActionDist"
+ effect = "Allow"
+ actions = ["s3:GetObject", "s3:GetObjectAcl"]
+ resources = ["${try(var.config.binaries_syncer.s3.arn, "")}/${try(var.config.binaries_syncer.s3.key, "")}"]
+ }
+}
+
+data "aws_iam_policy_document" "describe_tags" {
+ statement {
+ effect = "Allow"
+ actions = ["ec2:DescribeTags"]
+ resources = ["*"]
+ }
+}
+
+data "aws_iam_policy_document" "create_tags" {
+ statement {
+ effect = "Allow"
+ actions = ["ec2:CreateTags"]
+ resources = ["arn:*:ec2:*:*:instance/*"]
+
+ condition {
+ test = "ForAllValues:StringEquals"
+ variable = "aws:TagKeys"
+ values = ["ghr:github_runner_id"]
+ }
+
+ condition {
+ test = "StringEquals"
+ variable = "aws:ARN"
+ values = ["&{ec2:SourceInstanceARN}"]
+ }
+ }
+}
+
+data "aws_iam_policy_document" "terminate_self" {
+ statement {
+ effect = "Allow"
+ actions = ["ec2:TerminateInstances"]
+ resources = ["*"]
+
+ condition {
+ test = "StringEquals"
+ variable = "aws:ARN"
+ values = ["&{ec2:SourceInstanceARN}"]
+ }
+ }
+}
+
+data "aws_iam_policy_document" "cloudwatch" {
+ count = var.config.cloudwatch_agent.enabled ? 1 : 0
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "cloudwatch:PutMetricData",
+ "ec2:DescribeVolumes",
+ "ec2:DescribeTags",
+ "logs:PutLogEvents",
+ "logs:DescribeLogStreams",
+ "logs:DescribeLogGroups",
+ "logs:CreateLogStream",
+ ]
+ resources = ["*"]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParameter"]
+ resources = ["${local.cloudwatch_config_arn}/*"]
+ }
+}
+
+locals {
+ runner_inline_policies = merge(
+ {
+ ssm_parameters = {
+ name = "runner-ssm-parameters"
+ policy_json = data.aws_iam_policy_document.ssm_parameters.json
+ }
+ describe_tags = {
+ name = "runner-describe-tags"
+ policy_json = data.aws_iam_policy_document.describe_tags.json
+ }
+ create_tags = {
+ name = "runner-create-tags"
+ policy_json = data.aws_iam_policy_document.create_tags.json
+ }
+ terminate_self = {
+ name = "ec2"
+ policy_json = data.aws_iam_policy_document.terminate_self.json
+ }
+ },
+ var.config.ssm_enabled ? {
+ session_manager = {
+ name = "runner-ssm-session"
+ policy_json = data.aws_iam_policy_document.session_manager.json
+ }
+ } : {},
+ var.config.binaries_syncer.enabled ? {
+ distribution_bucket = {
+ name = "distribution-bucket"
+ policy_json = data.aws_iam_policy_document.distribution_bucket[0].json
+ }
+ } : {},
+ var.config.cloudwatch_agent.enabled ? {
+ cloudwatch = {
+ name = "CloudWatchLogginAndMetrics"
+ policy_json = data.aws_iam_policy_document.cloudwatch[0].json
+ }
+ } : {},
+ )
+}
diff --git a/modules/compute-providers/aws/ec2/provider-contract.tf b/modules/compute-providers/aws/ec2/provider-contract.tf
new file mode 100644
index 0000000000..5682496d78
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/provider-contract.tf
@@ -0,0 +1,34 @@
+locals {
+ provider_environment_variables = {
+ scale_up = local.scale_up_environment_variables
+ scale_down = local.scale_down_environment_variables
+ pool = local.pool_environment_variables
+ }
+
+ provider_policies = {
+ runner = {
+ inline_policies = local.runner_inline_policies
+ managed_policy_arns = var.runner.iam.managed_policy_arns
+ }
+ scale_up = {
+ iam_policy_json = local.scale_up_iam_policy_json
+ additional_iam_policy_json = local.service_linked_role_policy_json
+ managed_policy_enabled = local.ami_id_ssm_external
+ managed_policy_arn = local.ami_id_ssm_external ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null
+ }
+ scale_down = {
+ iam_policy_json = local.scale_down_iam_policy_json
+ }
+ pool = {
+ iam_policy_json = local.pool_iam_policy_json
+ managed_policy_enabled = local.ami_id_ssm_external
+ managed_policy_arn = local.ami_id_ssm_external ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null
+ }
+ }
+
+ provider_resources = {
+ launch_template = aws_launch_template.runner
+ runners_log_groups = try(aws_cloudwatch_log_group.gh_runners, [])
+ logfiles = local.logfiles
+ }
+}
diff --git a/modules/compute-providers/aws/ec2/runner-config.tf b/modules/compute-providers/aws/ec2/runner-config.tf
new file mode 100644
index 0000000000..f1d859581c
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/runner-config.tf
@@ -0,0 +1,13 @@
+resource "aws_ssm_parameter" "runner_config_run_as" {
+ name = "${var.ssm.paths.root}/${var.ssm.paths.config}/run_as"
+ type = "String"
+ value = var.runner.run_as_root ? "root" : var.runner.run_as
+ tags = local.ssm_parameter_tags
+}
+
+resource "aws_ssm_parameter" "runner_enable_cloudwatch" {
+ name = "${var.ssm.paths.root}/${var.ssm.paths.config}/enable_cloudwatch"
+ type = "String"
+ value = var.config.cloudwatch_agent.enabled
+ tags = local.ssm_parameter_tags
+}
diff --git a/modules/compute-providers/aws/ec2/runner-instances.tf b/modules/compute-providers/aws/ec2/runner-instances.tf
new file mode 100644
index 0000000000..f33ab8f532
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/runner-instances.tf
@@ -0,0 +1,327 @@
+# AMI selection, bootstrap rendering, launch template, and security group for
+# EC2 runner instances.
+locals {
+ provider_tags = merge(
+ {
+ "Name" = format("%s-action-runner", var.prefix)
+ },
+ var.tags,
+ )
+
+ ssm_parameter_tags = merge(
+ local.provider_tags,
+ var.ssm.tags,
+ var.ssm.parameters.tags,
+ )
+
+ log_group_tags = merge(
+ local.provider_tags,
+ var.observability.logs.tags,
+ )
+
+ name_sg = var.config.overrides.name_sg == "" ? local.provider_tags["Name"] : var.config.overrides.name_sg
+ name_runner = var.config.overrides.name_runner == "" ? local.provider_tags["Name"] : var.config.overrides.name_runner
+ runner_tags = merge(
+ local.provider_tags,
+ {
+ "Name" = local.name_runner
+ },
+ var.config.tags,
+ {
+ "ghr:environment" = var.prefix
+ "ghr:ssm_config_path" = "${var.ssm.paths.root}/${var.ssm.paths.config}"
+ "ghr:runner_name_prefix" = var.runner.name_prefix
+ },
+ )
+
+ role_path = var.runner.iam.path == null ? "/${var.prefix}/" : var.runner.iam.path
+ instance_profile_path = var.config.instance_profile_path == null ? "/${var.prefix}/" : var.config.instance_profile_path
+ userdata_template = var.config.user_data.template == null ? local.default_userdata_template[var.runner.os] : var.config.user_data.template
+ s3_location_runner_distribution = var.config.binaries_syncer.enabled ? "s3://${try(var.config.binaries_syncer.s3.id, "")}/${try(var.config.binaries_syncer.s3.key, "")}" : ""
+ default_ami = {
+ "windows" = { name = ["Windows_Server-2022-English-Full-ECS_Optimized-*"] }
+ "linux" = var.runner.architecture == "arm64" ? { name = ["al2023-ami-2023.*-kernel-6.*-arm64"] } : { name = ["al2023-ami-2023.*-kernel-6.*-x86_64"] }
+ "osx" = var.runner.architecture == "arm64" ? { name = ["amzn-ec2-macos-15.*-arm64"] } : { name = ["amzn-ec2-macos-15.*"] }
+ }
+
+ default_userdata_template = {
+ "windows" = "${path.module}/templates/user-data.ps1"
+ "linux" = "${path.module}/templates/user-data.sh"
+ "osx" = "${path.module}/templates/user-data-osx.sh"
+ }
+
+ userdata_install_runner = {
+ "windows" = "${path.module}/templates/install-runner.ps1"
+ "linux" = "${path.module}/templates/install-runner.sh"
+ "osx" = "${path.module}/templates/install-runner-osx.sh"
+ }
+
+ userdata_start_runner = {
+ "windows" = "${path.module}/templates/start-runner.ps1"
+ "linux" = "${path.module}/templates/start-runner.sh"
+ "osx" = "${path.module}/templates/start-runner-osx.sh"
+ }
+
+ # Handle AMI configuration
+ ami_config = var.config.ami != null ? var.config.ami : {
+ filter = local.default_ami[var.runner.os]
+ owners = ["amazon"]
+ id_ssm_parameter = null
+ kms_key = null
+ }
+ ami_kms_key_enabled = local.ami_config.kms_key != null
+ ami_kms_key_arn = local.ami_kms_key_enabled ? local.ami_config.kms_key.arn : null
+ ami_filter = merge(local.default_ami[var.runner.os], local.ami_config.filter)
+ ami_id_ssm_external = local.ami_config.id_ssm_parameter != null
+ ami_id_ssm_module_managed = !local.ami_id_ssm_external
+ ami_id_ssm_parameter_arn = local.ami_id_ssm_external ? local.ami_config.id_ssm_parameter.arn : null
+ # Extract parameter name from ARN (format: arn:aws:ssm:region:account:parameter/path/to/param)
+ ami_id_ssm_parameter_name = local.ami_id_ssm_external ? try(regex("parameter(/.+)$", local.ami_id_ssm_parameter_arn)[0], null) : null
+
+ user_data = var.config.user_data.enabled ? (var.config.user_data.content == null ? templatefile(local.userdata_template, {
+ enable_debug_logging = var.config.user_data.debug_logging_enabled
+ s3_location_runner_distribution = local.s3_location_runner_distribution
+ pre_install = var.config.user_data.pre_install
+ install_runner = templatefile(local.userdata_install_runner[var.runner.os], {
+ S3_LOCATION_RUNNER_DISTRIBUTION = local.s3_location_runner_distribution
+ RUNNER_ARCHITECTURE = var.runner.architecture
+ })
+ post_install = var.config.user_data.post_install
+ hook_job_started = var.runner.hooks.job_started
+ hook_job_completed = var.runner.hooks.job_completed
+ start_runner = templatefile(local.userdata_start_runner[var.runner.os], {
+ metadata_tags = var.config.metadata_options != null ? var.config.metadata_options.instance_metadata_tags : "enabled"
+ })
+ ghes_url = var.github.enterprise_server.url
+ ghes_ssl_verify = var.github.enterprise_server.ssl_verify
+
+ ## retain these for backwards compatibility
+ environment = var.prefix
+ enable_cloudwatch_agent = var.config.cloudwatch_agent.enabled
+ ssm_key_cloudwatch_agent_config = var.config.cloudwatch_agent.enabled ? aws_ssm_parameter.cloudwatch_agent_config_runner[0].name : ""
+ }) : var.config.user_data.content) : ""
+
+ encoded_user_data = (
+ var.runner.os == "linux" ? base64gzip(local.user_data) :
+ var.runner.os == "windows" ? base64encode(local.user_data) :
+ var.runner.os == "osx" ? base64encode(local.user_data) :
+ null
+ )
+}
+
+data "aws_ami" "runner" {
+ count = local.ami_id_ssm_module_managed ? 1 : 0
+
+ most_recent = "true"
+
+ dynamic "filter" {
+ for_each = local.ami_filter
+ content {
+ name = filter.key
+ values = filter.value
+ }
+ }
+
+ owners = local.ami_config.owners
+}
+
+resource "aws_ssm_parameter" "runner_ami_id" {
+ count = local.ami_id_ssm_module_managed ? 1 : 0
+ name = "${var.ssm.paths.root}/${var.ssm.paths.config}/ami_id"
+ type = "String"
+ data_type = "aws:ec2:image"
+ value = data.aws_ami.runner[0].id
+
+ tags = merge(
+ local.provider_tags,
+ local.ssm_parameter_tags,
+ {
+ # Remove parentheses from AMI name to comply with AWS tag constraints
+ "ghr:ami_name" = replace(data.aws_ami.runner[0].name, "/[()]/", "")
+ },
+ {
+ "ghr:ami_creation_date" = data.aws_ami.runner[0].creation_date
+ },
+ {
+ "ghr:ami_deprecation_time" = data.aws_ami.runner[0].deprecation_time
+ }
+ )
+}
+
+resource "aws_launch_template" "runner" {
+ name = "${var.prefix}-action-runner"
+
+ dynamic "block_device_mappings" {
+ for_each = var.config.block_device_mappings != null ? var.config.block_device_mappings : []
+ content {
+ device_name = block_device_mappings.value.device_name
+
+ ebs {
+ delete_on_termination = block_device_mappings.value.delete_on_termination
+ encrypted = block_device_mappings.value.encrypted
+ iops = block_device_mappings.value.iops
+ kms_key_id = block_device_mappings.value.kms_key_id
+ snapshot_id = block_device_mappings.value.snapshot_id
+ throughput = block_device_mappings.value.throughput
+ volume_initialization_rate = block_device_mappings.value.volume_initialization_rate
+ volume_size = block_device_mappings.value.volume_size
+ volume_type = block_device_mappings.value.volume_type
+ }
+ }
+ }
+
+ dynamic "metadata_options" {
+ for_each = var.config.metadata_options != null ? [var.config.metadata_options] : []
+
+ content {
+ http_endpoint = metadata_options.value.http_endpoint
+ http_tokens = metadata_options.value.http_tokens
+ http_put_response_hop_limit = metadata_options.value.http_put_response_hop_limit
+ instance_metadata_tags = metadata_options.value.instance_metadata_tags
+ }
+ }
+
+ dynamic "metadata_options" {
+ for_each = var.config.metadata_options != null ? [] : [0]
+
+ content {
+ instance_metadata_tags = "enabled"
+ }
+ }
+
+ dynamic "credit_specification" {
+ for_each = var.config.credit_specification != null ? [var.config.credit_specification] : []
+ content {
+ cpu_credits = credit_specification.value
+ }
+ }
+
+ dynamic "cpu_options" {
+ for_each = var.config.cpu_options != null ? [var.config.cpu_options] : []
+ content {
+ core_count = try(cpu_options.value.core_count, null)
+ threads_per_core = try(cpu_options.value.threads_per_core, null)
+ amd_sev_snp = try(cpu_options.value.amd_sev_snp, null)
+ nested_virtualization = try(cpu_options.value.nested_virtualization, null)
+ }
+ }
+
+ dynamic "placement" {
+ for_each = var.config.placement != null ? [var.config.placement] : []
+ content {
+ affinity = try(placement.value.affinity, null)
+ availability_zone = try(placement.value.availability_zone, null)
+ group_id = try(placement.value.group_id, null)
+ group_name = try(placement.value.group_name, null)
+ host_id = try(placement.value.host_id, null)
+ host_resource_group_arn = try(placement.value.host_resource_group_arn, null)
+ spread_domain = try(placement.value.spread_domain, null)
+ tenancy = try(placement.value.tenancy, null)
+ partition_number = try(placement.value.partition_number, null)
+ }
+ }
+
+ dynamic "license_specification" {
+ for_each = var.config.license_specifications
+ content {
+ license_configuration_arn = license_specification.value.license_configuration_arn
+ }
+ }
+
+ monitoring {
+ enabled = var.config.detailed_monitoring_enabled
+ }
+
+ iam_instance_profile {
+ name = var.config.instance_profile != null ? var.config.instance_profile.name : aws_iam_instance_profile.runner[0].name
+ }
+
+ instance_initiated_shutdown_behavior = "terminate"
+ image_id = "resolve:ssm:${local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : local.ami_id_ssm_parameter_arn}"
+ key_name = var.config.key_name
+ ebs_optimized = var.config.ebs_optimized
+
+ vpc_security_group_ids = !var.config.associate_public_ipv4_address ? compact(concat(
+ var.config.managed_security_group_enabled ? [aws_security_group.runner_sg[0].id] : [],
+ var.config.additional_security_group_ids,
+ )) : []
+
+ tag_specifications {
+ resource_type = "instance"
+ tags = local.runner_tags
+ }
+
+ tag_specifications {
+ resource_type = "volume"
+ tags = local.runner_tags
+ }
+
+ # We avoid including the "spot-instances-request" tag_specifications block when on_demand_failover_for_errors is defined,
+ # because when using on-demand fallback, the spot instance request resource is not created and thus the tags would not apply.
+ # Additionally, tagging spot requests via the CreateFleetCommand in the Lambda function does not work as expected,
+ # so we rely on Terraform to manage these tags only when spot is exclusively used without on-demand failover.
+ dynamic "tag_specifications" {
+ for_each = var.config.instance_target_capacity_type == "spot" && length(var.config.on_demand_failover_for_errors) == 0 ? [1] : [] # Include the block only if the value is "spot" and on_demand_failover_for_errors is not enabled
+ content {
+ resource_type = "spot-instances-request"
+ tags = local.runner_tags
+ }
+ }
+
+ tag_specifications {
+ resource_type = "network-interface"
+ tags = local.runner_tags
+ }
+
+ user_data = local.encoded_user_data
+
+ tags = local.provider_tags
+
+ update_default_version = true
+
+ dynamic "network_interfaces" {
+ for_each = var.config.associate_public_ipv4_address ? [var.config.associate_public_ipv4_address] : []
+ iterator = associate_public_ipv4_address
+ content {
+ associate_public_ip_address = associate_public_ipv4_address.value
+ security_groups = compact(concat(
+ var.config.managed_security_group_enabled ? [aws_security_group.runner_sg[0].id] : [],
+ var.config.additional_security_group_ids,
+ ))
+ }
+ }
+}
+
+resource "aws_security_group" "runner_sg" {
+ count = var.config.managed_security_group_enabled ? 1 : 0
+ name_prefix = "${var.prefix}-github-actions-runner-sg"
+ description = "Github Actions Runner security group"
+
+ vpc_id = var.config.vpc_id
+
+ ingress = []
+
+ dynamic "egress" {
+ for_each = var.config.egress_rules
+ iterator = each
+
+ content {
+ cidr_blocks = each.value.cidr_blocks
+ ipv6_cidr_blocks = each.value.ipv6_cidr_blocks
+ prefix_list_ids = each.value.prefix_list_ids
+ from_port = each.value.from_port
+ protocol = each.value.protocol
+ security_groups = each.value.security_groups
+ self = each.value.self
+ to_port = each.value.to_port
+ description = each.value.description
+ }
+ }
+
+ tags = merge(
+ local.provider_tags,
+ {
+ "Name" = format("%s", local.name_sg)
+ },
+ )
+}
diff --git a/modules/compute-providers/aws/ec2/templates/cloudwatch_config.json b/modules/compute-providers/aws/ec2/templates/cloudwatch_config.json
new file mode 100644
index 0000000000..47b9bede8a
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/templates/cloudwatch_config.json
@@ -0,0 +1,12 @@
+{
+ "agent": {
+ "metrics_collection_interval": 5
+ },
+ "logs": {
+ "logs_collected": {
+ "files": {
+ "collect_list": ${logfiles}
+ }
+ }
+ }
+}
diff --git a/modules/compute-providers/aws/ec2/templates/install-runner-osx.sh b/modules/compute-providers/aws/ec2/templates/install-runner-osx.sh
new file mode 100644
index 0000000000..ed848dad27
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/templates/install-runner-osx.sh
@@ -0,0 +1,61 @@
+# shellcheck shell=bash
+
+set -euo pipefail
+
+## install the runner (macOS)
+
+s3_location=${S3_LOCATION_RUNNER_DISTRIBUTION}
+architecture=${RUNNER_ARCHITECTURE}
+
+if [ -z "$RUNNER_TARBALL_URL" ] && [ -z "$s3_location" ]; then
+ echo "Neither RUNNER_TARBALL_URL or s3_location are set"
+ exit 1
+fi
+
+file_name="actions-runner.tar.gz"
+
+echo "Setting up GH Actions runner tool cache"
+mkdir -p /Users/runner/hostedtoolcache
+
+echo "Creating actions-runner directory for the GH Action installation"
+sudo mkdir -p /opt/actions-runner
+cd /opt/actions-runner || exit 1
+
+if [[ -n "$runner_tarball_url" ]]; then
+ echo "Downloading the GH Action runner from $runner_tarball_url to $file_name"
+ curl -s -o "$file_name" -L "$runner_tarball_url"
+else
+ echo "Retrieving REGION from AWS API"
+ token="$(curl -s -f -X PUT "http://169.254.169.254/latest/api/token" \
+ -H "X-aws-ec2-metadata-token-ttl-seconds: 180")"
+
+ region="$(curl -s -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/dynamic/instance-identity/document | jq -r .region)"
+ echo "Retrieved REGION from AWS API ($region)"
+
+ echo "Downloading the GH Action runner from s3 bucket $s3_location"
+ aws s3 cp "$s3_location" "$file_name" --region "$region" --no-progress
+fi
+
+echo "Un-tar action runner"
+tar xzf "./$file_name"
+echo "Delete tar file"
+rm -rf "$file_name"
+
+os_name=$(sw_vers -productName 2>/dev/null || echo "macOS")
+os_version=$(sw_vers -productVersion 2>/dev/null || echo "unknown")
+arch_name=$(uname -m)
+
+echo "OS: $os_name $os_version ($arch_name)"
+
+if ! command -v brew >/dev/null 2>&1; then
+ echo "Homebrew not found; skipping dependency installation via brew"
+else
+ echo "Homebrew detected; install any macOS-specific dependencies here if needed"
+ # Example: brew install jq awscli
+fi
+
+echo "Set file ownership of action runner"
+sudo chown -R "$user_name":staff /opt/actions-runner
+sudo chmod 755 "/Users/runner"
+sudo chown -R "$user_name":staff /Users/runner/hostedtoolcache
diff --git a/modules/compute-providers/aws/ec2/templates/install-runner.ps1 b/modules/compute-providers/aws/ec2/templates/install-runner.ps1
new file mode 100644
index 0000000000..a13f91a65b
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/templates/install-runner.ps1
@@ -0,0 +1,13 @@
+## install the runner
+
+Write-Host "Creating actions-runner directory for the GH Action installation"
+New-Item -ItemType Directory -Path C:\actions-runner ; Set-Location C:\actions-runner
+
+Write-Host "Downloading the GH Action runner from s3 bucket $s3_location"
+aws s3 cp ${S3_LOCATION_RUNNER_DISTRIBUTION} actions-runner.zip
+
+Write-Host "Un-zip action runner"
+Expand-Archive -Path actions-runner.zip -DestinationPath .
+
+Write-Host "Delete zip file"
+Remove-Item actions-runner.zip
diff --git a/modules/compute-providers/aws/ec2/templates/install-runner.sh b/modules/compute-providers/aws/ec2/templates/install-runner.sh
new file mode 100644
index 0000000000..5ed5897e7c
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/templates/install-runner.sh
@@ -0,0 +1,73 @@
+# shellcheck shell=bash
+
+## install the runner
+
+s3_location=${S3_LOCATION_RUNNER_DISTRIBUTION}
+
+if [ -z "$RUNNER_TARBALL_URL" ] && [ -z "$s3_location" ]; then
+ echo "Neither RUNNER_TARBALL_URL or s3_location are set"
+ exit 1
+fi
+
+file_name="actions-runner.tar.gz"
+
+echo "Setting up GH Actions runner tool cache"
+# Required for various */setup-* actions to work, location is also know by various environment
+# variable names in the actions/runner software : RUNNER_TOOL_CACHE / RUNNER_TOOLSDIRECTORY / AGENT_TOOLSDIRECTORY
+# Warning, not all setup actions support the env vars and so this specific path must be created regardless
+mkdir -p /opt/hostedtoolcache
+
+echo "Creating actions-runner directory for the GH Action installation"
+cd /opt/
+mkdir -p actions-runner && cd actions-runner
+
+
+if [[ -n "$RUNNER_TARBALL_URL" ]]; then
+ echo "Downloading the GH Action runner from $RUNNER_TARBALL_URL to $file_name"
+ curl -s -o $file_name -L "$RUNNER_TARBALL_URL"
+else
+ echo "Retrieving TOKEN from AWS API"
+ token="$(curl -s -f -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 180")"
+
+ region="$(curl -s -f -H "X-aws-ec2-metadata-token: $token" http://169.254.169.254/latest/dynamic/instance-identity/document | jq -r .region)"
+ echo "Retrieved REGION from AWS API ($region)"
+
+ echo "Downloading the GH Action runner from s3 bucket $s3_location"
+ aws s3 cp "$s3_location" "$file_name" --region "$region" --no-progress
+fi
+
+echo "Un-tar action runner"
+tar xzf ./$file_name
+echo "Delete tar file"
+rm -rf $file_name
+
+os_id=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
+echo OS: $os_id
+
+# Install libicu on non-ubuntu, non-debian
+if [[ ! "$os_id" =~ ^(ubuntu|debian).* ]]; then
+ max_attempts=5
+ attempt_count=0
+ success=false
+ while [ $success = false ] && [ $attempt_count -le $max_attempts ]; do
+ echo "Attempt $attempt_count/$max_attempts: Installing libicu"
+ dnf install -y libicu
+ if [ $? -eq 0 ]; then
+ success=true
+ else
+ echo "Failed to install libicu"
+ attempt_count=$(( attempt_count + 1 ))
+ sleep 5
+ fi
+ done
+fi
+
+# Install dependencies for ubuntu and debian
+if [[ "$os_id" =~ ^(ubuntu|debian).* ]]; then
+ echo "Installing dependencies"
+ ./bin/installdependencies.sh
+fi
+
+echo "Set file ownership of action runner"
+chown -R "$user_name":"$user_name" /opt/actions-runner
+chown -R "$user_name":"$user_name" /opt/hostedtoolcache
diff --git a/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh b/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh
new file mode 100644
index 0000000000..a6da66116d
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh
@@ -0,0 +1,185 @@
+#!/bin/bash
+
+# macOS variant of start-runner.sh
+
+tag_instance_with_runner_id() {
+ echo "Checking for .runner file to extract agent ID"
+
+ if [[ ! -f "/opt/actions-runner/.runner" ]]; then
+ echo "Warning: .runner file not found"
+ return 0
+ fi
+
+ echo "Found .runner file, extracting agent ID"
+ local agent_id
+ agent_id=$(jq -r '.agentId' /opt/actions-runner/.runner 2>/dev/null || echo "")
+
+ if [[ -z "$agent_id" || "$agent_id" == "null" ]]; then
+ echo "Warning: Could not extract agent ID from .runner file"
+ return 0
+ fi
+
+ echo "Tagging instance with GitHub runner agent ID: $agent_id"
+ if aws ec2 create-tags \
+ --region "$region" \
+ --resources "$instance_id" \
+ --tags Key=ghr:github_runner_id,Value="$agent_id"; then
+ echo "Successfully tagged instance with agent ID: $agent_id"
+ return 0
+ else
+ echo "Warning: Failed to tag instance with agent ID"
+ return 0
+ fi
+}
+
+cleanup() {
+ local exit_code="$1"
+
+ if [ "$exit_code" -ne 0 ]; then
+ echo "ERROR: runner-start-failed with exit code $exit_code"
+ fi
+
+ if [ "$agent_mode" = "ephemeral" ] || [ "$exit_code" -ne 0 ]; then
+ echo "Terminating instance"
+ aws ec2 terminate-instances \
+ --instance-ids "$instance_id" \
+ --region "$region" || true
+ fi
+}
+
+trap 'cleanup $?' EXIT
+
+echo "Retrieving TOKEN from AWS API"
+token=$(curl -f -X PUT "http://169.254.169.254/latest/api/token" \
+ -H "X-aws-ec2-metadata-token-ttl-seconds: 180" || true)
+if [ -z "$token" ]; then
+ retrycount=0
+ until [ -n "$token" ]; do
+ echo "Failed to retrieve token. Retrying in 5 seconds."
+ sleep 5
+ token=$(curl -f -X PUT "http://169.254.169.254/latest/api/token" \
+ -H "X-aws-ec2-metadata-token-ttl-seconds: 180" || true)
+ retrycount=$((retrycount + 1))
+ if [ $retrycount -gt 40 ]; then
+ break
+ fi
+ done
+fi
+
+region=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/dynamic/instance-identity/document | jq -r .region)
+echo "Retrieved REGION from AWS API ($region)"
+
+instance_id=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/meta-data/instance-id)
+echo "Retrieved INSTANCE_ID from AWS API ($instance_id)"
+
+availability_zone=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/meta-data/placement/availability-zone)
+
+environment=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/meta-data/tags/instance/ghr:environment || echo "")
+ssm_config_path=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/meta-data/tags/instance/ghr:ssm_config_path || echo "")
+runner_name_prefix=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/meta-data/tags/instance/ghr:runner_name_prefix || echo "")
+
+echo "Retrieved ghr:environment tag - ($environment)"
+echo "Retrieved ghr:ssm_config_path tag - ($ssm_config_path)"
+echo "Retrieved ghr:runner_name_prefix tag - ($runner_name_prefix)"
+
+parameters=$(aws ssm get-parameters-by-path \
+ --path "$ssm_config_path" \
+ --region "$region" \
+ --query "Parameters[*].{Name:Name,Value:Value}")
+echo "Retrieved parameters from AWS SSM ($parameters)"
+
+run_as=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/run_as") | .Value')
+echo "Retrieved /$ssm_config_path/run_as parameter - ($run_as)"
+
+agent_mode=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/agent_mode") | .Value')
+echo "Retrieved /$ssm_config_path/agent_mode parameter - ($agent_mode)"
+
+disable_default_labels=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/disable_default_labels") | .Value')
+echo "Retrieved /$ssm_config_path/disable_default_labels parameter - ($disable_default_labels)"
+
+enable_jit_config=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/enable_jit_config") | .Value')
+echo "Retrieved /$ssm_config_path/enable_jit_config parameter - ($enable_jit_config)"
+
+token_path=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/token_path") | .Value')
+echo "Retrieved /$ssm_config_path/token_path parameter - ($token_path)"
+
+echo "Get GH Runner config from AWS SSM"
+config=$(aws ssm get-parameter --name "$token_path"/"$instance_id" --with-decryption --region "$region" | jq -r ".Parameter | .Value")
+while [[ -z "$config" ]]; do
+ echo "Waiting for GH Runner config to become available in AWS SSM"
+ sleep 1
+ config=$(aws ssm get-parameter --name "$token_path"/"$instance_id" --with-decryption --region "$region" | jq -r ".Parameter | .Value")
+done
+
+echo "Delete GH Runner token from AWS SSM"
+aws ssm delete-parameter --name "$token_path"/"$instance_id" --region "$region"
+
+if [ -z "$run_as" ]; then
+ echo "No user specified, using default ec2-user account"
+ run_as="ec2-user"
+fi
+
+if [[ "$run_as" == "root" ]]; then
+ echo "run_as is set to root - export RUNNER_ALLOW_RUNASROOT=1"
+ export RUNNER_ALLOW_RUNASROOT=1
+fi
+
+sudo chown -R "$run_as" /opt/actions-runner
+
+info_arch=$(uname -m)
+info_os=$(sw_vers -productName 2>/dev/null || echo "macOS")
+info_ver=$(sw_vers -productVersion 2>/dev/null || echo "unknown")
+
+tee /opt/actions-runner/.setup_info <