From b5121b7d2e50964fd8ba57bbcf5a31352ac10447 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 6 Aug 2026 22:58:22 +0200 Subject: [PATCH 01/27] refactor(multi-runner): decouple EC2 Terraform logic --- .github/workflows/terraform.yml | 19 +- docs/index.md | 2 +- modules/multi-runner/README.md | 8 +- modules/multi-runner/multi-runner-config.tf | 10 +- modules/multi-runner/outputs.tf | 20 +- modules/multi-runner/providers/ec2/README.md | 90 ++++ modules/multi-runner/providers/ec2/main.tf | 131 +++++ modules/multi-runner/providers/ec2/outputs.tf | 22 + .../providers/ec2/tests/provider.tftest.hcl | 69 +++ .../multi-runner/providers/ec2/variables.tf | 489 ++++++++++++++++++ .../multi-runner/providers/ec2/versions.tf | 10 + modules/multi-runner/runners.tf | 150 ++---- modules/multi-runner/tests-upgrade/README.md | 10 + .../fixtures/pre-provider-boundary/README.md | 36 ++ .../fixtures/pre-provider-boundary/main.tf | 47 ++ .../fixtures/pre-provider-boundary/outputs.tf | 9 + .../pre-provider-boundary/versions.tf | 10 + .../tests-upgrade/state-migration.tftest.hcl | 99 ++++ .../tests/provider-routing.tftest.hcl | 133 +++++ 19 files changed, 1233 insertions(+), 131 deletions(-) create mode 100644 modules/multi-runner/providers/ec2/README.md create mode 100644 modules/multi-runner/providers/ec2/main.tf create mode 100644 modules/multi-runner/providers/ec2/outputs.tf create mode 100644 modules/multi-runner/providers/ec2/tests/provider.tftest.hcl create mode 100644 modules/multi-runner/providers/ec2/variables.tf create mode 100644 modules/multi-runner/providers/ec2/versions.tf create mode 100644 modules/multi-runner/tests-upgrade/README.md create mode 100644 modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/README.md create mode 100644 modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/main.tf create mode 100644 modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/outputs.tf create mode 100644 modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/versions.tf create mode 100644 modules/multi-runner/tests-upgrade/state-migration.tftest.hcl create mode 100644 modules/multi-runner/tests/provider-routing.tftest.hcl diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 122ed88025..39190401d2 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -85,6 +85,7 @@ jobs: "download-lambda", "lambda", "multi-runner", + "multi-runner/providers/ec2", "runner-binaries-syncer", "runners", "setup-iam-permissions", @@ -109,7 +110,7 @@ jobs: persist-credentials: false - name: terraform init run: terraform init -get -backend=false -input=false - - if: contains(matrix.terraform, '1.3.') + - if: contains(matrix.terraform, '1.5.') name: check terraform formatting run: terraform fmt -recursive -check=true -write=false - if: contains(matrix.terraform, 'latest') # check formatting for the latest release but avoid failing the build @@ -118,22 +119,22 @@ jobs: continue-on-error: true - name: validate terraform run: terraform validate - - if: contains(matrix.terraform, '1.3.') + - if: contains(matrix.terraform, '1.5.') name: Fix for actions/cache on alpine run: apk add --no-cache tar continue-on-error: true - - if: contains(matrix.terraform, '1.3.') + - if: contains(matrix.terraform, '1.5.') uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 name: Cache TFLint plugin dir with: path: ~/.tflint.d/plugins key: tflint-${{ hashFiles('.tflint.hcl') }} - - if: contains(matrix.terraform, '1.3.') + - if: contains(matrix.terraform, '1.5.') name: Setup TFLint uses: terraform-linters/setup-tflint@b480b8fcdaa6f2c577f8e4fa799e89e756bb7c93 # v6.2.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} - - if: contains(matrix.terraform, '1.3.') + - if: contains(matrix.terraform, '1.5.') name: Run TFLint working-directory: ${{ github.workspace }} env: @@ -214,6 +215,8 @@ jobs: matrix: module: - modules/runners + - modules/multi-runner + - modules/multi-runner/providers/ec2 defaults: run: working-directory: ${{ matrix.module }} @@ -233,3 +236,9 @@ jobs: run: terraform init -backend=false -input=false - name: terraform test run: terraform test -test-directory=tests + - if: matrix.module == 'modules/multi-runner' + name: terraform init state migration test + run: terraform init -backend=false -input=false -test-directory=tests-upgrade + - if: matrix.module == 'modules/multi-runner' + name: terraform test state migration + run: terraform test -test-directory=tests-upgrade diff --git a/docs/index.md b/docs/index.md index 7a7d0f70c6..641ab7f0fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,7 +101,7 @@ Besides these permissions, the lambdas also need permission to CloudWatch (for l ## Terraform main modules -Currently we support two main modules. The `runners` module is the main module for creating runners. And the 'multi-runner' module is a wrapper around the `runners` module to create multiple runners in one go. The `multi-runner` module is useful for creating runners for multiple repositories or organizations. +Currently we support two main modules. The `runners` module is the main module for creating runners. The `multi-runner` module adds provider-neutral lane normalization, queues, and webhook routing so multiple runners can be created in one deployment. Its internal EC2 provider owns the runner, pool, and EC2-specific IAM implementation. EC2 is the only Terraform-managed provider today; microVM, CodeBuild, and other provider modules are future work. Both modules are built on top of the same base modules. When using the multi-runner module you can deploy different runners with only one deployment. diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 022a4ea762..6ee1994f65 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -6,6 +6,12 @@ This module creates many runners with a single GitHub app. The module utilizes t The module takes a configuration as input containing a matcher for the labels. The [webhook](https://github-aws-runners.github.io/terraform-aws-github-runner/modules/internal/webhook/) lambda is using the configuration to delegate events based on the labels in the workflow job and sent them to a dedicated queue based on the configuration. Events on each queue are processed by a dedicated lambda per configuration to scale runners. +## Provider boundary + +The multi-runner module owns provider-neutral lane normalization, queues, shared runner-binary discovery, and webhook routing. Normalized EC2 lanes are delegated to the internal `providers/ec2` adapter, which owns the EC2 runner, pool, and EC2-specific IAM implementation. + +Both the stable `multi_runner_config` input and experimental `multi_runner_config_v2` input use this same boundary. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. Existing lane keys are retained, and a Terraform `moved` block migrates the previous per-lane runner module addresses into the EC2 provider module without replacing resources. + For each configuration: - When enabled, the [distribution syncer](https://github-aws-runners.github.io/terraform-aws-github-runner/modules/internal/runner-binaries-syncer/) is deployed for each unique combination of OS and architecture. @@ -94,9 +100,9 @@ module "multi-runner" { | Name | Source | Version | |------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | +| [ec2](#module\_ec2) | ./providers/ec2 | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | -| [runners](#module\_runners) | ../runners | n/a | | [ssm](#module\_ssm) | ../ssm | n/a | | [webhook](#module\_webhook) | ../webhook | n/a | diff --git a/modules/multi-runner/multi-runner-config.tf b/modules/multi-runner/multi-runner-config.tf index abd0b7b624..6651b70295 100644 --- a/modules/multi-runner/multi-runner-config.tf +++ b/modules/multi-runner/multi-runner-config.tf @@ -105,13 +105,15 @@ locals { } } - ec2_runner_config = { - for k, v in local.runner_config : k => v - if v.runnerProvider == "ec2" + runner_config_by_provider = { + ec2 = { + for k, v in local.runner_config : k => v + if v.runnerProvider == "ec2" + } } tmp_distinct_list_unique_os_and_arch = distinct([ - for _, config in local.ec2_runner_config : { + for _, config in local.runner_config_by_provider.ec2 : { "os_type" : config.runner.runner_os, "architecture" : config.runner.runner_architecture } diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index 7ce7171faf..367e418a6e 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -1,24 +1,6 @@ output "runners_map" { - value = { for runner_key, runner in module.runners : runner_key => { - launch_template_name = runner.launch_template.name - launch_template_id = runner.launch_template.id - launch_template_version = runner.launch_template.latest_version - launch_template_ami_id = runner.launch_template.image_id - lambda_up = runner.lambda_scale_up - lambda_up_log_group = runner.lambda_scale_up_log_group - lambda_down = runner.lambda_scale_down - lambda_down_log_group = runner.lambda_scale_down_log_group - lambda_pool = runner.lambda_pool - lambda_pool_log_group = runner.lambda_pool_log_group - role_runner = runner.role_runner - role_scale_up = runner.role_scale_up - role_scale_down = runner.role_scale_down - role_pool = runner.role_pool - runners_log_groups = runner.runners_log_groups - logfiles = runner.logfiles - } - } + value = module.ec2.runners_map } output "binaries_syncer_map" { diff --git a/modules/multi-runner/providers/ec2/README.md b/modules/multi-runner/providers/ec2/README.md new file mode 100644 index 0000000000..133a01a4f0 --- /dev/null +++ b/modules/multi-runner/providers/ec2/README.md @@ -0,0 +1,90 @@ +# Multi-runner EC2 provider + +This internal module adapts normalized multi-runner lanes to the existing EC2 runner implementation. It owns the per-lane EC2 runner module calls, including launch templates, scaling Lambdas, pools, and EC2-specific IAM policies. + +Provider-neutral lane normalization, SQS queues, shared runner-binary discovery, and webhook routing remain in the parent `multi-runner` module. Lane map keys pass through unchanged so Terraform `for_each` identities and the public `runners_map` keys remain stable. + +EC2 is currently the only Terraform-managed provider. microVM, CodeBuild, and other providers require separate provider modules and are intentionally not instantiated here. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [runners](#module\_runners) | ../../../runners | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Whether EC2 runners receive a public IPv4 address. | `bool` | `false` | no | +| [aws\_partition](#input\_aws\_partition) | AWS partition used in resource ARNs. | `string` | `"aws"` | no | +| [aws\_region](#input\_aws\_region) | AWS region for EC2 runner resources. | `string` | n/a | yes | +| [cloudwatch\_config](#input\_cloudwatch\_config) | Default CloudWatch agent configuration for EC2 runners. | `string` | `null` | no | +| [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Whether to create the managed security group for EC2 runners. | `bool` | `true` | no | +| [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | Whether EC2 runner control-plane functions verify GHES TLS certificates. | `bool` | `true` | no | +| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL used by EC2 runner control-plane functions. | `string` | `null` | no | +| [github\_app\_parameters](#input\_github\_app\_parameters) | SSM parameter references for the GitHub App credentials. |
object({
key_base64 = map(string)
id = map(string)
})
| n/a | yes | +| [instance\_profile\_path](#input\_instance\_profile\_path) | IAM path for EC2 runner instance profiles. | `string` | `null` | no | +| [key\_name](#input\_key\_name) | EC2 key pair name for runner instances. | `string` | `null` | no | +| [kms\_key\_arn](#input\_kms\_key\_arn) | KMS key ARN used for EC2 lane Parameter Store values. | `string` | `null` | no | +| [lambda\_architecture](#input\_lambda\_architecture) | Architecture used by EC2 control-plane Lambda functions. | `string` | `"arm64"` | no | +| [lambda\_event\_source\_mapping\_batch\_size](#input\_lambda\_event\_source\_mapping\_batch\_size) | Default SQS event-source batch size for EC2 scale-up Lambdas. | `number` | `10` | no | +| [lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds](#input\_lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds) | Default SQS event-source batching window for EC2 scale-up Lambdas. | `number` | `0` | no | +| [lambda\_runtime](#input\_lambda\_runtime) | Runtime used by EC2 control-plane Lambda functions. | `string` | `"nodejs24.x"` | no | +| [lambda\_s3\_bucket](#input\_lambda\_s3\_bucket) | S3 bucket containing the runner Lambda package. | `string` | `null` | no | +| [lambda\_security\_group\_ids](#input\_lambda\_security\_group\_ids) | Security group IDs for EC2 control-plane Lambda functions. | `list(string)` | `[]` | no | +| [lambda\_subnet\_ids](#input\_lambda\_subnet\_ids) | Subnet IDs for EC2 control-plane Lambda functions. | `list(string)` | `[]` | no | +| [lambda\_tags](#input\_lambda\_tags) | Additional tags for EC2 control-plane Lambda functions. | `map(string)` | `{}` | no | +| [lanes](#input\_lanes) | Resolved EC2 runner lanes keyed by the stable multi-runner lane name. |
map(object({
runner = object({
runner_os = string
runner_architecture = string
disable_runner_autoupdate = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_jit_config = optional(bool, null)
enable_organization_runners = optional(bool, false)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_iam_role_managed_policy_arns = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
})

provider = object({
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), 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
}])
cloudwatch_config = optional(string, null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
enable_runner_binaries_syncer = optional(bool, true)
enable_runner_detailed_monitoring = optional(bool, false)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
runner_additional_security_group_ids = optional(list(string), [])
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
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
})), [])
use_dedicated_host = optional(bool, false)
runner_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)
runner_ec2_tags = optional(map(string), {})
runner_hook_job_completed = optional(string, "")
runner_hook_job_started = optional(string, "")
userdata_content = optional(string, null)
userdata_post_install = optional(string, "")
userdata_pre_install = optional(string, "")
userdata_template = optional(string, null)
})

queue = object({
arn = string
url = string
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
})
}))
| n/a | yes | +| [log\_class](#input\_log\_class) | CloudWatch log class for EC2 runner log groups. | `string` | `"STANDARD"` | no | +| [log\_level](#input\_log\_level) | Log level for EC2 runner control-plane Lambda functions. | `string` | `"info"` | no | +| [logging\_kms\_key\_id](#input\_logging\_kms\_key\_id) | KMS key ID used to encrypt EC2 runner CloudWatch log groups. | `string` | `null` | no | +| [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | CloudWatch log retention for EC2 runner control-plane functions. | `number` | `180` | no | +| [metrics](#input\_metrics) | Metrics configuration for EC2 runner control-plane functions. |
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 | +| [parameter\_store\_tags](#input\_parameter\_store\_tags) | Tags for EC2 lane SSM parameters. | `map(string)` | `{}` | no | +| [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Reserved concurrency for EC2 pool Lambda functions. | `number` | `1` | no | +| [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Timeout for EC2 pool Lambda functions. | `number` | `60` | no | +| [prefix](#input\_prefix) | Prefix used for EC2 lane resource names. | `string` | n/a | yes | +| [role\_path](#input\_role\_path) | IAM path for EC2 runner roles. | `string` | `null` | no | +| [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary for EC2 runner IAM roles. | `string` | `null` | no | +| [runner\_additional\_security\_group\_ids](#input\_runner\_additional\_security\_group\_ids) | Default additional security group IDs for EC2 runners. | `list(string)` | `[]` | no | +| [runner\_binaries](#input\_runner\_binaries) | Runner binary artifacts keyed by operating system and architecture. |
map(object({
arn = string
id = string
key = string
}))
| `{}` | no | +| [runner\_egress\_rules](#input\_runner\_egress\_rules) | Egress rules for the managed EC2 runner security group. |
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"
],
"description": null,
"from_port": 0,
"ipv6_cidr_blocks": [
"::/0"
],
"prefix_list_ids": null,
"protocol": "-1",
"security_groups": null,
"self": null,
"to_port": 0
}
]
| no | +| [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for the runner Lambda package. | `string` | `null` | no | +| [runners\_lambda\_s3\_object\_version](#input\_runners\_lambda\_s3\_object\_version) | S3 object version for the runner Lambda package. | `string` | `null` | no | +| [runners\_lambda\_zip](#input\_runners\_lambda\_zip) | Local path to the runner Lambda package. | `string` | `null` | no | +| [runners\_scale\_down\_lambda\_timeout](#input\_runners\_scale\_down\_lambda\_timeout) | Timeout for the EC2 scale-down Lambda function. | `number` | `60` | no | +| [runners\_scale\_up\_lambda\_timeout](#input\_runners\_scale\_up\_lambda\_timeout) | Timeout for the EC2 scale-up Lambda function. | `number` | `30` | no | +| [runners\_ssm\_housekeeper](#input\_runners\_ssm\_housekeeper) | Configuration for the EC2 runner token and JIT-config SSM housekeeper. |
object({
schedule_expression = optional(string, "rate(1 day)")
enabled = optional(bool, true)
lambda_memory_size = optional(number, 512)
lambda_timeout = optional(number, 60)
config = object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
})
})
|
{
"config": {}
}
| no | +| [scale\_down\_lambda\_memory\_size](#input\_scale\_down\_lambda\_memory\_size) | Memory size for the EC2 scale-down Lambda function. | `number` | `512` | no | +| [scale\_up\_lambda\_memory\_size](#input\_scale\_up\_lambda\_memory\_size) | Memory size for the EC2 scale-up Lambda function. | `number` | `512` | no | +| [ssm\_paths](#input\_ssm\_paths) | Shared SSM path names used by EC2 runner lanes. |
object({
runners = string
})
| n/a | yes | +| [ssm\_root\_path](#input\_ssm\_root\_path) | Shared SSM root path beneath which EC2 lane paths are created. | `string` | n/a | yes | +| [subnet\_ids](#input\_subnet\_ids) | Default subnet IDs for EC2 runner resources. | `list(string)` | n/a | yes | +| [tags](#input\_tags) | Tags shared by all EC2 lane resources. | `map(string)` | `{}` | no | +| [tracing\_config](#input\_tracing\_config) | Tracing configuration for EC2 control-plane Lambda functions. |
object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
})
| `{}` | no | +| [user\_agent](#input\_user\_agent) | User agent used by EC2 runner control-plane GitHub API calls. | `string` | `"github-aws-runners"` | no | +| [vpc\_id](#input\_vpc\_id) | Default VPC for EC2 runner resources. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [runners\_map](#output\_runners\_map) | EC2 runner resources keyed by the stable multi-runner lane name. | + diff --git a/modules/multi-runner/providers/ec2/main.tf b/modules/multi-runner/providers/ec2/main.tf new file mode 100644 index 0000000000..3b0601079e --- /dev/null +++ b/modules/multi-runner/providers/ec2/main.tf @@ -0,0 +1,131 @@ +module "runners" { + source = "../../../runners" + for_each = var.lanes + + aws_region = var.aws_region + aws_partition = var.aws_partition + vpc_id = coalesce(each.value.provider.vpc_id, var.vpc_id) + subnet_ids = coalesce(each.value.provider.subnet_ids, var.subnet_ids) + prefix = "${var.prefix}-${each.key}" + tags = merge(var.tags, { + "ghr:environment" = "${var.prefix}-${each.key}" + }) + + s3_runner_binaries = each.value.provider.enable_runner_binaries_syncer ? var.runner_binaries["${each.value.runner.runner_os}_${each.value.runner.runner_architecture}"] : null + + ssm_paths = { + root = "${var.ssm_root_path}/${each.key}" + tokens = "${var.ssm_paths.runners}/tokens" + config = "${var.ssm_paths.runners}/config" + } + + runner_os = each.value.runner.runner_os + instance_types = each.value.provider.instance_types + instance_target_capacity_type = each.value.provider.instance_target_capacity_type + instance_allocation_strategy = each.value.provider.instance_allocation_strategy + instance_type_priorities = each.value.provider.instance_type_priorities + instance_max_spot_price = each.value.provider.instance_max_spot_price + block_device_mappings = each.value.provider.block_device_mappings + + runner_architecture = each.value.runner.runner_architecture + ami = each.value.provider.ami + + sqs_build_queue = { "arn" : each.value.queue.arn, "url" : each.value.queue.url } + github_app_parameters = var.github_app_parameters + ebs_optimized = each.value.provider.ebs_optimized + enable_on_demand_failover_for_errors = each.value.provider.enable_on_demand_failover_for_errors + scale_errors = each.value.provider.scale_errors + enable_organization_runners = each.value.runner.enable_organization_runners + enable_ephemeral_runners = each.value.runner.enable_ephemeral_runners + enable_jit_config = each.value.runner.enable_jit_config + enable_job_queued_check = each.value.runner.enable_job_queued_check + disable_runner_autoupdate = each.value.runner.disable_runner_autoupdate + enable_managed_runner_security_group = var.enable_managed_runner_security_group + enable_runner_detailed_monitoring = each.value.provider.enable_runner_detailed_monitoring + scale_down_schedule_expression = each.value.runner.scale_down_schedule_expression + minimum_running_time_in_minutes = each.value.runner.minimum_running_time_in_minutes + runner_boot_time_in_minutes = each.value.runner.runner_boot_time_in_minutes + runner_disable_default_labels = each.value.runner.runner_disable_default_labels + runner_labels = each.value.runner.runner_disable_default_labels ? sort(distinct(each.value.runner.runner_extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.runner_os, each.value.runner.runner_architecture], each.value.runner.runner_extra_labels))) + runner_as_root = each.value.runner.runner_as_root + runner_run_as = each.value.runner.runner_run_as + runners_maximum_count = each.value.runner.runners_maximum_count + idle_config = each.value.provider.idle_config + enable_ssm_on_runners = each.value.provider.enable_ssm_on_runners + egress_rules = var.runner_egress_rules + runner_additional_security_group_ids = try(coalescelist(each.value.provider.runner_additional_security_group_ids, var.runner_additional_security_group_ids), []) + metadata_options = each.value.provider.runner_metadata_options + credit_specification = each.value.provider.credit_specification + cpu_options = each.value.provider.cpu_options + placement = each.value.provider.placement + license_specifications = each.value.provider.license_specifications + use_dedicated_host = each.value.provider.use_dedicated_host + + enable_runner_binaries_syncer = each.value.provider.enable_runner_binaries_syncer + lambda_s3_bucket = var.lambda_s3_bucket + runners_lambda_s3_key = var.runners_lambda_s3_key + runners_lambda_s3_object_version = var.runners_lambda_s3_object_version + lambda_runtime = var.lambda_runtime + lambda_architecture = var.lambda_architecture + lambda_zip = var.runners_lambda_zip + lambda_scale_up_memory_size = var.scale_up_lambda_memory_size + lambda_event_source_mapping_batch_size = coalesce(each.value.queue.lambda_event_source_mapping_batch_size, var.lambda_event_source_mapping_batch_size) + lambda_event_source_mapping_maximum_batching_window_in_seconds = coalesce(each.value.queue.lambda_event_source_mapping_maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) + lambda_timeout_scale_up = var.runners_scale_up_lambda_timeout + lambda_scale_down_memory_size = var.scale_down_lambda_memory_size + lambda_timeout_scale_down = var.runners_scale_down_lambda_timeout + lambda_subnet_ids = var.lambda_subnet_ids + lambda_security_group_ids = var.lambda_security_group_ids + lambda_tags = var.lambda_tags + tracing_config = var.tracing_config + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + log_class = var.log_class + enable_cloudwatch_agent = each.value.provider.enable_cloudwatch_agent + cloudwatch_config = try(coalesce(each.value.provider.cloudwatch_config, var.cloudwatch_config), null) + runner_log_files = each.value.provider.runner_log_files + runner_group_name = each.value.runner.runner_group_name + runner_name_prefix = each.value.runner.runner_name_prefix + parameter_store_tags = var.parameter_store_tags + + scale_up_reserved_concurrent_executions = each.value.runner.scale_up_reserved_concurrent_executions + + instance_profile_path = var.instance_profile_path + role_path = var.role_path + role_permissions_boundary = var.role_permissions_boundary + + enable_userdata = each.value.provider.enable_userdata + userdata_template = each.value.provider.userdata_template + userdata_content = each.value.provider.userdata_content + userdata_pre_install = each.value.provider.userdata_pre_install + userdata_post_install = each.value.provider.userdata_post_install + runner_hook_job_started = each.value.provider.runner_hook_job_started + runner_hook_job_completed = each.value.provider.runner_hook_job_completed + key_name = var.key_name + runner_ec2_tags = each.value.provider.runner_ec2_tags + + create_service_linked_role_spot = each.value.provider.create_service_linked_role_spot + + runner_iam_role_managed_policy_arns = each.value.runner.runner_iam_role_managed_policy_arns + iam_overrides = each.value.runner.iam_overrides + + ghes_url = var.ghes_url + ghes_ssl_verify = var.ghes_ssl_verify + user_agent = var.user_agent + + kms_key_arn = var.kms_key_arn + + log_level = var.log_level + + pool_config = each.value.runner.pool_config + pool_lambda_timeout = var.pool_lambda_timeout + pool_runner_owner = each.value.runner.pool_runner_owner + pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions + associate_public_ipv4_address = var.associate_public_ipv4_address + + ssm_housekeeper = var.runners_ssm_housekeeper + + job_retry = each.value.runner.job_retry + + metrics = var.metrics +} diff --git a/modules/multi-runner/providers/ec2/outputs.tf b/modules/multi-runner/providers/ec2/outputs.tf new file mode 100644 index 0000000000..40d811b740 --- /dev/null +++ b/modules/multi-runner/providers/ec2/outputs.tf @@ -0,0 +1,22 @@ +output "runners_map" { + description = "EC2 runner resources keyed by the stable multi-runner lane name." + value = { for runner_key, runner in module.runners : runner_key => { + launch_template_name = runner.launch_template.name + launch_template_id = runner.launch_template.id + launch_template_version = runner.launch_template.latest_version + launch_template_ami_id = runner.launch_template.image_id + lambda_up = runner.lambda_scale_up + lambda_up_log_group = runner.lambda_scale_up_log_group + lambda_down = runner.lambda_scale_down + lambda_down_log_group = runner.lambda_scale_down_log_group + lambda_pool = runner.lambda_pool + lambda_pool_log_group = runner.lambda_pool_log_group + role_runner = runner.role_runner + role_scale_up = runner.role_scale_up + role_scale_down = runner.role_scale_down + role_pool = runner.role_pool + runners_log_groups = runner.runners_log_groups + logfiles = runner.logfiles + } + } +} diff --git a/modules/multi-runner/providers/ec2/tests/provider.tftest.hcl b/modules/multi-runner/providers/ec2/tests/provider.tftest.hcl new file mode 100644 index 0000000000..f9a9b8b06f --- /dev/null +++ b/modules/multi-runner/providers/ec2/tests/provider.tftest.hcl @@ -0,0 +1,69 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + } + } +} + +variables { + aws_region = "eu-west-1" + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + prefix = "provider-test" + ssm_root_path = "/github-action-runners/provider-test" + ssm_paths = { + runners = "runners" + } + + github_app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } + } + + lambda_s3_bucket = "lambda-artifacts" + runners_lambda_s3_key = "runners.zip" + + lanes = { + linux = { + runner = { + runner_os = "linux" + runner_architecture = "x64" + runners_maximum_count = 2 + enable_organization_runners = true + pool_config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + provider = { + instance_types = ["m5.large"] + enable_runner_binaries_syncer = false + } + queue = { + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + } + } + } +} + +run "plan_preserves_lane_key_through_ec2_provider" { + command = plan + + assert { + condition = keys(module.runners) == ["linux"] + error_message = "The EC2 provider must preserve the multi-runner lane key." + } + + assert { + condition = keys(output.runners_map) == ["linux"] + error_message = "The EC2 provider output must preserve the public runner map key." + } +} diff --git a/modules/multi-runner/providers/ec2/variables.tf b/modules/multi-runner/providers/ec2/variables.tf new file mode 100644 index 0000000000..176b980a82 --- /dev/null +++ b/modules/multi-runner/providers/ec2/variables.tf @@ -0,0 +1,489 @@ +variable "lanes" { + description = "Resolved EC2 runner lanes keyed by the stable multi-runner lane name." + type = map(object({ + runner = object({ + runner_os = string + runner_architecture = string + disable_runner_autoupdate = optional(bool, false) + enable_ephemeral_runners = optional(bool, false) + enable_job_queued_check = optional(bool, null) + enable_jit_config = optional(bool, null) + enable_organization_runners = optional(bool, false) + minimum_running_time_in_minutes = optional(number, null) + pool_runner_owner = optional(string, null) + runner_as_root = optional(bool, false) + runner_boot_time_in_minutes = optional(number, 5) + runner_disable_default_labels = optional(bool, false) + runner_extra_labels = optional(list(string), []) + runner_group_name = optional(string, "Default") + runner_name_prefix = optional(string, "") + runner_run_as = optional(string, "ec2-user") + runners_maximum_count = number + runner_iam_role_managed_policy_arns = optional(list(string), []) + scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)") + scale_up_reserved_concurrent_executions = optional(number, 1) + pool_config = optional(list(object({ + schedule_expression = string + schedule_expression_timezone = optional(string) + size = number + })), []) + job_retry = optional(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) + }), {}) + iam_overrides = optional(object({ + override_instance_profile = optional(bool, null) + instance_profile_name = optional(string, null) + override_runner_role = optional(bool, null) + runner_role_arn = optional(string, null) + }), { + override_instance_profile = false + instance_profile_name = null + override_runner_role = false + runner_role_arn = null + }) + }) + + provider = object({ + runner_metadata_options = optional(map(any), { + instance_metadata_tags = "enabled" + http_endpoint = "enabled" + http_tokens = "required" + http_put_response_hop_limit = 1 + }) + ami = optional(object({ + filter = optional(map(list(string)), { state = ["available"] }) + owners = optional(list(string), ["amazon"]) + id_ssm_parameter_arn = optional(string, null) + kms_key_arn = optional(string, null) + }), 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 + }]) + cloudwatch_config = optional(string, null) + create_service_linked_role_spot = optional(bool, false) + credit_specification = optional(string, null) + ebs_optimized = optional(bool, false) + enable_cloudwatch_agent = optional(bool, true) + enable_runner_binaries_syncer = optional(bool, true) + enable_runner_detailed_monitoring = optional(bool, false) + enable_ssm_on_runners = optional(bool, false) + enable_userdata = optional(bool, true) + instance_allocation_strategy = optional(string, "lowest-price") + instance_max_spot_price = optional(string, null) + instance_target_capacity_type = optional(string, "spot") + instance_type_priorities = optional(map(number), null) + instance_types = list(string) + runner_additional_security_group_ids = optional(list(string), []) + enable_on_demand_failover_for_errors = optional(list(string), []) + scale_errors = optional(list(string), [ + "UnfulfillableCapacity", + "MaxSpotInstanceCountExceeded", + "TargetCapacityLimitExceededException", + "RequestLimitExceeded", + "ResourceLimitExceeded", + "MaxSpotInstanceCountExceeded", + "MaxSpotFleetRequestCountExceeded", + "InsufficientInstanceCapacity", + "InsufficientCapacityOnHost", + ]) + subnet_ids = optional(list(string), null) + vpc_id = optional(string, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), []) + 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 + })), []) + use_dedicated_host = optional(bool, false) + runner_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) + runner_ec2_tags = optional(map(string), {}) + runner_hook_job_completed = optional(string, "") + runner_hook_job_started = optional(string, "") + userdata_content = optional(string, null) + userdata_post_install = optional(string, "") + userdata_pre_install = optional(string, "") + userdata_template = optional(string, null) + }) + + queue = object({ + arn = string + url = string + lambda_event_source_mapping_batch_size = optional(number, null) + lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null) + }) + })) +} + +variable "aws_region" { + description = "AWS region for EC2 runner resources." + type = string +} + +variable "aws_partition" { + description = "AWS partition used in resource ARNs." + type = string + default = "aws" +} + +variable "vpc_id" { + description = "Default VPC for EC2 runner resources." + type = string +} + +variable "subnet_ids" { + description = "Default subnet IDs for EC2 runner resources." + type = list(string) +} + +variable "prefix" { + description = "Prefix used for EC2 lane resource names." + type = string +} + +variable "tags" { + description = "Tags shared by all EC2 lane resources." + type = map(string) + default = {} +} + +variable "runner_binaries" { + description = "Runner binary artifacts keyed by operating system and architecture." + type = map(object({ + arn = string + id = string + key = string + })) + default = {} +} + +variable "github_app_parameters" { + description = "SSM parameter references for the GitHub App credentials." + type = object({ + key_base64 = map(string) + id = map(string) + }) +} + +variable "ssm_root_path" { + description = "Shared SSM root path beneath which EC2 lane paths are created." + type = string +} + +variable "ssm_paths" { + description = "Shared SSM path names used by EC2 runner lanes." + type = object({ + runners = string + }) +} + +variable "enable_managed_runner_security_group" { + description = "Whether to create the managed security group for EC2 runners." + type = bool + default = true +} + +variable "runner_egress_rules" { + description = "Egress rules for the managed EC2 runner security group." + type = 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 + })) + default = [{ + 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 + }] +} + +variable "runner_additional_security_group_ids" { + description = "Default additional security group IDs for EC2 runners." + type = list(string) + default = [] +} + +variable "associate_public_ipv4_address" { + description = "Whether EC2 runners receive a public IPv4 address." + type = bool + default = false +} + +variable "key_name" { + description = "EC2 key pair name for runner instances." + type = string + default = null +} + +variable "lambda_s3_bucket" { + description = "S3 bucket containing the runner Lambda package." + type = string + default = null +} + +variable "runners_lambda_s3_key" { + description = "S3 key for the runner Lambda package." + type = string + default = null +} + +variable "runners_lambda_s3_object_version" { + description = "S3 object version for the runner Lambda package." + type = string + default = null +} + +variable "runners_lambda_zip" { + description = "Local path to the runner Lambda package." + type = string + default = null +} + +variable "lambda_runtime" { + description = "Runtime used by EC2 control-plane Lambda functions." + type = string + default = "nodejs24.x" +} + +variable "lambda_architecture" { + description = "Architecture used by EC2 control-plane Lambda functions." + type = string + default = "arm64" +} + +variable "scale_up_lambda_memory_size" { + description = "Memory size for the EC2 scale-up Lambda function." + type = number + default = 512 +} + +variable "runners_scale_up_lambda_timeout" { + description = "Timeout for the EC2 scale-up Lambda function." + type = number + default = 30 +} + +variable "scale_down_lambda_memory_size" { + description = "Memory size for the EC2 scale-down Lambda function." + type = number + default = 512 +} + +variable "runners_scale_down_lambda_timeout" { + description = "Timeout for the EC2 scale-down Lambda function." + type = number + default = 60 +} + +variable "lambda_event_source_mapping_batch_size" { + description = "Default SQS event-source batch size for EC2 scale-up Lambdas." + type = number + default = 10 +} + +variable "lambda_event_source_mapping_maximum_batching_window_in_seconds" { + description = "Default SQS event-source batching window for EC2 scale-up Lambdas." + type = number + default = 0 +} + +variable "lambda_subnet_ids" { + description = "Subnet IDs for EC2 control-plane Lambda functions." + type = list(string) + default = [] +} + +variable "lambda_security_group_ids" { + description = "Security group IDs for EC2 control-plane Lambda functions." + type = list(string) + default = [] +} + +variable "lambda_tags" { + description = "Additional tags for EC2 control-plane Lambda functions." + type = map(string) + default = {} +} + +variable "tracing_config" { + description = "Tracing configuration for EC2 control-plane Lambda functions." + type = object({ + mode = optional(string, null) + capture_http_requests = optional(bool, false) + capture_error = optional(bool, false) + }) + default = {} +} + +variable "logging_retention_in_days" { + description = "CloudWatch log retention for EC2 runner control-plane functions." + type = number + default = 180 +} + +variable "logging_kms_key_id" { + description = "KMS key ID used to encrypt EC2 runner CloudWatch log groups." + type = string + default = null +} + +variable "log_class" { + description = "CloudWatch log class for EC2 runner log groups." + type = string + default = "STANDARD" +} + +variable "log_level" { + description = "Log level for EC2 runner control-plane Lambda functions." + type = string + default = "info" +} + +variable "cloudwatch_config" { + description = "Default CloudWatch agent configuration for EC2 runners." + type = string + default = null +} + +variable "parameter_store_tags" { + description = "Tags for EC2 lane SSM parameters." + type = map(string) + default = {} +} + +variable "instance_profile_path" { + description = "IAM path for EC2 runner instance profiles." + type = string + default = null +} + +variable "role_path" { + description = "IAM path for EC2 runner roles." + type = string + default = null +} + +variable "role_permissions_boundary" { + description = "Permissions boundary for EC2 runner IAM roles." + type = string + default = null +} + +variable "kms_key_arn" { + description = "KMS key ARN used for EC2 lane Parameter Store values." + type = string + default = null +} + +variable "ghes_url" { + description = "GitHub Enterprise Server URL used by EC2 runner control-plane functions." + type = string + default = null +} + +variable "ghes_ssl_verify" { + description = "Whether EC2 runner control-plane functions verify GHES TLS certificates." + type = bool + default = true +} + +variable "user_agent" { + description = "User agent used by EC2 runner control-plane GitHub API calls." + type = string + default = "github-aws-runners" +} + +variable "pool_lambda_timeout" { + description = "Timeout for EC2 pool Lambda functions." + type = number + default = 60 +} + +variable "pool_lambda_reserved_concurrent_executions" { + description = "Reserved concurrency for EC2 pool Lambda functions." + type = number + default = 1 +} + +variable "runners_ssm_housekeeper" { + description = "Configuration for the EC2 runner token and JIT-config SSM housekeeper." + type = object({ + schedule_expression = optional(string, "rate(1 day)") + enabled = optional(bool, true) + lambda_memory_size = optional(number, 512) + lambda_timeout = optional(number, 60) + config = object({ + tokenPath = optional(string) + minimumDaysOld = optional(number, 1) + dryRun = optional(bool, false) + }) + }) + default = { config = {} } +} + +variable "metrics" { + description = "Metrics configuration for EC2 runner control-plane functions." + type = 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) + }), {}) + }) + default = {} +} diff --git a/modules/multi-runner/providers/ec2/versions.tf b/modules/multi-runner/providers/ec2/versions.tf new file mode 100644 index 0000000000..da9769f550 --- /dev/null +++ b/modules/multi-runner/providers/ec2/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 35a714dd80..ecd3f53ff1 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -1,130 +1,78 @@ -module "runners" { - source = "../runners" - for_each = local.ec2_runner_config - aws_region = var.aws_region - aws_partition = var.aws_partition - vpc_id = coalesce(each.value.provider.ec2.vpc_id, var.vpc_id) - subnet_ids = coalesce(each.value.provider.ec2.subnet_ids, var.subnet_ids) - prefix = "${var.prefix}-${each.key}" - tags = merge(local.tags, { - "ghr:environment" = "${var.prefix}-${each.key}" - }) - - s3_runner_binaries = each.value.provider.ec2.enable_runner_binaries_syncer ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.runner_os}_${each.value.runner.runner_architecture}"] : null - - ssm_paths = { - root = "${local.ssm_root_path}/${each.key}" - tokens = "${var.ssm_paths.runners}/tokens" - config = "${var.ssm_paths.runners}/config" +module "ec2" { + source = "./providers/ec2" + + lanes = { + for lane_key, lane in local.runner_config_by_provider.ec2 : lane_key => { + runner = lane.runner + provider = lane.provider.ec2 + queue = { + arn = lane.arn + url = lane.url + lambda_event_source_mapping_batch_size = lane.queue.lambda_event_source_mapping_batch_size + lambda_event_source_mapping_maximum_batching_window_in_seconds = lane.queue.lambda_event_source_mapping_maximum_batching_window_in_seconds + } + } } - runner_os = each.value.runner.runner_os - instance_types = each.value.provider.ec2.instance_types - instance_target_capacity_type = each.value.provider.ec2.instance_target_capacity_type - instance_allocation_strategy = each.value.provider.ec2.instance_allocation_strategy - instance_type_priorities = each.value.provider.ec2.instance_type_priorities - instance_max_spot_price = each.value.provider.ec2.instance_max_spot_price - block_device_mappings = each.value.provider.ec2.block_device_mappings + aws_region = var.aws_region + aws_partition = var.aws_partition + vpc_id = var.vpc_id + subnet_ids = var.subnet_ids + prefix = var.prefix + tags = local.tags - runner_architecture = each.value.runner.runner_architecture - ami = each.value.provider.ec2.ami + runner_binaries = local.runner_binaries_by_os_and_arch_map + github_app_parameters = local.github_app_parameters + ssm_root_path = local.ssm_root_path + ssm_paths = var.ssm_paths - sqs_build_queue = { "arn" : each.value.arn, "url" : each.value.url } - github_app_parameters = local.github_app_parameters - ebs_optimized = each.value.provider.ec2.ebs_optimized - enable_on_demand_failover_for_errors = each.value.provider.ec2.enable_on_demand_failover_for_errors - scale_errors = each.value.provider.ec2.scale_errors - enable_organization_runners = each.value.runner.enable_organization_runners - enable_ephemeral_runners = each.value.runner.enable_ephemeral_runners - enable_jit_config = each.value.runner.enable_jit_config - enable_job_queued_check = each.value.runner.enable_job_queued_check - disable_runner_autoupdate = each.value.runner.disable_runner_autoupdate enable_managed_runner_security_group = var.enable_managed_runner_security_group - enable_runner_detailed_monitoring = each.value.provider.ec2.enable_runner_detailed_monitoring - scale_down_schedule_expression = each.value.runner.scale_down_schedule_expression - minimum_running_time_in_minutes = each.value.runner.minimum_running_time_in_minutes - runner_boot_time_in_minutes = each.value.runner.runner_boot_time_in_minutes - runner_disable_default_labels = each.value.runner.runner_disable_default_labels - runner_labels = each.value.runner.runner_disable_default_labels ? sort(distinct(each.value.runner.runner_extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.runner_os, each.value.runner.runner_architecture], each.value.runner.runner_extra_labels))) - runner_as_root = each.value.runner.runner_as_root - runner_run_as = each.value.runner.runner_run_as - runners_maximum_count = each.value.runner.runners_maximum_count - idle_config = each.value.provider.ec2.idle_config - enable_ssm_on_runners = each.value.provider.ec2.enable_ssm_on_runners - egress_rules = var.runner_egress_rules - runner_additional_security_group_ids = try(coalescelist(each.value.provider.ec2.runner_additional_security_group_ids, var.runner_additional_security_group_ids), []) - metadata_options = each.value.provider.ec2.runner_metadata_options - credit_specification = each.value.provider.ec2.credit_specification - cpu_options = each.value.provider.ec2.cpu_options - placement = each.value.provider.ec2.placement - license_specifications = each.value.provider.ec2.license_specifications - use_dedicated_host = each.value.provider.ec2.use_dedicated_host + runner_egress_rules = var.runner_egress_rules + runner_additional_security_group_ids = var.runner_additional_security_group_ids + associate_public_ipv4_address = var.associate_public_ipv4_address + key_name = var.key_name - enable_runner_binaries_syncer = each.value.provider.ec2.enable_runner_binaries_syncer lambda_s3_bucket = var.lambda_s3_bucket runners_lambda_s3_key = var.runners_lambda_s3_key runners_lambda_s3_object_version = var.runners_lambda_s3_object_version + runners_lambda_zip = var.runners_lambda_zip lambda_runtime = var.lambda_runtime lambda_architecture = var.lambda_architecture - lambda_zip = var.runners_lambda_zip - lambda_scale_up_memory_size = var.scale_up_lambda_memory_size - lambda_event_source_mapping_batch_size = coalesce(each.value.queue.lambda_event_source_mapping_batch_size, var.lambda_event_source_mapping_batch_size) - lambda_event_source_mapping_maximum_batching_window_in_seconds = coalesce(each.value.queue.lambda_event_source_mapping_maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) - lambda_timeout_scale_up = var.runners_scale_up_lambda_timeout - lambda_scale_down_memory_size = var.scale_down_lambda_memory_size - lambda_timeout_scale_down = var.runners_scale_down_lambda_timeout + scale_up_lambda_memory_size = var.scale_up_lambda_memory_size + runners_scale_up_lambda_timeout = var.runners_scale_up_lambda_timeout + scale_down_lambda_memory_size = var.scale_down_lambda_memory_size + runners_scale_down_lambda_timeout = var.runners_scale_down_lambda_timeout + lambda_event_source_mapping_batch_size = var.lambda_event_source_mapping_batch_size + lambda_event_source_mapping_maximum_batching_window_in_seconds = var.lambda_event_source_mapping_maximum_batching_window_in_seconds lambda_subnet_ids = var.lambda_subnet_ids lambda_security_group_ids = var.lambda_security_group_ids lambda_tags = var.lambda_tags tracing_config = var.tracing_config - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id - log_class = var.log_class - enable_cloudwatch_agent = each.value.provider.ec2.enable_cloudwatch_agent - cloudwatch_config = try(coalesce(each.value.provider.ec2.cloudwatch_config, var.cloudwatch_config), null) - runner_log_files = each.value.provider.ec2.runner_log_files - runner_group_name = each.value.runner.runner_group_name - runner_name_prefix = each.value.runner.runner_name_prefix - parameter_store_tags = var.parameter_store_tags - scale_up_reserved_concurrent_executions = each.value.runner.scale_up_reserved_concurrent_executions + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + log_class = var.log_class + log_level = var.log_level + cloudwatch_config = var.cloudwatch_config + parameter_store_tags = var.parameter_store_tags instance_profile_path = var.instance_profile_path role_path = var.role_path role_permissions_boundary = var.role_permissions_boundary - - enable_userdata = each.value.provider.ec2.enable_userdata - userdata_template = each.value.provider.ec2.userdata_template - userdata_content = each.value.provider.ec2.userdata_content - userdata_pre_install = each.value.provider.ec2.userdata_pre_install - userdata_post_install = each.value.provider.ec2.userdata_post_install - runner_hook_job_started = each.value.provider.ec2.runner_hook_job_started - runner_hook_job_completed = each.value.provider.ec2.runner_hook_job_completed - key_name = var.key_name - runner_ec2_tags = each.value.provider.ec2.runner_ec2_tags - - create_service_linked_role_spot = each.value.provider.ec2.create_service_linked_role_spot - - runner_iam_role_managed_policy_arns = each.value.runner.runner_iam_role_managed_policy_arns - iam_overrides = each.value.runner.iam_overrides + kms_key_arn = var.kms_key_arn ghes_url = var.ghes_url ghes_ssl_verify = var.ghes_ssl_verify user_agent = var.user_agent - kms_key_arn = var.kms_key_arn - - log_level = var.log_level - - pool_config = each.value.runner.pool_config pool_lambda_timeout = var.pool_lambda_timeout - pool_runner_owner = each.value.runner.pool_runner_owner pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions - associate_public_ipv4_address = var.associate_public_ipv4_address - - ssm_housekeeper = var.runners_ssm_housekeeper - - job_retry = each.value.runner.job_retry + runners_ssm_housekeeper = var.runners_ssm_housekeeper + metrics = var.metrics +} - metrics = var.metrics +# Keep every existing for_each lane in state while introducing the provider layer. +moved { + from = module.runners + to = module.ec2.module.runners } diff --git a/modules/multi-runner/tests-upgrade/README.md b/modules/multi-runner/tests-upgrade/README.md new file mode 100644 index 0000000000..31c4f2ea86 --- /dev/null +++ b/modules/multi-runner/tests-upgrade/README.md @@ -0,0 +1,10 @@ +# Multi-runner upgrade tests + +These tests use Terraform 1.14 state sharing to apply a pre-provider-boundary fixture, plan the current module against the same in-memory state, and verify that EC2 runner, pool, and IAM resource identities survive the module move. + +Run them separately from the minimum-version module checks: + +```shell +terraform init -backend=false -input=false -test-directory=tests-upgrade +terraform test -test-directory=tests-upgrade +``` diff --git a/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/README.md b/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/README.md new file mode 100644 index 0000000000..07b21386d9 --- /dev/null +++ b/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/README.md @@ -0,0 +1,36 @@ +# Pre-provider-boundary fixture + +This internal test fixture recreates the former `module.runners[]` address so the multi-runner upgrade test can verify that the EC2 provider `moved` block retains existing resource identities. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.14.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [runners](#module\_runners) | ../../../../runners | n/a | + +## Resources + +No resources. + +## Inputs + +No inputs. + +## Outputs + +| Name | Description | +|------|-------------| +| [runner\_state](#output\_runner\_state) | Stable resource identities captured before the EC2 provider boundary. | + diff --git a/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/main.tf b/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/main.tf new file mode 100644 index 0000000000..3682a15d5d --- /dev/null +++ b/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/main.tf @@ -0,0 +1,47 @@ +module "runners" { + source = "../../../../runners" + for_each = { + linux = true + } + + aws_region = "eu-west-1" + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + prefix = "upgrade-test-linux" + + instance_types = ["m5.large"] + enable_runner_binaries_syncer = false + s3_runner_binaries = null + sqs_build_queue = { + arn = "arn:aws:sqs:eu-west-1:123456789012:upgrade-test-linux" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/upgrade-test-linux" + } + + github_app_parameters = { + key_base64 = { + name = "/github-action-runners/upgrade-test/app/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/upgrade-test/app/key-base64" + } + id = { + name = "/github-action-runners/upgrade-test/app/id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/upgrade-test/app/id" + } + } + + enable_organization_runners = true + enable_ssm_on_runners = false + runner_labels = ["self-hosted", "linux", "x64"] + ssm_paths = { + root = "/github-action-runners/upgrade-test/linux" + tokens = "runners/tokens" + config = "runners/config" + } + + lambda_s3_bucket = "lambda-artifacts" + runners_lambda_s3_key = "runners.zip" + + pool_config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] +} diff --git a/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/outputs.tf b/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/outputs.tf new file mode 100644 index 0000000000..5c30d244c6 --- /dev/null +++ b/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/outputs.tf @@ -0,0 +1,9 @@ +output "runner_state" { + description = "Stable resource identities captured before the EC2 provider boundary." + value = { + launch_template_id = module.runners["linux"].launch_template.id + lambda_up_id = module.runners["linux"].lambda_scale_up.id + lambda_pool_id = module.runners["linux"].lambda_pool.id + role_runner_id = module.runners["linux"].role_runner[0].id + } +} diff --git a/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/versions.tf b/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/versions.tf new file mode 100644 index 0000000000..0494d14462 --- /dev/null +++ b/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/multi-runner/tests-upgrade/state-migration.tftest.hcl b/modules/multi-runner/tests-upgrade/state-migration.tftest.hcl new file mode 100644 index 0000000000..aabd1fb621 --- /dev/null +++ b/modules/multi-runner/tests-upgrade/state-migration.tftest.hcl @@ -0,0 +1,99 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/upgrade-test" + } + } + + mock_resource "aws_lambda_function" { + defaults = { + arn = "arn:aws:lambda:eu-west-1:123456789012:function:upgrade-test" + } + } + + mock_resource "aws_cloudwatch_event_rule" { + defaults = { + arn = "arn:aws:events:eu-west-1:123456789012:rule/upgrade-test" + } + } +} + +mock_provider "random" {} +mock_provider "null" {} + +run "apply_pre_provider_boundary" { + command = apply + state_key = "provider-boundary-upgrade" + + module { + source = "./tests-upgrade/fixtures/pre-provider-boundary" + } +} + +run "plan_provider_boundary_upgrade" { + command = plan + state_key = "provider-boundary-upgrade" + + variables { + aws_region = "eu-west-1" + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + prefix = "upgrade-test" + + github_app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + + lambda_s3_bucket = "lambda-artifacts" + webhook_lambda_s3_key = "webhook.zip" + runners_lambda_s3_key = "runners.zip" + + multi_runner_config = { + linux = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 2 + enable_runner_binaries_syncer = false + enable_organization_runners = true + pool_config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + + assert { + condition = output.runners_map["linux"].launch_template_id == run.apply_pre_provider_boundary.runner_state.launch_template_id + error_message = "The EC2 provider boundary must retain the existing launch template state." + } + + assert { + condition = output.runners_map["linux"].lambda_up.id == run.apply_pre_provider_boundary.runner_state.lambda_up_id + error_message = "The EC2 provider boundary must retain the existing scale-up Lambda state." + } + + assert { + condition = output.runners_map["linux"].lambda_pool.id == run.apply_pre_provider_boundary.runner_state.lambda_pool_id + error_message = "The EC2 provider boundary must retain the existing pool Lambda state." + } + + assert { + condition = output.runners_map["linux"].role_runner[0].id == run.apply_pre_provider_boundary.runner_state.role_runner_id + error_message = "The EC2 provider boundary must retain the existing runner IAM role state." + } +} diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl new file mode 100644 index 0000000000..8d80201a5a --- /dev/null +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -0,0 +1,133 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + } + } +} + +mock_provider "random" {} +mock_provider "null" {} + +variables { + aws_region = "eu-west-1" + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + + github_app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + + lambda_s3_bucket = "lambda-artifacts" + webhook_lambda_s3_key = "webhook.zip" + runners_lambda_s3_key = "runners.zip" +} + +run "stable_v1_routes_through_ec2_provider" { + command = plan + + variables { + multi_runner_config = { + linux = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 2 + enable_runner_binaries_syncer = false + enable_organization_runners = true + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + + assert { + condition = keys(local.runner_config_by_provider.ec2) == ["linux"] + error_message = "Stable multi_runner_config lanes must route to the EC2 provider." + } + + assert { + condition = keys(aws_sqs_queue.queued_builds) == ["linux"] + error_message = "Common queue ownership must preserve the stable lane key." + } + + assert { + condition = keys(output.runners_map) == ["linux"] + error_message = "Stable multi_runner_config must preserve the public runner map key." + } +} + +run "experimental_v2_routes_through_ec2_provider" { + command = plan + + variables { + multi_runner_config_v2 = { + linux = { + runner = { + runner_os = "linux" + runner_architecture = "x64" + runners_maximum_count = 2 + enable_organization_runners = true + pool_config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + provider = { + type = "ec2" + ec2 = { + instance_types = ["m5.large"] + enable_runner_binaries_syncer = false + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + + assert { + condition = keys(local.runner_config_by_provider.ec2) == ["linux"] + error_message = "Experimental multi_runner_config_v2 lanes must route to the EC2 provider." + } + + assert { + condition = keys(aws_sqs_queue.queued_builds) == ["linux"] + error_message = "Common queue ownership must preserve the experimental lane key." + } + + assert { + condition = keys(output.runners_map) == ["linux"] + error_message = "Experimental multi_runner_config_v2 must preserve the public runner map key." + } +} + +run "experimental_v2_rejects_future_providers" { + command = plan + + variables { + multi_runner_config_v2 = { + microvm = { + runner = { + runner_os = "linux" + runner_architecture = "x64" + runners_maximum_count = 2 + } + provider = { + type = "microvm" + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + + expect_failures = [var.multi_runner_config_v2] +} From 70be8791fafd711ed698c93984f4c654c71c8f42 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 6 Aug 2026 23:54:37 +0200 Subject: [PATCH 02/27] refactor(runners): colocate EC2 provider adapter --- .github/workflows/terraform.yml | 14 +++++++------- docs/index.md | 2 +- modules/multi-runner/README.md | 4 ++-- modules/multi-runner/runners.tf | 2 +- .../providers/ec2/README.md | 6 +++--- .../providers/ec2/main.tf | 2 +- .../providers/ec2/outputs.tf | 0 .../providers/ec2/tests/provider.tftest.hcl | 0 .../providers/ec2/variables.tf | 0 .../providers/ec2/versions.tf | 0 10 files changed, 15 insertions(+), 15 deletions(-) rename modules/{multi-runner => runners}/providers/ec2/README.md (98%) rename modules/{multi-runner => runners}/providers/ec2/main.tf (99%) rename modules/{multi-runner => runners}/providers/ec2/outputs.tf (100%) rename modules/{multi-runner => runners}/providers/ec2/tests/provider.tftest.hcl (100%) rename modules/{multi-runner => runners}/providers/ec2/variables.tf (100%) rename modules/{multi-runner => runners}/providers/ec2/versions.tf (100%) diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 39190401d2..5d7d9607b9 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -85,7 +85,7 @@ jobs: "download-lambda", "lambda", "multi-runner", - "multi-runner/providers/ec2", + "runners/providers/ec2", "runner-binaries-syncer", "runners", "setup-iam-permissions", @@ -110,7 +110,7 @@ jobs: persist-credentials: false - name: terraform init run: terraform init -get -backend=false -input=false - - if: contains(matrix.terraform, '1.5.') + - if: contains(matrix.terraform, '1.3.') name: check terraform formatting run: terraform fmt -recursive -check=true -write=false - if: contains(matrix.terraform, 'latest') # check formatting for the latest release but avoid failing the build @@ -119,22 +119,22 @@ jobs: continue-on-error: true - name: validate terraform run: terraform validate - - if: contains(matrix.terraform, '1.5.') + - if: contains(matrix.terraform, '1.3.') name: Fix for actions/cache on alpine run: apk add --no-cache tar continue-on-error: true - - if: contains(matrix.terraform, '1.5.') + - if: contains(matrix.terraform, '1.3.') uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 name: Cache TFLint plugin dir with: path: ~/.tflint.d/plugins key: tflint-${{ hashFiles('.tflint.hcl') }} - - if: contains(matrix.terraform, '1.5.') + - if: contains(matrix.terraform, '1.3.') name: Setup TFLint uses: terraform-linters/setup-tflint@b480b8fcdaa6f2c577f8e4fa799e89e756bb7c93 # v6.2.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} - - if: contains(matrix.terraform, '1.5.') + - if: contains(matrix.terraform, '1.3.') name: Run TFLint working-directory: ${{ github.workspace }} env: @@ -216,7 +216,7 @@ jobs: module: - modules/runners - modules/multi-runner - - modules/multi-runner/providers/ec2 + - modules/runners/providers/ec2 defaults: run: working-directory: ${{ matrix.module }} diff --git a/docs/index.md b/docs/index.md index 641ab7f0fa..982ed1395b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,7 +101,7 @@ Besides these permissions, the lambdas also need permission to CloudWatch (for l ## Terraform main modules -Currently we support two main modules. The `runners` module is the main module for creating runners. The `multi-runner` module adds provider-neutral lane normalization, queues, and webhook routing so multiple runners can be created in one deployment. Its internal EC2 provider owns the runner, pool, and EC2-specific IAM implementation. EC2 is the only Terraform-managed provider today; microVM, CodeBuild, and other provider modules are future work. +Currently we support two main modules. The `runners` module is the main module for creating runners and contains the current EC2 implementation. The `multi-runner` module adds provider-neutral lane normalization, queues, and webhook routing so multiple runners can be created in one deployment, delegating normalized EC2 lanes through the provider adapter under `modules/runners/providers/ec2`. EC2 is the only Terraform-managed provider today; microVM, CodeBuild, and other provider modules are future work. Both modules are built on top of the same base modules. When using the multi-runner module you can deploy different runners with only one deployment. diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 6ee1994f65..e90e26fbea 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -8,7 +8,7 @@ The module takes a configuration as input containing a matcher for the labels. T ## Provider boundary -The multi-runner module owns provider-neutral lane normalization, queues, shared runner-binary discovery, and webhook routing. Normalized EC2 lanes are delegated to the internal `providers/ec2` adapter, which owns the EC2 runner, pool, and EC2-specific IAM implementation. +The multi-runner module owns provider-neutral lane normalization, queues, shared runner-binary discovery, and webhook routing. Normalized EC2 lanes are delegated to the `modules/runners/providers/ec2` adapter, which composes the existing EC2 runner, pool, and EC2-specific IAM implementation. Both the stable `multi_runner_config` input and experimental `multi_runner_config_v2` input use this same boundary. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. Existing lane keys are retained, and a Terraform `moved` block migrates the previous per-lane runner module addresses into the EC2 provider module without replacing resources. @@ -100,7 +100,7 @@ module "multi-runner" { | Name | Source | Version | |------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | -| [ec2](#module\_ec2) | ./providers/ec2 | n/a | +| [ec2](#module\_ec2) | ../runners/providers/ec2 | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | | [ssm](#module\_ssm) | ../ssm | n/a | diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index ecd3f53ff1..1328ed9359 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -1,5 +1,5 @@ module "ec2" { - source = "./providers/ec2" + source = "../runners/providers/ec2" lanes = { for lane_key, lane in local.runner_config_by_provider.ec2 : lane_key => { diff --git a/modules/multi-runner/providers/ec2/README.md b/modules/runners/providers/ec2/README.md similarity index 98% rename from modules/multi-runner/providers/ec2/README.md rename to modules/runners/providers/ec2/README.md index 133a01a4f0..12790173cc 100644 --- a/modules/multi-runner/providers/ec2/README.md +++ b/modules/runners/providers/ec2/README.md @@ -1,6 +1,6 @@ -# Multi-runner EC2 provider +# EC2 multi-runner provider adapter -This internal module adapts normalized multi-runner lanes to the existing EC2 runner implementation. It owns the per-lane EC2 runner module calls, including launch templates, scaling Lambdas, pools, and EC2-specific IAM policies. +This internal provider module lives with the existing EC2 runner implementation and adapts normalized multi-runner lanes into per-lane runner module calls, including launch templates, scaling Lambdas, pools, and EC2-specific IAM policies. Provider-neutral lane normalization, SQS queues, shared runner-binary discovery, and webhook routing remain in the parent `multi-runner` module. Lane map keys pass through unchanged so Terraform `for_each` identities and the public `runners_map` keys remain stable. @@ -22,7 +22,7 @@ No providers. | Name | Source | Version | |------|--------|---------| -| [runners](#module\_runners) | ../../../runners | n/a | +| [runners](#module\_runners) | ../.. | n/a | ## Resources diff --git a/modules/multi-runner/providers/ec2/main.tf b/modules/runners/providers/ec2/main.tf similarity index 99% rename from modules/multi-runner/providers/ec2/main.tf rename to modules/runners/providers/ec2/main.tf index 3b0601079e..a5fbae0b41 100644 --- a/modules/multi-runner/providers/ec2/main.tf +++ b/modules/runners/providers/ec2/main.tf @@ -1,5 +1,5 @@ module "runners" { - source = "../../../runners" + source = "../.." for_each = var.lanes aws_region = var.aws_region diff --git a/modules/multi-runner/providers/ec2/outputs.tf b/modules/runners/providers/ec2/outputs.tf similarity index 100% rename from modules/multi-runner/providers/ec2/outputs.tf rename to modules/runners/providers/ec2/outputs.tf diff --git a/modules/multi-runner/providers/ec2/tests/provider.tftest.hcl b/modules/runners/providers/ec2/tests/provider.tftest.hcl similarity index 100% rename from modules/multi-runner/providers/ec2/tests/provider.tftest.hcl rename to modules/runners/providers/ec2/tests/provider.tftest.hcl diff --git a/modules/multi-runner/providers/ec2/variables.tf b/modules/runners/providers/ec2/variables.tf similarity index 100% rename from modules/multi-runner/providers/ec2/variables.tf rename to modules/runners/providers/ec2/variables.tf diff --git a/modules/multi-runner/providers/ec2/versions.tf b/modules/runners/providers/ec2/versions.tf similarity index 100% rename from modules/multi-runner/providers/ec2/versions.tf rename to modules/runners/providers/ec2/versions.tf From 6b6944334b42fdb48e557bca85f66fc144ff71c8 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 01:18:52 +0200 Subject: [PATCH 03/27] refactor(runners): split common control plane from EC2 --- .github/workflows/terraform.yml | 16 +- docs/index.md | 2 +- modules/multi-runner/README.md | 24 +- modules/multi-runner/multi-runner-config.tf | 6 +- modules/multi-runner/outputs.tf | 20 +- modules/multi-runner/runners.tf | 150 ++-- modules/multi-runner/tests-upgrade/README.md | 10 - .../fixtures/pre-provider-boundary/README.md | 36 - .../fixtures/pre-provider-boundary/main.tf | 47 -- .../fixtures/pre-provider-boundary/outputs.tf | 9 - .../tests-upgrade/state-migration.tftest.hcl | 99 --- .../tests/provider-routing.tftest.hcl | 30 +- .../multi-runner/variables.experimental.tf | 50 +- modules/runners/README.md | 42 +- modules/runners/job-retry.tf | 1 + modules/runners/job-retry/README.md | 14 +- modules/runners/job-retry/main.tf | 1 + modules/runners/job-retry/variables.tf | 2 + modules/runners/main.tf | 399 ++-------- modules/runners/moved.tf | 94 +++ modules/runners/outputs.tf | 10 +- modules/runners/policies-lambda-common.tf | 24 - .../runners/policies/lambda-scale-down.json | 62 +- modules/runners/policies/lambda-scale-up.json | 146 +--- modules/runners/pool.tf | 55 +- modules/runners/pool/README.md | 20 +- modules/runners/pool/main.tf | 105 ++- .../runners/pool/policies/lambda-pool.json | 54 +- .../runners/pool/tests/provider.tftest.hcl | 123 +++ modules/runners/pool/variables.tf | 84 +- modules/runners/pool/versions.tf | 2 +- modules/runners/providers/ec2/README.md | 159 ++-- modules/runners/providers/ec2/compute.tf | 337 ++++++++ modules/runners/providers/ec2/contracts.tf | 70 ++ .../runners/{ => providers/ec2}/logging.tf | 1 + modules/runners/providers/ec2/main.tf | 131 ---- modules/runners/providers/ec2/outputs.tf | 43 +- .../{ => providers/ec2}/policies-runner.tf | 1 + .../policies/instance-cloudwatch-policy.json | 0 .../policies/instance-create-tags-policy.json | 0 .../instance-describe-tags-policy.json | 0 .../ec2}/policies/instance-ec2.json | 0 .../policies/instance-role-trust-policy.json | 0 .../ec2}/policies/instance-s3-policy.json | 0 .../instance-ssm-parameters-policy.json | 0 .../ec2}/policies/instance-ssm-policy.json | 0 .../providers/ec2/policies/lambda-pool.json | 43 + .../ec2/policies/lambda-scale-down.json | 30 + .../ec2/policies/lambda-scale-up.json | 64 ++ .../service-linked-role-create-policy.json | 0 .../runners/providers/ec2/runner-config.tf | 13 + .../ec2}/templates/cloudwatch_config.json | 0 .../ec2}/templates/install-runner-osx.sh | 0 .../ec2}/templates/install-runner.ps1 | 0 .../ec2}/templates/install-runner.sh | 0 .../ec2}/templates/start-runner-osx.sh | 0 .../ec2}/templates/start-runner.ps1 | 0 .../ec2}/templates/start-runner.sh | 0 .../ec2}/templates/user-data-osx.sh | 0 .../ec2}/templates/user-data.ps1 | 0 .../ec2}/templates/user-data.sh | 0 .../providers/ec2/tests/provider.tftest.hcl | 114 +-- modules/runners/providers/ec2/variables.tf | 734 +++++++++--------- modules/runners/runner-config.tf | 14 - modules/runners/scale-down.tf | 29 +- modules/runners/scale-up.tf | 54 +- modules/runners/tests-upgrade/README.md | 13 + .../fixtures/pre-provider-split/README.md | 9 + .../fixtures/pre-provider-split/main.tf | 287 +++++++ .../fixtures/pre-provider-split/outputs.tf | 23 + .../fixtures/pre-provider-split}/versions.tf | 0 .../tests-upgrade/state-migration.tftest.hcl | 249 ++++++ modules/runners/tests-upgrade/verify-moves.jq | 61 ++ modules/runners/tests/pool.tftest.hcl | 73 +- modules/runners/variables.tf | 11 + 75 files changed, 2585 insertions(+), 1715 deletions(-) delete mode 100644 modules/multi-runner/tests-upgrade/README.md delete mode 100644 modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/README.md delete mode 100644 modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/main.tf delete mode 100644 modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/outputs.tf delete mode 100644 modules/multi-runner/tests-upgrade/state-migration.tftest.hcl create mode 100644 modules/runners/moved.tf create mode 100644 modules/runners/pool/tests/provider.tftest.hcl create mode 100644 modules/runners/providers/ec2/compute.tf create mode 100644 modules/runners/providers/ec2/contracts.tf rename modules/runners/{ => providers/ec2}/logging.tf (98%) delete mode 100644 modules/runners/providers/ec2/main.tf rename modules/runners/{ => providers/ec2}/policies-runner.tf (99%) rename modules/runners/{ => providers/ec2}/policies/instance-cloudwatch-policy.json (100%) rename modules/runners/{ => providers/ec2}/policies/instance-create-tags-policy.json (100%) rename modules/runners/{ => providers/ec2}/policies/instance-describe-tags-policy.json (100%) rename modules/runners/{ => providers/ec2}/policies/instance-ec2.json (100%) rename modules/runners/{ => providers/ec2}/policies/instance-role-trust-policy.json (100%) rename modules/runners/{ => providers/ec2}/policies/instance-s3-policy.json (100%) rename modules/runners/{ => providers/ec2}/policies/instance-ssm-parameters-policy.json (100%) rename modules/runners/{ => providers/ec2}/policies/instance-ssm-policy.json (100%) create mode 100644 modules/runners/providers/ec2/policies/lambda-pool.json create mode 100644 modules/runners/providers/ec2/policies/lambda-scale-down.json create mode 100644 modules/runners/providers/ec2/policies/lambda-scale-up.json rename modules/runners/{ => providers/ec2}/policies/service-linked-role-create-policy.json (100%) create mode 100644 modules/runners/providers/ec2/runner-config.tf rename modules/runners/{ => providers/ec2}/templates/cloudwatch_config.json (100%) rename modules/runners/{ => providers/ec2}/templates/install-runner-osx.sh (100%) rename modules/runners/{ => providers/ec2}/templates/install-runner.ps1 (100%) rename modules/runners/{ => providers/ec2}/templates/install-runner.sh (100%) rename modules/runners/{ => providers/ec2}/templates/start-runner-osx.sh (100%) rename modules/runners/{ => providers/ec2}/templates/start-runner.ps1 (100%) rename modules/runners/{ => providers/ec2}/templates/start-runner.sh (100%) rename modules/runners/{ => providers/ec2}/templates/user-data-osx.sh (100%) rename modules/runners/{ => providers/ec2}/templates/user-data.ps1 (100%) rename modules/runners/{ => providers/ec2}/templates/user-data.sh (100%) create mode 100644 modules/runners/tests-upgrade/README.md create mode 100644 modules/runners/tests-upgrade/fixtures/pre-provider-split/README.md create mode 100644 modules/runners/tests-upgrade/fixtures/pre-provider-split/main.tf create mode 100644 modules/runners/tests-upgrade/fixtures/pre-provider-split/outputs.tf rename modules/{multi-runner/tests-upgrade/fixtures/pre-provider-boundary => runners/tests-upgrade/fixtures/pre-provider-split}/versions.tf (100%) create mode 100644 modules/runners/tests-upgrade/state-migration.tftest.hcl create mode 100644 modules/runners/tests-upgrade/verify-moves.jq diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 5d7d9607b9..cb443dc241 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -216,6 +216,7 @@ jobs: module: - modules/runners - modules/multi-runner + - modules/runners/pool - modules/runners/providers/ec2 defaults: run: @@ -236,9 +237,14 @@ jobs: run: terraform init -backend=false -input=false - name: terraform test run: terraform test -test-directory=tests - - if: matrix.module == 'modules/multi-runner' - name: terraform init state migration test + - if: matrix.module == 'modules/runners' + name: Install state-upgrade test tools + run: apk add --no-cache jq + - if: matrix.module == 'modules/runners' + name: terraform init state upgrade test run: terraform init -backend=false -input=false -test-directory=tests-upgrade - - if: matrix.module == 'modules/multi-runner' - name: terraform test state migration - run: terraform test -test-directory=tests-upgrade + - if: matrix.module == 'modules/runners' + name: terraform test state upgrade + run: | + terraform test -test-directory=tests-upgrade -json -verbose > state-upgrade-test.json + jq -e -s -f tests-upgrade/verify-moves.jq state-upgrade-test.json diff --git a/docs/index.md b/docs/index.md index 982ed1395b..35072a2427 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,7 +101,7 @@ Besides these permissions, the lambdas also need permission to CloudWatch (for l ## Terraform main modules -Currently we support two main modules. The `runners` module is the main module for creating runners and contains the current EC2 implementation. The `multi-runner` module adds provider-neutral lane normalization, queues, and webhook routing so multiple runners can be created in one deployment, delegating normalized EC2 lanes through the provider adapter under `modules/runners/providers/ec2`. EC2 is the only Terraform-managed provider today; microVM, CodeBuild, and other provider modules are future work. +Currently we support two main modules. The `runners` module creates the provider-neutral control plane and selects an internal compute implementation with `provider_type`. The `multi-runner` module adds lane normalization, queues, and webhook routing so multiple runners can be created in one deployment. EC2 is the only Terraform-managed provider today; its child module owns EC2 compute and runner-instance permissions while the shared layer owns scale-up, scale-down, pool, retry, and Lambda roles. microVM, CodeBuild, and other providers are future work. Both modules are built on top of the same base modules. When using the multi-runner module you can deploy different runners with only one deployment. diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index e90e26fbea..5f392de0fe 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -8,9 +8,9 @@ The module takes a configuration as input containing a matcher for the labels. T ## Provider boundary -The multi-runner module owns provider-neutral lane normalization, queues, shared runner-binary discovery, and webhook routing. Normalized EC2 lanes are delegated to the `modules/runners/providers/ec2` adapter, which composes the existing EC2 runner, pool, and EC2-specific IAM implementation. +The multi-runner module owns provider-neutral lane normalization, queues, shared runner-binary discovery, and webhook routing. Each normalized lane is deployed through `modules/runners`, which selects its internal compute provider with `provider_type`. The EC2 child owns EC2 compute, runner-instance IAM, bootstrap, and provider-specific Lambda fragments; the shared runner module owns scale-up, scale-down, pool, retry, and Lambda roles. -Both the stable `multi_runner_config` input and experimental `multi_runner_config_v2` input use this same boundary. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. Existing lane keys are retained, and a Terraform `moved` block migrates the previous per-lane runner module addresses into the EC2 provider module without replacing resources. +Both the stable `multi_runner_config` input and experimental `multi_runner_config_v2` input use this same boundary. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. Existing lane keys and common control-plane addresses remain stable. Internal `moved` blocks migrate only the resources that are now owned by the EC2 child. For each configuration: @@ -83,7 +83,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -91,25 +91,25 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | -| [random](#provider\_random) | ~> 3.0 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | +| [random](#provider\_random) | 3.9.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | -| [ec2](#module\_ec2) | ../runners/providers/ec2 | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | +| [runners](#module\_runners) | ../runners | n/a | | [ssm](#module\_ssm) | ../ssm | n/a | | [webhook](#module\_webhook) | ../webhook | n/a | ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -120,7 +120,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | | [ami\_housekeeper\_lambda\_s3\_key](#input\_ami\_housekeeper\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | @@ -159,7 +159,7 @@ module "multi-runner" { | [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, osx, 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\_job\_queued\_check: Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
enable\_on\_demand\_failover\_for\_errors: "Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later."
scale\_errors: "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`."
instance\_type\_priorities: "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
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)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
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."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` disables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_reserved\_concurrent\_executions: "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
enable\_jit\_config: "Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
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."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`."
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."
pool\_config: "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
map(object({
runner_config = object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
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
})), [])
use_dedicated_host = optional(bool, false)
runner_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)
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
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}))
| `{}` | no | -| [multi\_runner\_config\_v2](#input\_multi\_runner\_config\_v2) | Experimental runner lane configuration keyed by lane name. This v2 shape separates common runner routing from provider-specific backend configuration. The schema can change while the provider model is being finalized. When set, this variable takes precedence over stable `multi_runner_config`.

Each lane has:
- `runner`: GitHub runner behavior shared by all providers.
- `provider`: backend discriminator plus typed provider configuration.
- `queue`: queue and event-source settings for the lane.
- `matcherConfig`: webhook routing labels and priority. |
map(object({
runner = object({
runner_os = string
runner_architecture = string
disable_runner_autoupdate = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_jit_config = optional(bool, null)
enable_organization_runners = optional(bool, false)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_iam_role_managed_policy_arns = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
})

provider = object({
type = string

ec2 = optional(object({
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), 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
}])
cloudwatch_config = optional(string, null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
enable_runner_binaries_syncer = optional(bool, true)
enable_runner_detailed_monitoring = optional(bool, false)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
runner_additional_security_group_ids = optional(list(string), [])
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
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
})), [])
use_dedicated_host = optional(bool, false)
runner_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)
runner_ec2_tags = optional(map(string), {})
runner_hook_job_completed = optional(string, "")
runner_hook_job_started = optional(string, "")
userdata_content = optional(string, null)
userdata_post_install = optional(string, "")
userdata_pre_install = optional(string, "")
userdata_template = optional(string, null)
}), null)

# Future provider references only. Do not uncomment until the Terraform
# resources for these lanes are implemented.
#
# microvm = optional(object({
# environment_variables = optional(map(string), {})
# }), null)
})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| `{}` | no | +| [multi\_runner\_config\_v2](#input\_multi\_runner\_config\_v2) | Experimental runner lane configuration keyed by lane name. This v2 shape separates common runner routing from provider-specific backend configuration. The schema can change while the provider model is being finalized. When set, this variable takes precedence over stable `multi_runner_config`.

Each lane has:
- `runner`: GitHub runner behavior shared by all providers.
- `provider`: backend discriminator plus typed provider configuration.
- `queue`: queue and event-source settings for the lane.
- `matcherConfig`: webhook routing labels and priority. |
map(object({
runner = object({
runner_os = string
runner_architecture = string
disable_runner_autoupdate = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_jit_config = optional(bool, null)
enable_organization_runners = optional(bool, false)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
})

provider = object({
type = string

ec2 = optional(object({
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), 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
}])
cloudwatch_config = optional(string, null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
enable_runner_binaries_syncer = optional(bool, true)
enable_runner_detailed_monitoring = optional(bool, false)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
runner_additional_security_group_ids = optional(list(string), [])
runner_iam_role_managed_policy_arns = optional(list(string), [])
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = 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
})), [])
use_dedicated_host = optional(bool, false)
runner_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)
runner_ec2_tags = optional(map(string), {})
runner_hook_job_completed = optional(string, "")
runner_hook_job_started = optional(string, "")
userdata_content = optional(string, null)
userdata_post_install = optional(string, "")
userdata_pre_install = optional(string, "")
userdata_template = optional(string, null)
}), null)

# Future provider references only. Do not uncomment until the Terraform
# resources for these lanes are implemented.
#
# microvm = optional(object({
# environment_variables = optional(map(string), {})
# }), null)
})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| `{}` | no | | [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | | [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | @@ -204,7 +204,7 @@ module "multi-runner" { ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | diff --git a/modules/multi-runner/multi-runner-config.tf b/modules/multi-runner/multi-runner-config.tf index 6651b70295..7332fd3d5c 100644 --- a/modules/multi-runner/multi-runner-config.tf +++ b/modules/multi-runner/multi-runner-config.tf @@ -9,6 +9,7 @@ locals { enable_job_queued_check = v.runner_config.enable_job_queued_check enable_jit_config = v.runner_config.enable_jit_config enable_organization_runners = v.runner_config.enable_organization_runners + idle_config = v.runner_config.idle_config minimum_running_time_in_minutes = v.runner_config.minimum_running_time_in_minutes pool_runner_owner = v.runner_config.pool_runner_owner runner_as_root = v.runner_config.runner_as_root @@ -19,12 +20,10 @@ locals { runner_name_prefix = v.runner_config.runner_name_prefix runner_run_as = v.runner_config.runner_run_as runners_maximum_count = v.runner_config.runners_maximum_count - runner_iam_role_managed_policy_arns = v.runner_config.runner_iam_role_managed_policy_arns scale_down_schedule_expression = v.runner_config.scale_down_schedule_expression scale_up_reserved_concurrent_executions = v.runner_config.scale_up_reserved_concurrent_executions pool_config = v.runner_config.pool_config job_retry = v.runner_config.job_retry - iam_overrides = v.runner_config.iam_overrides } provider = { @@ -48,11 +47,12 @@ locals { instance_type_priorities = v.runner_config.instance_type_priorities instance_types = v.runner_config.instance_types runner_additional_security_group_ids = v.runner_config.runner_additional_security_group_ids + runner_iam_role_managed_policy_arns = v.runner_config.runner_iam_role_managed_policy_arns + iam_overrides = v.runner_config.iam_overrides enable_on_demand_failover_for_errors = v.runner_config.enable_on_demand_failover_for_errors scale_errors = v.runner_config.scale_errors subnet_ids = v.runner_config.subnet_ids vpc_id = v.runner_config.vpc_id - idle_config = v.runner_config.idle_config cpu_options = v.runner_config.cpu_options placement = v.runner_config.placement license_specifications = v.runner_config.license_specifications diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index 367e418a6e..a08e3a7ff2 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -1,6 +1,24 @@ output "runners_map" { - value = module.ec2.runners_map + value = { for runner_key, runner in module.runners : runner_key => { + launch_template_name = try(runner.launch_template.name, null) + launch_template_id = try(runner.launch_template.id, null) + launch_template_version = try(runner.launch_template.latest_version, null) + launch_template_ami_id = try(runner.launch_template.image_id, null) + lambda_up = runner.lambda_scale_up + lambda_up_log_group = runner.lambda_scale_up_log_group + lambda_down = runner.lambda_scale_down + lambda_down_log_group = runner.lambda_scale_down_log_group + lambda_pool = runner.lambda_pool + lambda_pool_log_group = runner.lambda_pool_log_group + role_runner = runner.role_runner + role_scale_up = runner.role_scale_up + role_scale_down = runner.role_scale_down + role_pool = runner.role_pool + runners_log_groups = runner.runners_log_groups + logfiles = runner.logfiles + } + } } output "binaries_syncer_map" { diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 1328ed9359..e70d2a27d5 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -1,78 +1,132 @@ -module "ec2" { - source = "../runners/providers/ec2" - - lanes = { - for lane_key, lane in local.runner_config_by_provider.ec2 : lane_key => { - runner = lane.runner - provider = lane.provider.ec2 - queue = { - arn = lane.arn - url = lane.url - lambda_event_source_mapping_batch_size = lane.queue.lambda_event_source_mapping_batch_size - lambda_event_source_mapping_maximum_batching_window_in_seconds = lane.queue.lambda_event_source_mapping_maximum_batching_window_in_seconds - } - } - } +module "runners" { + source = "../runners" + for_each = local.runner_config + provider_type = each.value.runnerProvider aws_region = var.aws_region aws_partition = var.aws_partition - vpc_id = var.vpc_id - subnet_ids = var.subnet_ids - prefix = var.prefix - tags = local.tags + vpc_id = coalesce(each.value.provider.ec2.vpc_id, var.vpc_id) + subnet_ids = coalesce(each.value.provider.ec2.subnet_ids, var.subnet_ids) + prefix = "${var.prefix}-${each.key}" + tags = merge(local.tags, { + "ghr:environment" = "${var.prefix}-${each.key}" + }) + + s3_runner_binaries = each.value.provider.ec2.enable_runner_binaries_syncer ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.runner_os}_${each.value.runner.runner_architecture}"] : null + + ssm_paths = { + root = "${local.ssm_root_path}/${each.key}" + tokens = "${var.ssm_paths.runners}/tokens" + config = "${var.ssm_paths.runners}/config" + } - runner_binaries = local.runner_binaries_by_os_and_arch_map - github_app_parameters = local.github_app_parameters - ssm_root_path = local.ssm_root_path - ssm_paths = var.ssm_paths + runner_os = each.value.runner.runner_os + instance_types = each.value.provider.ec2.instance_types + instance_target_capacity_type = each.value.provider.ec2.instance_target_capacity_type + instance_allocation_strategy = each.value.provider.ec2.instance_allocation_strategy + instance_type_priorities = each.value.provider.ec2.instance_type_priorities + instance_max_spot_price = each.value.provider.ec2.instance_max_spot_price + block_device_mappings = each.value.provider.ec2.block_device_mappings + runner_architecture = each.value.runner.runner_architecture + ami = each.value.provider.ec2.ami + + sqs_build_queue = { "arn" : each.value.arn, "url" : each.value.url } + github_app_parameters = local.github_app_parameters + ebs_optimized = each.value.provider.ec2.ebs_optimized + enable_on_demand_failover_for_errors = each.value.provider.ec2.enable_on_demand_failover_for_errors + scale_errors = each.value.provider.ec2.scale_errors + enable_organization_runners = each.value.runner.enable_organization_runners + enable_ephemeral_runners = each.value.runner.enable_ephemeral_runners + enable_jit_config = each.value.runner.enable_jit_config + enable_job_queued_check = each.value.runner.enable_job_queued_check + disable_runner_autoupdate = each.value.runner.disable_runner_autoupdate enable_managed_runner_security_group = var.enable_managed_runner_security_group - runner_egress_rules = var.runner_egress_rules - runner_additional_security_group_ids = var.runner_additional_security_group_ids - associate_public_ipv4_address = var.associate_public_ipv4_address - key_name = var.key_name + enable_runner_detailed_monitoring = each.value.provider.ec2.enable_runner_detailed_monitoring + scale_down_schedule_expression = each.value.runner.scale_down_schedule_expression + minimum_running_time_in_minutes = each.value.runner.minimum_running_time_in_minutes + runner_boot_time_in_minutes = each.value.runner.runner_boot_time_in_minutes + runner_disable_default_labels = each.value.runner.runner_disable_default_labels + runner_labels = each.value.runner.runner_disable_default_labels ? sort(distinct(each.value.runner.runner_extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.runner_os, each.value.runner.runner_architecture], each.value.runner.runner_extra_labels))) + runner_as_root = each.value.runner.runner_as_root + runner_run_as = each.value.runner.runner_run_as + runners_maximum_count = each.value.runner.runners_maximum_count + idle_config = each.value.runner.idle_config + enable_ssm_on_runners = each.value.provider.ec2.enable_ssm_on_runners + egress_rules = var.runner_egress_rules + runner_additional_security_group_ids = try(coalescelist(each.value.provider.ec2.runner_additional_security_group_ids, var.runner_additional_security_group_ids), []) + metadata_options = each.value.provider.ec2.runner_metadata_options + credit_specification = each.value.provider.ec2.credit_specification + cpu_options = each.value.provider.ec2.cpu_options + placement = each.value.provider.ec2.placement + license_specifications = each.value.provider.ec2.license_specifications + use_dedicated_host = each.value.provider.ec2.use_dedicated_host + enable_runner_binaries_syncer = each.value.provider.ec2.enable_runner_binaries_syncer lambda_s3_bucket = var.lambda_s3_bucket runners_lambda_s3_key = var.runners_lambda_s3_key runners_lambda_s3_object_version = var.runners_lambda_s3_object_version - runners_lambda_zip = var.runners_lambda_zip lambda_runtime = var.lambda_runtime lambda_architecture = var.lambda_architecture - scale_up_lambda_memory_size = var.scale_up_lambda_memory_size - runners_scale_up_lambda_timeout = var.runners_scale_up_lambda_timeout - scale_down_lambda_memory_size = var.scale_down_lambda_memory_size - runners_scale_down_lambda_timeout = var.runners_scale_down_lambda_timeout - lambda_event_source_mapping_batch_size = var.lambda_event_source_mapping_batch_size - lambda_event_source_mapping_maximum_batching_window_in_seconds = var.lambda_event_source_mapping_maximum_batching_window_in_seconds + lambda_zip = var.runners_lambda_zip + lambda_scale_up_memory_size = var.scale_up_lambda_memory_size + lambda_event_source_mapping_batch_size = coalesce(each.value.queue.lambda_event_source_mapping_batch_size, var.lambda_event_source_mapping_batch_size) + lambda_event_source_mapping_maximum_batching_window_in_seconds = coalesce(each.value.queue.lambda_event_source_mapping_maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) + lambda_timeout_scale_up = var.runners_scale_up_lambda_timeout + lambda_scale_down_memory_size = var.scale_down_lambda_memory_size + lambda_timeout_scale_down = var.runners_scale_down_lambda_timeout lambda_subnet_ids = var.lambda_subnet_ids lambda_security_group_ids = var.lambda_security_group_ids lambda_tags = var.lambda_tags tracing_config = var.tracing_config + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + log_class = var.log_class + enable_cloudwatch_agent = each.value.provider.ec2.enable_cloudwatch_agent + cloudwatch_config = try(coalesce(each.value.provider.ec2.cloudwatch_config, var.cloudwatch_config), null) + runner_log_files = each.value.provider.ec2.runner_log_files + runner_group_name = each.value.runner.runner_group_name + runner_name_prefix = each.value.runner.runner_name_prefix + parameter_store_tags = var.parameter_store_tags - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id - log_class = var.log_class - log_level = var.log_level - cloudwatch_config = var.cloudwatch_config - parameter_store_tags = var.parameter_store_tags + scale_up_reserved_concurrent_executions = each.value.runner.scale_up_reserved_concurrent_executions instance_profile_path = var.instance_profile_path role_path = var.role_path role_permissions_boundary = var.role_permissions_boundary - kms_key_arn = var.kms_key_arn + + enable_userdata = each.value.provider.ec2.enable_userdata + userdata_template = each.value.provider.ec2.userdata_template + userdata_content = each.value.provider.ec2.userdata_content + userdata_pre_install = each.value.provider.ec2.userdata_pre_install + userdata_post_install = each.value.provider.ec2.userdata_post_install + runner_hook_job_started = each.value.provider.ec2.runner_hook_job_started + runner_hook_job_completed = each.value.provider.ec2.runner_hook_job_completed + key_name = var.key_name + runner_ec2_tags = each.value.provider.ec2.runner_ec2_tags + + create_service_linked_role_spot = each.value.provider.ec2.create_service_linked_role_spot + + runner_iam_role_managed_policy_arns = each.value.provider.ec2.runner_iam_role_managed_policy_arns + iam_overrides = each.value.provider.ec2.iam_overrides ghes_url = var.ghes_url ghes_ssl_verify = var.ghes_ssl_verify user_agent = var.user_agent + kms_key_arn = var.kms_key_arn + + log_level = var.log_level + + pool_config = each.value.runner.pool_config pool_lambda_timeout = var.pool_lambda_timeout + pool_runner_owner = each.value.runner.pool_runner_owner pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions - runners_ssm_housekeeper = var.runners_ssm_housekeeper - metrics = var.metrics -} + associate_public_ipv4_address = var.associate_public_ipv4_address + + ssm_housekeeper = var.runners_ssm_housekeeper + + job_retry = each.value.runner.job_retry -# Keep every existing for_each lane in state while introducing the provider layer. -moved { - from = module.runners - to = module.ec2.module.runners + metrics = var.metrics } diff --git a/modules/multi-runner/tests-upgrade/README.md b/modules/multi-runner/tests-upgrade/README.md deleted file mode 100644 index 31c4f2ea86..0000000000 --- a/modules/multi-runner/tests-upgrade/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Multi-runner upgrade tests - -These tests use Terraform 1.14 state sharing to apply a pre-provider-boundary fixture, plan the current module against the same in-memory state, and verify that EC2 runner, pool, and IAM resource identities survive the module move. - -Run them separately from the minimum-version module checks: - -```shell -terraform init -backend=false -input=false -test-directory=tests-upgrade -terraform test -test-directory=tests-upgrade -``` diff --git a/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/README.md b/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/README.md deleted file mode 100644 index 07b21386d9..0000000000 --- a/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Pre-provider-boundary fixture - -This internal test fixture recreates the former `module.runners[]` address so the multi-runner upgrade test can verify that the EC2 provider `moved` block retains existing resource identities. - - -## Requirements - -| Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.14.0 | -| [aws](#requirement\_aws) | >= 6.33 | - -## Providers - -No providers. - -## Modules - -| Name | Source | Version | -|------|--------|---------| -| [runners](#module\_runners) | ../../../../runners | n/a | - -## Resources - -No resources. - -## Inputs - -No inputs. - -## Outputs - -| Name | Description | -|------|-------------| -| [runner\_state](#output\_runner\_state) | Stable resource identities captured before the EC2 provider boundary. | - diff --git a/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/main.tf b/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/main.tf deleted file mode 100644 index 3682a15d5d..0000000000 --- a/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/main.tf +++ /dev/null @@ -1,47 +0,0 @@ -module "runners" { - source = "../../../../runners" - for_each = { - linux = true - } - - aws_region = "eu-west-1" - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - prefix = "upgrade-test-linux" - - instance_types = ["m5.large"] - enable_runner_binaries_syncer = false - s3_runner_binaries = null - sqs_build_queue = { - arn = "arn:aws:sqs:eu-west-1:123456789012:upgrade-test-linux" - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/upgrade-test-linux" - } - - github_app_parameters = { - key_base64 = { - name = "/github-action-runners/upgrade-test/app/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/upgrade-test/app/key-base64" - } - id = { - name = "/github-action-runners/upgrade-test/app/id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/upgrade-test/app/id" - } - } - - enable_organization_runners = true - enable_ssm_on_runners = false - runner_labels = ["self-hosted", "linux", "x64"] - ssm_paths = { - root = "/github-action-runners/upgrade-test/linux" - tokens = "runners/tokens" - config = "runners/config" - } - - lambda_s3_bucket = "lambda-artifacts" - runners_lambda_s3_key = "runners.zip" - - pool_config = [{ - schedule_expression = "cron(0 8 * * ? *)" - size = 1 - }] -} diff --git a/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/outputs.tf b/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/outputs.tf deleted file mode 100644 index 5c30d244c6..0000000000 --- a/modules/multi-runner/tests-upgrade/fixtures/pre-provider-boundary/outputs.tf +++ /dev/null @@ -1,9 +0,0 @@ -output "runner_state" { - description = "Stable resource identities captured before the EC2 provider boundary." - value = { - launch_template_id = module.runners["linux"].launch_template.id - lambda_up_id = module.runners["linux"].lambda_scale_up.id - lambda_pool_id = module.runners["linux"].lambda_pool.id - role_runner_id = module.runners["linux"].role_runner[0].id - } -} diff --git a/modules/multi-runner/tests-upgrade/state-migration.tftest.hcl b/modules/multi-runner/tests-upgrade/state-migration.tftest.hcl deleted file mode 100644 index aabd1fb621..0000000000 --- a/modules/multi-runner/tests-upgrade/state-migration.tftest.hcl +++ /dev/null @@ -1,99 +0,0 @@ -mock_provider "aws" { - mock_data "aws_iam_policy_document" { - defaults = { - json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" - } - } - - mock_resource "aws_iam_role" { - defaults = { - arn = "arn:aws:iam::123456789012:role/upgrade-test" - } - } - - mock_resource "aws_lambda_function" { - defaults = { - arn = "arn:aws:lambda:eu-west-1:123456789012:function:upgrade-test" - } - } - - mock_resource "aws_cloudwatch_event_rule" { - defaults = { - arn = "arn:aws:events:eu-west-1:123456789012:rule/upgrade-test" - } - } -} - -mock_provider "random" {} -mock_provider "null" {} - -run "apply_pre_provider_boundary" { - command = apply - state_key = "provider-boundary-upgrade" - - module { - source = "./tests-upgrade/fixtures/pre-provider-boundary" - } -} - -run "plan_provider_boundary_upgrade" { - command = plan - state_key = "provider-boundary-upgrade" - - variables { - aws_region = "eu-west-1" - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - prefix = "upgrade-test" - - github_app = { - id = "123456" - key_base64 = "dGVzdA==" - webhook_secret = "test-secret" - } - - lambda_s3_bucket = "lambda-artifacts" - webhook_lambda_s3_key = "webhook.zip" - runners_lambda_s3_key = "runners.zip" - - multi_runner_config = { - linux = { - runner_config = { - runner_os = "linux" - runner_architecture = "x64" - instance_types = ["m5.large"] - runners_maximum_count = 2 - enable_runner_binaries_syncer = false - enable_organization_runners = true - pool_config = [{ - schedule_expression = "cron(0 8 * * ? *)" - size = 1 - }] - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } - } - } - } - - assert { - condition = output.runners_map["linux"].launch_template_id == run.apply_pre_provider_boundary.runner_state.launch_template_id - error_message = "The EC2 provider boundary must retain the existing launch template state." - } - - assert { - condition = output.runners_map["linux"].lambda_up.id == run.apply_pre_provider_boundary.runner_state.lambda_up_id - error_message = "The EC2 provider boundary must retain the existing scale-up Lambda state." - } - - assert { - condition = output.runners_map["linux"].lambda_pool.id == run.apply_pre_provider_boundary.runner_state.lambda_pool_id - error_message = "The EC2 provider boundary must retain the existing pool Lambda state." - } - - assert { - condition = output.runners_map["linux"].role_runner[0].id == run.apply_pre_provider_boundary.runner_state.role_runner_id - error_message = "The EC2 provider boundary must retain the existing runner IAM role state." - } -} diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 8d80201a5a..8eab304ad0 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -51,6 +51,11 @@ run "stable_v1_routes_through_ec2_provider" { error_message = "Stable multi_runner_config lanes must route to the EC2 provider." } + assert { + condition = keys(module.runners) == keys(local.runner_config) + error_message = "The common runners facade must dispatch every normalized lane." + } + assert { condition = keys(aws_sqs_queue.queued_builds) == ["linux"] error_message = "Common queue ownership must preserve the stable lane key." @@ -73,6 +78,11 @@ run "experimental_v2_routes_through_ec2_provider" { runner_architecture = "x64" runners_maximum_count = 2 enable_organization_runners = true + idle_config = [{ + cron = "* * * * *" + timeZone = "UTC" + idleCount = 1 + }] pool_config = [{ schedule_expression = "cron(0 8 * * ? *)" size = 1 @@ -81,8 +91,9 @@ run "experimental_v2_routes_through_ec2_provider" { provider = { type = "ec2" ec2 = { - instance_types = ["m5.large"] - enable_runner_binaries_syncer = false + instance_types = ["m5.large"] + enable_runner_binaries_syncer = false + runner_iam_role_managed_policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"] } } matcherConfig = { @@ -97,6 +108,11 @@ run "experimental_v2_routes_through_ec2_provider" { error_message = "Experimental multi_runner_config_v2 lanes must route to the EC2 provider." } + assert { + condition = keys(module.runners) == keys(local.runner_config) + error_message = "The common runners facade must dispatch every normalized experimental lane." + } + assert { condition = keys(aws_sqs_queue.queued_builds) == ["linux"] error_message = "Common queue ownership must preserve the experimental lane key." @@ -106,6 +122,16 @@ run "experimental_v2_routes_through_ec2_provider" { condition = keys(output.runners_map) == ["linux"] error_message = "Experimental multi_runner_config_v2 must preserve the public runner map key." } + + assert { + condition = local.runner_config_by_provider.ec2["linux"].runner.idle_config[0].idleCount == 1 + error_message = "Provider-neutral idle configuration must remain in the common runner contract." + } + + assert { + condition = local.runner_config_by_provider.ec2["linux"].provider.ec2.runner_iam_role_managed_policy_arns[0] == "arn:aws:iam::aws:policy/ReadOnlyAccess" + error_message = "EC2 runner-role policies must remain in the EC2 provider contract." + } } run "experimental_v2_rejects_future_providers" { diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index a0ee5dd137..e460883ed7 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -10,13 +10,19 @@ Each lane has: EOT type = map(object({ runner = object({ - runner_os = string - runner_architecture = string - disable_runner_autoupdate = optional(bool, false) - enable_ephemeral_runners = optional(bool, false) - enable_job_queued_check = optional(bool, null) - enable_jit_config = optional(bool, null) - enable_organization_runners = optional(bool, false) + runner_os = string + runner_architecture = string + disable_runner_autoupdate = optional(bool, false) + enable_ephemeral_runners = optional(bool, false) + enable_job_queued_check = optional(bool, null) + enable_jit_config = optional(bool, null) + enable_organization_runners = optional(bool, false) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), []) minimum_running_time_in_minutes = optional(number, null) pool_runner_owner = optional(string, null) runner_as_root = optional(bool, false) @@ -27,7 +33,6 @@ EOT runner_name_prefix = optional(string, "") runner_run_as = optional(string, "ec2-user") runners_maximum_count = number - runner_iam_role_managed_policy_arns = optional(list(string), []) scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)") scale_up_reserved_concurrent_executions = optional(number, 1) pool_config = optional(list(object({ @@ -43,17 +48,6 @@ EOT lambda_timeout = optional(number, 30) max_attempts = optional(number, 1) }), {}) - iam_overrides = optional(object({ - override_instance_profile = optional(bool, null) - instance_profile_name = optional(string, null) - override_runner_role = optional(bool, null) - runner_role_arn = optional(string, null) - }), { - override_instance_profile = false - instance_profile_name = null - override_runner_role = false - runner_role_arn = null - }) }) provider = object({ @@ -101,6 +95,18 @@ EOT instance_type_priorities = optional(map(number), null) instance_types = list(string) runner_additional_security_group_ids = optional(list(string), []) + runner_iam_role_managed_policy_arns = optional(list(string), []) + iam_overrides = optional(object({ + override_instance_profile = optional(bool, null) + instance_profile_name = optional(string, null) + override_runner_role = optional(bool, null) + runner_role_arn = optional(string, null) + }), { + override_instance_profile = false + instance_profile_name = null + override_runner_role = false + runner_role_arn = null + }) enable_on_demand_failover_for_errors = optional(list(string), []) scale_errors = optional(list(string), [ "UnfulfillableCapacity", @@ -115,12 +121,6 @@ EOT ]) subnet_ids = optional(list(string), null) vpc_id = optional(string, null) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), []) cpu_options = optional(object({ core_count = optional(number) threads_per_core = optional(number) diff --git a/modules/runners/README.md b/modules/runners/README.md index 0a1c0c53cd..baa2feda61 100644 --- a/modules/runners/README.md +++ b/modules/runners/README.md @@ -2,7 +2,9 @@ > This module is treated as internal module, breaking changes will not trigger a major release bump. -This module creates resources required to run the GitHub action runner on AWS EC2 spot instances. The lifecycle of the runners on AWS is managed by two lambda functions. One function will handle scaling up, the other scaling down. +This module creates a provider-neutral runner control plane and selects its compute implementation with `provider_type`. EC2 is the only active provider today and remains the default, so existing callers continue to deploy without changing their configuration. + +The common layer owns scale-up, scale-down, pool, job retry, Lambda execution roles, shared SSM configuration, and the SSM housekeeper. `providers/ec2` owns the launch template, runner instance role and policies, EC2 bootstrap parameters, runner log groups, and the EC2 environment and IAM fragments merged into the common Lambda resources. Future providers can implement the same output contract without copying the control plane. ## Overview @@ -51,48 +53,39 @@ yarn run dist ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | +| [ec2](#module\_ec2) | ./providers/ec2 | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | | [aws_cloudwatch_event_target.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | -| [aws_cloudwatch_log_group.gh_runners](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_cloudwatch_log_group.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_cloudwatch_log_group.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_cloudwatch_log_group.ssm_housekeeper](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_iam_role.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | -| [aws_iam_role_policy.cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.create_tag](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.describe_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.dist_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.ec2](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.job_retry_sqs_publish](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.runner_session_manager_aws_managed](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.scale_down_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.scale_down_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -103,13 +96,10 @@ yarn run dist | [aws_iam_role_policy.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.ssm_housekeeper_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.ssm_housekeeper_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.ssm_parameters](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy_attachment.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_iam_role_policy_attachment.managed_policies](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.scale_down_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.scale_up_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_iam_role_policy_attachment.xray_tracing](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_lambda_event_source_mapping.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | | [aws_lambda_function.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | | [aws_lambda_function.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | @@ -117,25 +107,20 @@ yarn run dist | [aws_lambda_permission.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | | [aws_lambda_permission.scale_runners_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | | [aws_lambda_permission.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | 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.disable_default_labels](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.jit_config_enabled](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.runner_agent_mode](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 | | [aws_ssm_parameter.token_path](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | 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.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.lambda_xray](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 | ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | @@ -205,6 +190,7 @@ yarn run dist | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | | [pool\_runner\_owner](#input\_pool\_runner\_owner) | The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported. | `string` | `null` | no | | [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no | +| [provider\_type](#input\_provider\_type) | Compute provider used by the common runner control plane. | `string` | `"ec2"` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the prefix will be used. | `string` | `null` | no | | [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | | [runner\_additional\_security\_group\_ids](#input\_runner\_additional\_security\_group\_ids) | (optional) List of additional security groups IDs to apply to the runner | `list(string)` | `[]` | no | @@ -246,7 +232,7 @@ yarn run dist ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [lambda\_pool](#output\_lambda\_pool) | n/a | | [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a | | [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a | diff --git a/modules/runners/job-retry.tf b/modules/runners/job-retry.tf index 00ed54d8e1..4adb27ad1a 100644 --- a/modules/runners/job-retry.tf +++ b/modules/runners/job-retry.tf @@ -26,6 +26,7 @@ locals { tracing_config = var.tracing_config github_app_parameters = var.github_app_parameters enable_organization_runners = var.enable_organization_runners + runner_name_prefix = var.runner_name_prefix sqs_build_queue = var.sqs_build_queue ghes_url = var.ghes_url lambda_event_source_mapping_batch_size = var.lambda_event_source_mapping_batch_size diff --git a/modules/runners/job-retry/README.md b/modules/runners/job-retry/README.md index 7ecd69deeb..a200075891 100644 --- a/modules/runners/job-retry/README.md +++ b/modules/runners/job-retry/README.md @@ -11,26 +11,26 @@ The module is an inner module and used by the runner module when the opt-in feat ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.21 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [job\_retry](#module\_job\_retry) | ../../lambda | n/a | ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_iam_role_policy.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_lambda_event_source_mapping.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | | [aws_lambda_permission.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | @@ -41,13 +41,13 @@ The module is an inner module and used by the runner module when the opt-in feat ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | +| ---- | ----------- | ---- | ------- | :------: | +| [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runner_name_prefix`: Prefix used to identify runners belonging to this lane.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | n/a | | [lambda](#output\_lambda) | n/a | diff --git a/modules/runners/job-retry/main.tf b/modules/runners/job-retry/main.tf index eba478b214..9c3377f59d 100644 --- a/modules/runners/job-retry/main.tf +++ b/modules/runners/job-retry/main.tf @@ -11,6 +11,7 @@ locals { JOB_QUEUE_SCALE_UP_URL = var.config.sqs_build_queue.url PARAMETER_GITHUB_APP_ID_NAME = var.config.github_app_parameters.id.name PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github_app_parameters.key_base64.name + RUNNER_NAME_PREFIX = var.config.runner_name_prefix } config = merge(var.config, { diff --git a/modules/runners/job-retry/variables.tf b/modules/runners/job-retry/variables.tf index 7ccfdf63b3..e71712d7a2 100644 --- a/modules/runners/job-retry/variables.tf +++ b/modules/runners/job-retry/variables.tf @@ -23,6 +23,7 @@ variable "config" { `prefix`: The prefix used for naming resources. `role_path`: The path that will be added to the role, if not set the environment name will be used. `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda. + `runner_name_prefix`: Prefix used to identify runners belonging to this lane. `runtime`: AWS Lambda runtime. `s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly. `s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. @@ -75,6 +76,7 @@ variable "config" { }), {}) role_path = optional(string, null) role_permissions_boundary = optional(string, null) + runner_name_prefix = optional(string, "") runtime = optional(string, null) security_group_ids = optional(list(string), []) subnet_ids = optional(list(string), []) diff --git a/modules/runners/main.tf b/modules/runners/main.tf index 547f320bda..ed40f2c1dc 100644 --- a/modules/runners/main.tf +++ b/modules/runners/main.tf @@ -9,334 +9,83 @@ locals { var.tags, ) - name_sg = var.overrides["name_sg"] == "" ? local.tags["Name"] : var.overrides["name_sg"] - name_runner = var.overrides["name_runner"] == "" ? local.tags["Name"] : var.overrides["name_runner"] - role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path - instance_profile_path = var.instance_profile_path == null ? "/${var.prefix}/" : var.instance_profile_path - lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/control-plane/runners.zip" : var.lambda_zip - userdata_template = var.userdata_template == null ? local.default_userdata_template[var.runner_os] : var.userdata_template - kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" - s3_location_runner_distribution = var.enable_runner_binaries_syncer ? "s3://${var.s3_runner_binaries.id}/${var.s3_runner_binaries.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.ami != null ? var.ami : { - filter = local.default_ami[var.runner_os] - owners = ["amazon"] - id_ssm_parameter_arn = null - kms_key_arn = null - } - ami_kms_key_arn = local.ami_config.kms_key_arn != null ? local.ami_config.kms_key_arn : "" - ami_filter = merge(local.default_ami[var.runner_os], local.ami_config.filter) - ami_id_ssm_module_managed = 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_module_managed ? null : try(regex("parameter(/.+)$", local.ami_config.id_ssm_parameter_arn)[0], null) - - enable_job_queued_check = var.enable_job_queued_check == null ? !var.enable_ephemeral_runners : var.enable_job_queued_check - + role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path + lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/control-plane/runners.zip" : var.lambda_zip + kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" + enable_job_queued_check = var.enable_job_queued_check == null ? !var.enable_ephemeral_runners : var.enable_job_queued_check + token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" + provider_type = lower(trimspace(var.provider_type)) - token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" - - user_data = var.enable_userdata ? (var.userdata_content == null ? templatefile(local.userdata_template, { - enable_debug_logging = var.enable_user_data_debug_logging - s3_location_runner_distribution = local.s3_location_runner_distribution - pre_install = var.userdata_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.userdata_post_install - hook_job_started = var.runner_hook_job_started - hook_job_completed = var.runner_hook_job_completed - start_runner = templatefile(local.userdata_start_runner[var.runner_os], { - metadata_tags = var.metadata_options != null ? var.metadata_options.instance_metadata_tags : "enabled" - }) - ghes_url = var.ghes_url - ghes_ssl_verify = var.ghes_ssl_verify - - ## retain these for backwards compatibility - environment = var.prefix - enable_cloudwatch_agent = var.enable_cloudwatch_agent - ssm_key_cloudwatch_agent_config = var.enable_cloudwatch_agent ? aws_ssm_parameter.cloudwatch_agent_config_runner[0].name : "" - }) : var.userdata_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" { - 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.id - - tags = merge( - local.tags, - { - # Remove parentheses from AMI name to comply with AWS tag constraints - "ghr:ami_name" = replace(data.aws_ami.runner.name, "/[()]/", "") - }, - { - "ghr:ami_creation_date" = data.aws_ami.runner.creation_date - }, - { - "ghr:ami_deprecation_time" = data.aws_ami.runner.deprecation_time - } - ) + provider = one(module.ec2[*].provider) } -resource "aws_launch_template" "runner" { - name = "${var.prefix}-action-runner" - - dynamic "block_device_mappings" { - for_each = var.block_device_mappings != null ? var.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.metadata_options != null ? [var.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.metadata_options != null ? [] : [0] - - content { - instance_metadata_tags = "enabled" - } - } - - dynamic "credit_specification" { - for_each = var.credit_specification != null ? [var.credit_specification] : [] - content { - cpu_credits = credit_specification.value - } - } - - dynamic "cpu_options" { - for_each = var.cpu_options != null ? [var.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.placement != null ? [var.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.license_specifications - content { - license_configuration_arn = license_specification.value.license_configuration_arn - } - } - - monitoring { - enabled = var.enable_runner_detailed_monitoring - } - - iam_instance_profile { - name = var.iam_overrides["override_instance_profile"] ? var.iam_overrides["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 : var.ami.id_ssm_parameter_arn}" - key_name = var.key_name +data "aws_caller_identity" "current" {} + +module "ec2" { + count = local.provider_type == "ec2" ? 1 : 0 + source = "./providers/ec2" + + ami = var.ami + aws_region = var.aws_region + vpc_id = var.vpc_id + subnet_ids = var.subnet_ids + overrides = var.overrides + iam_overrides = var.iam_overrides + tags = var.tags + prefix = var.prefix + s3_runner_binaries = var.s3_runner_binaries + block_device_mappings = var.block_device_mappings ebs_optimized = var.ebs_optimized - - vpc_security_group_ids = !var.associate_public_ipv4_address ? compact(concat( - var.enable_managed_runner_security_group ? [aws_security_group.runner_sg[0].id] : [], - var.runner_additional_security_group_ids, - )) : [] - - tag_specifications { - resource_type = "instance" - tags = merge( - local.tags, - { - "Name" = format("%s", local.name_runner) - }, - { - "ghr:runner_name_prefix" = var.runner_name_prefix - }, - var.runner_ec2_tags - ) - } - - tag_specifications { - resource_type = "volume" - tags = merge( - local.tags, - { - "Name" = format("%s", local.name_runner) - }, - { - "ghr:runner_name_prefix" = var.runner_name_prefix - }, - var.runner_ec2_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.instance_target_capacity_type == "spot" && length(var.enable_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 = merge( - local.tags, - { - "Name" = format("%s", local.name_runner) - }, - { - "ghr:runner_name_prefix" = var.runner_name_prefix - }, - var.runner_ec2_tags - ) - } - } - - tag_specifications { - resource_type = "network-interface" - tags = merge( - local.tags, - { - "Name" = format("%s", local.name_runner) - }, - { - "ghr:runner_name_prefix" = var.runner_name_prefix - }, - var.runner_ec2_tags - ) - } - - user_data = local.encoded_user_data - - tags = local.tags - - update_default_version = true - - dynamic "network_interfaces" { - for_each = var.associate_public_ipv4_address ? [var.associate_public_ipv4_address] : [] - iterator = associate_public_ipv4_address - content { - associate_public_ip_address = associate_public_ipv4_address.value - security_groups = compact(concat( - var.enable_managed_runner_security_group ? [aws_security_group.runner_sg[0].id] : [], - var.runner_additional_security_group_ids, - )) - } - } -} - -resource "aws_security_group" "runner_sg" { - count = var.enable_managed_runner_security_group ? 1 : 0 - name_prefix = "${var.prefix}-github-actions-runner-sg" - description = "Github Actions Runner security group" - - vpc_id = var.vpc_id - - ingress = [] - - dynamic "egress" { - for_each = var.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.tags, - { - "Name" = format("%s", local.name_sg) - }, - ) + instance_target_capacity_type = var.instance_target_capacity_type + instance_allocation_strategy = var.instance_allocation_strategy + instance_type_priorities = var.instance_type_priorities + instance_max_spot_price = var.instance_max_spot_price + runner_os = var.runner_os + instance_types = var.instance_types + enable_userdata = var.enable_userdata + userdata_template = var.userdata_template + userdata_content = var.userdata_content + userdata_pre_install = var.userdata_pre_install + userdata_post_install = var.userdata_post_install + runner_hook_job_started = var.runner_hook_job_started + runner_hook_job_completed = var.runner_hook_job_completed + runner_boot_time_in_minutes = var.runner_boot_time_in_minutes + role_permissions_boundary = var.role_permissions_boundary + role_path = var.role_path + instance_profile_path = var.instance_profile_path + runner_as_root = var.runner_as_root + runner_run_as = var.runner_run_as + runner_architecture = var.runner_architecture + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + enable_ssm_on_runners = var.enable_ssm_on_runners + create_service_linked_role_spot = var.create_service_linked_role_spot + aws_partition = var.aws_partition + runner_iam_role_managed_policy_arns = var.runner_iam_role_managed_policy_arns + enable_cloudwatch_agent = var.enable_cloudwatch_agent + enable_managed_runner_security_group = var.enable_managed_runner_security_group + cloudwatch_config = var.cloudwatch_config + runner_log_files = var.runner_log_files + ghes_url = var.ghes_url + ghes_ssl_verify = var.ghes_ssl_verify + key_name = var.key_name + runner_additional_security_group_ids = var.runner_additional_security_group_ids + enable_runner_detailed_monitoring = var.enable_runner_detailed_monitoring + egress_rules = var.egress_rules + runner_ec2_tags = var.runner_ec2_tags + metadata_options = var.metadata_options + enable_runner_binaries_syncer = var.enable_runner_binaries_syncer + enable_user_data_debug_logging = var.enable_user_data_debug_logging + ssm_paths = var.ssm_paths + runner_name_prefix = var.runner_name_prefix + tracing_config = var.tracing_config + credit_specification = var.credit_specification + cpu_options = var.cpu_options + placement = var.placement + license_specifications = var.license_specifications + associate_public_ipv4_address = var.associate_public_ipv4_address + enable_on_demand_failover_for_errors = var.enable_on_demand_failover_for_errors + scale_errors = var.scale_errors + use_dedicated_host = var.use_dedicated_host } diff --git a/modules/runners/moved.tf b/modules/runners/moved.tf new file mode 100644 index 0000000000..4352f30fb1 --- /dev/null +++ b/modules/runners/moved.tf @@ -0,0 +1,94 @@ +moved { + from = aws_ssm_parameter.runner_ami_id + to = module.ec2[0].aws_ssm_parameter.runner_ami_id +} + +moved { + from = aws_launch_template.runner + to = module.ec2[0].aws_launch_template.runner +} + +moved { + from = aws_security_group.runner_sg + to = module.ec2[0].aws_security_group.runner_sg +} + +moved { + from = aws_ssm_parameter.runner_config_run_as + to = module.ec2[0].aws_ssm_parameter.runner_config_run_as +} + +moved { + from = aws_ssm_parameter.runner_enable_cloudwatch + to = module.ec2[0].aws_ssm_parameter.runner_enable_cloudwatch +} + +moved { + from = aws_ssm_parameter.cloudwatch_agent_config_runner + to = module.ec2[0].aws_ssm_parameter.cloudwatch_agent_config_runner +} + +moved { + from = aws_cloudwatch_log_group.gh_runners + to = module.ec2[0].aws_cloudwatch_log_group.gh_runners +} + +moved { + from = aws_iam_role_policy.cloudwatch + to = module.ec2[0].aws_iam_role_policy.cloudwatch +} + +moved { + from = aws_iam_role.runner + to = module.ec2[0].aws_iam_role.runner +} + +moved { + from = aws_iam_instance_profile.runner + to = module.ec2[0].aws_iam_instance_profile.runner +} + +moved { + from = aws_iam_role_policy.runner_session_manager_aws_managed + to = module.ec2[0].aws_iam_role_policy.runner_session_manager_aws_managed +} + +moved { + from = aws_iam_role_policy.ssm_parameters + to = module.ec2[0].aws_iam_role_policy.ssm_parameters +} + +moved { + from = aws_iam_role_policy.dist_bucket + to = module.ec2[0].aws_iam_role_policy.dist_bucket +} + +moved { + from = aws_iam_role_policy_attachment.xray_tracing + to = module.ec2[0].aws_iam_role_policy_attachment.xray_tracing +} + +moved { + from = aws_iam_role_policy.describe_tags + to = module.ec2[0].aws_iam_role_policy.describe_tags +} + +moved { + from = aws_iam_role_policy.create_tag + to = module.ec2[0].aws_iam_role_policy.create_tag +} + +moved { + from = aws_iam_role_policy_attachment.managed_policies + to = module.ec2[0].aws_iam_role_policy_attachment.managed_policies +} + +moved { + from = aws_iam_role_policy.ec2 + to = module.ec2[0].aws_iam_role_policy.ec2 +} + +moved { + from = aws_iam_policy.ami_id_ssm_parameter_read + to = module.ec2[0].aws_iam_policy.ami_id_ssm_parameter_read +} diff --git a/modules/runners/outputs.tf b/modules/runners/outputs.tf index 8f366dce90..973538ebce 100644 --- a/modules/runners/outputs.tf +++ b/modules/runners/outputs.tf @@ -1,9 +1,11 @@ output "launch_template" { - value = aws_launch_template.runner + description = "EC2 launch template when the selected compute provider exposes one." + value = try(local.provider.launch_template, null) } output "role_runner" { - value = aws_iam_role.runner + description = "EC2 runner roles when the selected compute provider exposes them." + value = try(local.provider.role_runner, []) } output "lambda_scale_up" { @@ -44,10 +46,10 @@ output "role_pool" { output "runners_log_groups" { description = "List of log groups from different log files of runner machine." - value = try(aws_cloudwatch_log_group.gh_runners, []) + value = try(local.provider.runners_log_groups, []) } output "logfiles" { - value = local.logfiles + value = try(local.provider.logfiles, []) description = "List of logfiles to send to CloudWatch. Object description: `log_group_name`: Name of the log group, `file_path`: path to the log file, `log_stream_name`: name of the log stream." } diff --git a/modules/runners/policies-lambda-common.tf b/modules/runners/policies-lambda-common.tf index 0e9b2eace9..e3ce5c427f 100644 --- a/modules/runners/policies-lambda-common.tf +++ b/modules/runners/policies-lambda-common.tf @@ -9,30 +9,6 @@ data "aws_iam_policy_document" "lambda_assume_role_policy" { } } -resource "aws_iam_policy" "ami_id_ssm_parameter_read" { - count = local.ami_id_ssm_parameter_name != null ? 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.tags - policy = <<-JSON - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameter" - ], - "Resource": [ - "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${trimprefix(local.ami_id_ssm_parameter_name, "/")}" - ] - } - ] - } - JSON -} - data "aws_iam_policy_document" "lambda_xray" { count = var.tracing_config.mode != null ? 1 : 0 statement { diff --git a/modules/runners/policies/lambda-scale-down.json b/modules/runners/policies/lambda-scale-down.json index 067a747c81..dbd77b2383 100644 --- a/modules/runners/policies/lambda-scale-down.json +++ b/modules/runners/policies/lambda-scale-down.json @@ -3,65 +3,15 @@ "Statement": [ { "Effect": "Allow", - "Action": [ - "ec2:DescribeInstances", - "ec2:DescribeTags" - ], - "Resource": [ - "*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "ec2:TerminateInstances", - "ec2:CreateTags", - "ec2:DeleteTags" - ], - "Resource": [ - "*" - ], - "Condition": { - "StringEquals": { - "ec2:ResourceTag/ghr:Application": "github-action-runner" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "ec2:TerminateInstances", - "ec2:CreateTags", - "ec2:DeleteTags" - ], - "Resource": [ - "*" - ], - "Condition": { - "StringEquals": { - "ec2:ResourceTag/gh:environment": "${environment}" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameter", - "ssm:GetParameters" - ], - "Resource": [ - "${github_app_key_base64_arn}", - "${github_app_id_arn}" - ] + "Action": ["ssm:GetParameter", "ssm:GetParameters"], + "Resource": ["${github_app_key_base64_arn}", "${github_app_id_arn}"] + } %{ if kms_key_arn != "" ~} - }, - { + ,{ "Effect": "Allow", - "Action": [ - "kms:Decrypt" - ], + "Action": ["kms:Decrypt"], "Resource": "${kms_key_arn}" -%{ endif ~} } +%{ endif ~} ] } diff --git a/modules/runners/policies/lambda-scale-up.json b/modules/runners/policies/lambda-scale-up.json index 4a38d23f6a..c7a98ffa88 100644 --- a/modules/runners/policies/lambda-scale-up.json +++ b/modules/runners/policies/lambda-scale-up.json @@ -1,123 +1,31 @@ { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "ec2:DescribeInstances", - "ec2:DescribeLaunchTemplateVersions", - "ec2:DescribeTags", - "ec2:RunInstances", - "ec2:CreateFleet", - "ec2:CreateTags" - ], - "Resource": [ - "*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "ec2:TerminateInstances" - ], - "Resource": [ - "*" - ], - "Condition": { - "StringEquals": { - "ec2:ResourceTag/ghr:Application": "github-action-runner" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "ec2:TerminateInstances" - ], - "Resource": [ - "*" - ], - "Condition": { - "StringEquals": { - "ec2:ResourceTag/gh:environment": "${environment}" - } - } - }, - { - "Effect": "Allow", - "Action": "iam:PassRole", - "Resource": "${arn_runner_instance_role}" - }, - { - "Effect": "Allow", - "Action": [ - "ssm:PutParameter", - "ssm:AddTagsToResource" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameter", - "ssm:GetParameters" - ], - "Resource": [ - "${github_app_key_base64_arn}", - "${github_app_id_arn}", - "${ssm_config_path}/*", - "${ssm_ami_id_parameter_arn}" - ] - }, - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameters" - ], - "Resource": [ - "${ssm_ami_id_parameter_arn}" - ] - }, - { - "Effect": "Allow", - "Action": [ - "sqs:ReceiveMessage", - "sqs:GetQueueAttributes", - "sqs:DeleteMessage" - ], - "Resource": "${sqs_arn}" + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["ssm:PutParameter", "ssm:AddTagsToResource"], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": ["ssm:GetParameter", "ssm:GetParameters"], + "Resource": [ + "${github_app_key_base64_arn}", + "${github_app_id_arn}", + "${ssm_config_path}/*" + ] + }, + { + "Effect": "Allow", + "Action": ["sqs:ReceiveMessage", "sqs:GetQueueAttributes", "sqs:DeleteMessage"], + "Resource": "${sqs_arn}" + } %{ if kms_key_arn != "" ~} - }, - { - "Effect": "Allow", - "Action": [ - "kms:Decrypt" - ], - "Resource": "${kms_key_arn}" + ,{ + "Effect": "Allow", + "Action": ["kms:Decrypt"], + "Resource": "${kms_key_arn}" + } %{ endif ~} -%{ if ami_kms_key_arn != "" ~} - }, - { - "Effect": "Allow", - "Action": [ - "kms:DescribeKey", - "kms:ReEncrypt*", - "kms:Decrypt" - ], - "Resource": "${ami_kms_key_arn}" - }, - { - "Effect": "Allow", - "Action": [ - "kms:CreateGrant" - ], - "Resource": "${ami_kms_key_arn}", - "Condition": { - "Bool": { - "aws:ViaAWSService": "true" - } - } -%{ endif ~} - } - ] + ] } diff --git a/modules/runners/pool.tf b/modules/runners/pool.tf index 11840a4638..79c56aeae6 100644 --- a/modules/runners/pool.tf +++ b/modules/runners/pool.tf @@ -9,17 +9,10 @@ module "pool" { ssl_verify = var.ghes_ssl_verify url = var.ghes_url } - user_agent = var.user_agent - github_app_parameters = var.github_app_parameters - instance_allocation_strategy = var.instance_allocation_strategy - instance_type_priorities = var.instance_type_priorities - instance_max_spot_price = var.instance_max_spot_price - instance_target_capacity_type = var.instance_target_capacity_type - instance_types = var.instance_types - runners_maximum_count = var.runners_maximum_count - kms_key_arn = local.kms_key_arn - ami_kms_key_arn = local.ami_kms_key_arn - ami_id_ssm_parameter_arn = local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn + user_agent = var.user_agent + github_app_parameters = var.github_app_parameters + runners_maximum_count = var.runners_maximum_count + kms_key_arn = local.kms_key_arn lambda = { log_level = var.log_level logging_retention_in_days = var.logging_retention_in_days @@ -43,30 +36,28 @@ module "pool" { role_path = local.role_path role_permissions_boundary = var.role_permissions_boundary runner = { - disable_runner_autoupdate = var.disable_runner_autoupdate - ephemeral = var.enable_ephemeral_runners - enable_jit_config = var.enable_jit_config - enable_on_demand_failover_for_errors = var.enable_on_demand_failover_for_errors - scale_errors = var.scale_errors - boot_time_in_minutes = var.runner_boot_time_in_minutes - labels = var.runner_labels - launch_template = aws_launch_template.runner - group_name = var.runner_group_name - name_prefix = var.runner_name_prefix - pool_owner = var.pool_runner_owner - role = { arn = var.iam_overrides["override_runner_role"] ? var.iam_overrides["runner_role_arn"] : aws_iam_role.runner[0].arn } - use_dedicated_host = var.use_dedicated_host + disable_runner_autoupdate = var.disable_runner_autoupdate + ephemeral = var.enable_ephemeral_runners + enable_jit_config = var.enable_jit_config + labels = var.runner_labels + group_name = var.runner_group_name + name_prefix = var.runner_name_prefix + pool_owner = var.pool_runner_owner } - subnet_ids = var.subnet_ids - ssm_token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" - ssm_config_path = "${var.ssm_paths.root}/${var.ssm_paths.config}" - ami_id_ssm_parameter_name = local.ami_id_ssm_parameter_name - ami_id_ssm_parameter_read_policy_arn = local.ami_id_ssm_parameter_name != null ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null - tags = local.tags - lambda_tags = var.lambda_tags - arn_ssm_parameters_path_config = local.arn_ssm_parameters_path_config + ssm_token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" + ssm_config_path = "${var.ssm_paths.root}/${var.ssm_paths.config}" + tags = local.tags + lambda_tags = var.lambda_tags + arn_ssm_parameters_path_config = local.arn_ssm_parameters_path_config } aws_partition = var.aws_partition tracing_config = var.tracing_config + runner_provider = { + type = local.provider.type + environment_variables = local.provider.pool.environment_variables + iam_policy_json = local.provider.pool.iam_policy_json + managed_policy_enabled = local.provider.pool.managed_policy_enabled + managed_policy_arn = local.provider.pool.managed_policy_arn + } } diff --git a/modules/runners/pool/README.md b/modules/runners/pool/README.md index 24f9de5811..b865902c50 100644 --- a/modules/runners/pool/README.md +++ b/modules/runners/pool/README.md @@ -9,15 +9,15 @@ The pool is an opt-in feature. To be able to use the count on a module level to ## Requirements | Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 0.14.1 | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.21 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | ## Modules @@ -26,7 +26,7 @@ No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_cloudwatch_log_group.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | @@ -34,28 +34,30 @@ No modules. | [aws_iam_role_policy.pool_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.pool_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy_attachment.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.pool_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_lambda_function.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | | [aws_scheduler_schedule.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/scheduler_schedule) | resource | | [aws_scheduler_schedule_group.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/scheduler_schedule_group) | resource | | [aws_iam_policy_document.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.lambda_xray](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.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.scheduler_assume](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) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no | -| [config](#input\_config) | Lookup details in parent module. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
subnet_ids = list(string)
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
enable_on_demand_failover_for_errors = list(string)
scale_errors = list(string)
boot_time_in_minutes = number
labels = list(string)
launch_template = object({
name = string
})
group_name = string
name_prefix = string
pool_owner = string
role = object({
arn = string
})
use_dedicated_host = bool
})
runners_maximum_count = number
instance_types = list(string)
instance_type_priorities = optional(map(number))
instance_target_capacity_type = string
instance_allocation_strategy = string
instance_max_spot_price = string
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_arn = string
ami_kms_key_arn = string
ami_id_ssm_parameter_arn = string
role_path = string
ssm_token_path = string
ssm_config_path = string
ami_id_ssm_parameter_name = string
ami_id_ssm_parameter_read_policy_arn = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
})
| n/a | yes | +| [config](#input\_config) | Lookup details in parent module. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_arn = string
role_path = string
ssm_token_path = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
})
| n/a | yes | +| [runner\_provider](#input\_runner\_provider) | Compute provider configuration for the pool Lambda. |
object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
| n/a | yes | | [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 | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [lambda](#output\_lambda) | n/a | | [lambda\_log\_group](#output\_lambda\_log\_group) | n/a | | [role\_pool](#output\_role\_pool) | n/a | diff --git a/modules/runners/pool/main.tf b/modules/runners/pool/main.tf index e4f2485ccd..c34df8ea64 100644 --- a/modules/runners/pool/main.tf +++ b/modules/runners/pool/main.tf @@ -4,6 +4,33 @@ locals { ? "${var.config.prefix}-pool" : "${substr("${var.config.prefix}-pool", 0, 29)}-${substr(md5("${var.config.prefix}-pool"), 0, 8)}" ) + + common_environment_variables = { + DISABLE_RUNNER_AUTOUPDATE = var.config.runner.disable_runner_autoupdate + ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral + ENABLE_JIT_CONFIG = var.config.runner.enable_jit_config + ENVIRONMENT = var.config.prefix + GHES_URL = var.config.ghes.url + USER_AGENT = var.config.user_agent + LOG_LEVEL = upper(var.config.lambda.log_level) + NODE_TLS_REJECT_UNAUTHORIZED = var.config.ghes.url != null && !var.config.ghes.ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = var.config.github_app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github_app_parameters.key_base64.name + POWERTOOLS_LOGGER_LOG_EVENT = var.config.lambda.log_level == "debug" ? "true" : "false" + RUNNER_LABELS = lower(join(",", var.config.runner.labels)) + RUNNER_GROUP_NAME = var.config.runner.group_name + RUNNER_NAME_PREFIX = var.config.runner.name_prefix + RUNNER_OWNER = var.config.runner.pool_owner + RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count + SSM_TOKEN_PATH = var.config.ssm_token_path + SSM_CONFIG_PATH = var.config.ssm_config_path + POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-pool" + POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + SSM_PARAMETER_STORE_TAGS = var.config.lambda.parameter_store_tags + INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners + } } resource "aws_lambda_function" "pool" { @@ -24,44 +51,7 @@ resource "aws_lambda_function" "pool" { tags = merge(var.config.tags, var.config.lambda_tags) environment { - variables = { - AMI_ID_SSM_PARAMETER_NAME = var.config.ami_id_ssm_parameter_name - DISABLE_RUNNER_AUTOUPDATE = var.config.runner.disable_runner_autoupdate - ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral - ENABLE_JIT_CONFIG = var.config.runner.enable_jit_config - ENVIRONMENT = var.config.prefix - GHES_URL = var.config.ghes.url - USER_AGENT = var.config.user_agent - 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 = var.config.runner.launch_template.name - LOG_LEVEL = upper(var.config.lambda.log_level) - NODE_TLS_REJECT_UNAUTHORIZED = var.config.ghes.url != null && !var.config.ghes.ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = var.config.github_app_parameters.id.name - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github_app_parameters.key_base64.name - POWERTOOLS_LOGGER_LOG_EVENT = var.config.lambda.log_level == "debug" ? "true" : "false" - RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes - RUNNER_LABELS = lower(join(",", var.config.runner.labels)) - RUNNER_GROUP_NAME = var.config.runner.group_name - RUNNER_NAME_PREFIX = var.config.runner.name_prefix - RUNNER_OWNER = var.config.runner.pool_owner - RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count - SSM_TOKEN_PATH = var.config.ssm_token_path - SSM_CONFIG_PATH = var.config.ssm_config_path - SUBNET_IDS = join(",", var.config.subnet_ids) - POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-pool" - POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error - ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.config.runner.enable_on_demand_failover_for_errors) - SSM_PARAMETER_STORE_TAGS = var.config.lambda.parameter_store_tags - SCALE_ERRORS = jsonencode(var.config.runner.scale_errors) - USE_DEDICATED_HOST = var.config.runner.use_dedicated_host - INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners - } + variables = merge(var.runner_provider.environment_variables, local.common_environment_variables) } dynamic "vpc_config" { @@ -97,17 +87,21 @@ resource "aws_iam_role" "pool" { } resource "aws_iam_role_policy" "pool" { - name = "pool-policy" - role = aws_iam_role.pool.name - policy = templatefile("${path.module}/policies/lambda-pool.json", { - arn_ssm_parameters_path_config = var.config.arn_ssm_parameters_path_config - arn_runner_instance_role = var.config.runner.role.arn - github_app_id_arn = var.config.github_app_parameters.id.arn - github_app_key_base64_arn = var.config.github_app_parameters.key_base64.arn - kms_key_arn = var.config.kms_key_arn - ami_kms_key_arn = var.config.ami_kms_key_arn - ssm_ami_id_parameter_arn = var.config.ami_id_ssm_parameter_arn - }) + name = "pool-policy" + role = aws_iam_role.pool.name + policy = data.aws_iam_policy_document.pool.json +} + +data "aws_iam_policy_document" "pool" { + source_policy_documents = [ + templatefile("${path.module}/policies/lambda-pool.json", { + arn_ssm_parameters_path_config = var.config.arn_ssm_parameters_path_config + github_app_id_arn = var.config.github_app_parameters.id.arn + github_app_key_base64_arn = var.config.github_app_parameters.key_base64.arn + kms_key_arn = var.config.kms_key_arn + }), + var.runner_provider.iam_policy_json, + ] } resource "aws_iam_role_policy" "pool_logging" { @@ -135,10 +129,15 @@ data "aws_iam_policy_document" "lambda_assume_role_policy" { } } -resource "aws_iam_role_policy_attachment" "ami_id_ssm_parameter_read" { - count = var.config.ami_id_ssm_parameter_name != null ? 1 : 0 +resource "aws_iam_role_policy_attachment" "provider" { + count = var.runner_provider.managed_policy_enabled ? 1 : 0 role = aws_iam_role.pool.name - policy_arn = var.config.ami_id_ssm_parameter_read_policy_arn + policy_arn = var.runner_provider.managed_policy_arn +} + +moved { + from = aws_iam_role_policy_attachment.ami_id_ssm_parameter_read + to = aws_iam_role_policy_attachment.provider } # lambda xray policy @@ -231,7 +230,7 @@ resource "aws_scheduler_schedule" "pool" { role_arn = aws_iam_role.scheduler.arn input = jsonencode({ poolSize = each.value.size - type = "ec2" + type = var.runner_provider.type }) } } diff --git a/modules/runners/pool/policies/lambda-pool.json b/modules/runners/pool/policies/lambda-pool.json index 91c9997ce4..b44f135a64 100644 --- a/modules/runners/pool/policies/lambda-pool.json +++ b/modules/runners/pool/policies/lambda-pool.json @@ -1,24 +1,6 @@ { "Version": "2012-10-17", "Statement": [ - { - "Effect": "Allow", - "Action": [ - "ec2:DescribeInstances", - "ec2:DescribeTags", - "ec2:RunInstances", - "ec2:CreateFleet", - "ec2:CreateTags" - ], - "Resource": [ - "*" - ] - }, - { - "Effect": "Allow", - "Action": "iam:PassRole", - "Resource": "${arn_runner_instance_role}" - }, { "Effect": "Allow", "Action": [ @@ -34,18 +16,9 @@ "ssm:GetParameters", "ssm:GetParametersByPath" ], - "Resource": [ - "${arn_ssm_parameters_path_config}", - "${arn_ssm_parameters_path_config}/*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameters" - ], "Resource": [ - "${ssm_ami_id_parameter_arn}" + "${arn_ssm_parameters_path_config}", + "${arn_ssm_parameters_path_config}/*" ] }, { @@ -66,29 +39,6 @@ "kms:Decrypt" ], "Resource": "${kms_key_arn}" -%{ endif ~} -%{ if ami_kms_key_arn != "" ~} - }, - { - "Effect": "Allow", - "Action": [ - "kms:DescribeKey", - "kms:ReEncrypt*", - "kms:Decrypt" - ], - "Resource": "${ami_kms_key_arn}" - }, - { - "Effect": "Allow", - "Action": [ - "kms:CreateGrant" - ], - "Resource": "${ami_kms_key_arn}", - "Condition": { - "Bool": { - "aws:ViaAWSService": "true" - } - } %{ endif ~} } ] diff --git a/modules/runners/pool/tests/provider.tftest.hcl b/modules/runners/pool/tests/provider.tftest.hcl new file mode 100644 index 0000000000..729ff76592 --- /dev/null +++ b/modules/runners/pool/tests/provider.tftest.hcl @@ -0,0 +1,123 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"logs:CreateLogStream\",\"Resource\":\"*\"}]}" + } + } +} + +variables { + config = { + lambda = { + log_level = "info" + logging_retention_in_days = 14 + logging_kms_key_id = null + log_class = "STANDARD" + reserved_concurrent_executions = 1 + s3_bucket = "lambda-artifacts" + s3_key = "runners.zip" + s3_object_version = null + security_group_ids = [] + runtime = "nodejs24.x" + architecture = "arm64" + memory_size = 256 + timeout = 60 + zip = "runners.zip" + subnet_ids = [] + parameter_store_tags = "{}" + } + tags = { + Environment = "pool-test" + } + ghes = { + url = null + ssl_verify = true + } + github_app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } + } + runner = { + disable_runner_autoupdate = false + ephemeral = true + enable_jit_config = true + labels = ["self-hosted", "microvm"] + group_name = "default" + name_prefix = "microvm" + pool_owner = "example" + } + runners_maximum_count = 10 + prefix = "pool-test" + pool = [{ + schedule_expression = "cron(0 8 * * ? *)" + schedule_expression_timezone = "UTC" + size = 2 + }] + include_busy_runners = false + role_permissions_boundary = null + kms_key_arn = "" + role_path = "/" + ssm_token_path = "/github-runner/tokens" + ssm_config_path = "/github-runner/config" + arn_ssm_parameters_path_config = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/config" + lambda_tags = {} + user_agent = "terraform-aws-github-runner" + } + + runner_provider = { + type = "microvm" + environment_variables = { + MICROVM_CLUSTER = "runner-cluster" + } + iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["microvm:CreateRunner"] + Resource = ["*"] + }] + }) + managed_policy_enabled = true + managed_policy_arn = "arn:aws:iam::123456789012:policy/microvm-pool" + } +} + +run "provider_supplies_only_compute_specific_pool_configuration" { + command = plan + + assert { + condition = aws_lambda_function.pool.environment[0].variables["RUNNER_OWNER"] == "example" + error_message = "The pool module must continue to assemble common runner environment variables." + } + + assert { + condition = aws_lambda_function.pool.environment[0].variables["MICROVM_CLUSTER"] == "runner-cluster" + error_message = "The pool module must merge compute-provider environment variables into the Lambda environment." + } + + assert { + condition = !contains(keys(aws_lambda_function.pool.environment[0].variables), "AMI_ID_SSM_PARAMETER_NAME") + error_message = "The common pool module must not add EC2-specific environment variables." + } + + assert { + condition = jsondecode(aws_scheduler_schedule.pool["0"].target[0].input).type == "microvm" + error_message = "The pool scheduler payload must select the configured compute provider." + } + + assert { + condition = length(data.aws_iam_policy_document.pool.source_policy_documents) == 2 + error_message = "The pool role policy must merge the common and compute-provider policy documents." + } + + assert { + condition = length(aws_iam_role_policy_attachment.provider) == 1 + error_message = "The optional compute-provider managed policy must be attached to the pool role." + } +} diff --git a/modules/runners/pool/variables.tf b/modules/runners/pool/variables.tf index adf5ad571c..833fdb4956 100644 --- a/modules/runners/pool/variables.tf +++ b/modules/runners/pool/variables.tf @@ -28,54 +28,60 @@ variable "config" { key_base64 = map(string) id = map(string) }) - subnet_ids = list(string) runner = object({ - disable_runner_autoupdate = bool - ephemeral = bool - enable_jit_config = bool - enable_on_demand_failover_for_errors = list(string) - scale_errors = list(string) - boot_time_in_minutes = number - labels = list(string) - launch_template = object({ - name = string - }) - group_name = string - name_prefix = string - pool_owner = string - role = object({ - arn = string - }) - use_dedicated_host = bool + disable_runner_autoupdate = bool + ephemeral = bool + enable_jit_config = bool + labels = list(string) + group_name = string + name_prefix = string + pool_owner = string }) - runners_maximum_count = number - instance_types = list(string) - instance_type_priorities = optional(map(number)) - instance_target_capacity_type = string - instance_allocation_strategy = string - instance_max_spot_price = string - prefix = string + runners_maximum_count = number + prefix = string pool = list(object({ schedule_expression = string schedule_expression_timezone = string size = number })) - include_busy_runners = bool - role_permissions_boundary = string - kms_key_arn = string - ami_kms_key_arn = string - ami_id_ssm_parameter_arn = string - role_path = string - ssm_token_path = string - ssm_config_path = string - ami_id_ssm_parameter_name = string - ami_id_ssm_parameter_read_policy_arn = string - arn_ssm_parameters_path_config = string - lambda_tags = map(string) - user_agent = string + include_busy_runners = bool + role_permissions_boundary = string + kms_key_arn = string + role_path = string + ssm_token_path = string + ssm_config_path = string + arn_ssm_parameters_path_config = string + lambda_tags = map(string) + user_agent = string }) } +variable "runner_provider" { + description = "Compute provider configuration for the pool Lambda." + type = object({ + type = string + environment_variables = map(string) + iam_policy_json = string + managed_policy_enabled = bool + managed_policy_arn = optional(string, null) + }) + + validation { + condition = trimspace(var.runner_provider.type) != "" + error_message = "The compute provider type must not be empty." + } + + validation { + condition = can(jsondecode(var.runner_provider.iam_policy_json)) + error_message = "The compute provider IAM policy must be valid JSON." + } + + validation { + condition = !var.runner_provider.managed_policy_enabled || var.runner_provider.managed_policy_arn != null + error_message = "The compute provider managed policy ARN must be set when its attachment is enabled." + } +} + variable "aws_partition" { description = "(optional) partition for the arn if not 'aws'" type = string @@ -91,5 +97,3 @@ variable "tracing_config" { }) default = {} } - - diff --git a/modules/runners/pool/versions.tf b/modules/runners/pool/versions.tf index bceee0424e..42a40b33fd 100644 --- a/modules/runners/pool/versions.tf +++ b/modules/runners/pool/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 0.14.1" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/runners/providers/ec2/README.md b/modules/runners/providers/ec2/README.md index 12790173cc..590a5ff212 100644 --- a/modules/runners/providers/ec2/README.md +++ b/modules/runners/providers/ec2/README.md @@ -1,90 +1,125 @@ -# EC2 multi-runner provider adapter +# EC2 runner provider -This internal provider module lives with the existing EC2 runner implementation and adapts normalized multi-runner lanes into per-lane runner module calls, including launch templates, scaling Lambdas, pools, and EC2-specific IAM policies. +This internal module owns the EC2 compute implementation used by the common `modules/runners` control plane. It creates the runner launch template, security group, instance role and profile, EC2 bootstrap parameters, and runner log groups. -Provider-neutral lane normalization, SQS queues, shared runner-binary discovery, and webhook routing remain in the parent `multi-runner` module. Lane map keys pass through unchanged so Terraform `for_each` identities and the public `runners_map` keys remain stable. +The module returns a provider contract containing only the EC2-specific environment variables and IAM policy fragments required by scale-up, scale-down, and pool. The parent module owns the Lambda functions, execution roles, schedules, queues, retry flow, and SSM housekeeper. -EC2 is currently the only Terraform-managed provider. microVM, CodeBuild, and other providers require separate provider modules and are intentionally not instantiated here. +EC2 is the only active compute provider. A future provider must implement the same contract before it can be selected by `provider_type`. ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers -No providers. +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | ## Modules -| Name | Source | Version | -|------|--------|---------| -| [runners](#module\_runners) | ../.. | n/a | +No modules. ## Resources -No 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_iam_role.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.create_tag](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.describe_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.dist_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ec2](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.runner_session_manager_aws_managed](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ssm_parameters](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.managed_policies](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.xray_tracing](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | 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 | +| [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 | ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Whether EC2 runners receive a public IPv4 address. | `bool` | `false` | no | -| [aws\_partition](#input\_aws\_partition) | AWS partition used in resource ARNs. | `string` | `"aws"` | no | -| [aws\_region](#input\_aws\_region) | AWS region for EC2 runner resources. | `string` | n/a | yes | -| [cloudwatch\_config](#input\_cloudwatch\_config) | Default CloudWatch agent configuration for EC2 runners. | `string` | `null` | no | -| [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Whether to create the managed security group for EC2 runners. | `bool` | `true` | no | -| [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | Whether EC2 runner control-plane functions verify GHES TLS certificates. | `bool` | `true` | no | -| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL used by EC2 runner control-plane functions. | `string` | `null` | no | -| [github\_app\_parameters](#input\_github\_app\_parameters) | SSM parameter references for the GitHub App credentials. |
object({
key_base64 = map(string)
id = map(string)
})
| n/a | yes | -| [instance\_profile\_path](#input\_instance\_profile\_path) | IAM path for EC2 runner instance profiles. | `string` | `null` | no | -| [key\_name](#input\_key\_name) | EC2 key pair name for runner instances. | `string` | `null` | no | -| [kms\_key\_arn](#input\_kms\_key\_arn) | KMS key ARN used for EC2 lane Parameter Store values. | `string` | `null` | no | -| [lambda\_architecture](#input\_lambda\_architecture) | Architecture used by EC2 control-plane Lambda functions. | `string` | `"arm64"` | no | -| [lambda\_event\_source\_mapping\_batch\_size](#input\_lambda\_event\_source\_mapping\_batch\_size) | Default SQS event-source batch size for EC2 scale-up Lambdas. | `number` | `10` | no | -| [lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds](#input\_lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds) | Default SQS event-source batching window for EC2 scale-up Lambdas. | `number` | `0` | no | -| [lambda\_runtime](#input\_lambda\_runtime) | Runtime used by EC2 control-plane Lambda functions. | `string` | `"nodejs24.x"` | no | -| [lambda\_s3\_bucket](#input\_lambda\_s3\_bucket) | S3 bucket containing the runner Lambda package. | `string` | `null` | no | -| [lambda\_security\_group\_ids](#input\_lambda\_security\_group\_ids) | Security group IDs for EC2 control-plane Lambda functions. | `list(string)` | `[]` | no | -| [lambda\_subnet\_ids](#input\_lambda\_subnet\_ids) | Subnet IDs for EC2 control-plane Lambda functions. | `list(string)` | `[]` | no | -| [lambda\_tags](#input\_lambda\_tags) | Additional tags for EC2 control-plane Lambda functions. | `map(string)` | `{}` | no | -| [lanes](#input\_lanes) | Resolved EC2 runner lanes keyed by the stable multi-runner lane name. |
map(object({
runner = object({
runner_os = string
runner_architecture = string
disable_runner_autoupdate = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_jit_config = optional(bool, null)
enable_organization_runners = optional(bool, false)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_iam_role_managed_policy_arns = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
})

provider = object({
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), 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
}])
cloudwatch_config = optional(string, null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
enable_runner_binaries_syncer = optional(bool, true)
enable_runner_detailed_monitoring = optional(bool, false)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
runner_additional_security_group_ids = optional(list(string), [])
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
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
})), [])
use_dedicated_host = optional(bool, false)
runner_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)
runner_ec2_tags = optional(map(string), {})
runner_hook_job_completed = optional(string, "")
runner_hook_job_started = optional(string, "")
userdata_content = optional(string, null)
userdata_post_install = optional(string, "")
userdata_pre_install = optional(string, "")
userdata_template = optional(string, null)
})

queue = object({
arn = string
url = string
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
})
}))
| n/a | yes | -| [log\_class](#input\_log\_class) | CloudWatch log class for EC2 runner log groups. | `string` | `"STANDARD"` | no | -| [log\_level](#input\_log\_level) | Log level for EC2 runner control-plane Lambda functions. | `string` | `"info"` | no | -| [logging\_kms\_key\_id](#input\_logging\_kms\_key\_id) | KMS key ID used to encrypt EC2 runner CloudWatch log groups. | `string` | `null` | no | -| [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | CloudWatch log retention for EC2 runner control-plane functions. | `number` | `180` | no | -| [metrics](#input\_metrics) | Metrics configuration for EC2 runner control-plane functions. |
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 | -| [parameter\_store\_tags](#input\_parameter\_store\_tags) | Tags for EC2 lane SSM parameters. | `map(string)` | `{}` | no | -| [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Reserved concurrency for EC2 pool Lambda functions. | `number` | `1` | no | -| [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Timeout for EC2 pool Lambda functions. | `number` | `60` | no | -| [prefix](#input\_prefix) | Prefix used for EC2 lane resource names. | `string` | n/a | yes | -| [role\_path](#input\_role\_path) | IAM path for EC2 runner roles. | `string` | `null` | no | -| [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary for EC2 runner IAM roles. | `string` | `null` | no | -| [runner\_additional\_security\_group\_ids](#input\_runner\_additional\_security\_group\_ids) | Default additional security group IDs for EC2 runners. | `list(string)` | `[]` | no | -| [runner\_binaries](#input\_runner\_binaries) | Runner binary artifacts keyed by operating system and architecture. |
map(object({
arn = string
id = string
key = string
}))
| `{}` | no | -| [runner\_egress\_rules](#input\_runner\_egress\_rules) | Egress rules for the managed EC2 runner security group. |
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"
],
"description": null,
"from_port": 0,
"ipv6_cidr_blocks": [
"::/0"
],
"prefix_list_ids": null,
"protocol": "-1",
"security_groups": null,
"self": null,
"to_port": 0
}
]
| no | -| [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for the runner Lambda package. | `string` | `null` | no | -| [runners\_lambda\_s3\_object\_version](#input\_runners\_lambda\_s3\_object\_version) | S3 object version for the runner Lambda package. | `string` | `null` | no | -| [runners\_lambda\_zip](#input\_runners\_lambda\_zip) | Local path to the runner Lambda package. | `string` | `null` | no | -| [runners\_scale\_down\_lambda\_timeout](#input\_runners\_scale\_down\_lambda\_timeout) | Timeout for the EC2 scale-down Lambda function. | `number` | `60` | no | -| [runners\_scale\_up\_lambda\_timeout](#input\_runners\_scale\_up\_lambda\_timeout) | Timeout for the EC2 scale-up Lambda function. | `number` | `30` | no | -| [runners\_ssm\_housekeeper](#input\_runners\_ssm\_housekeeper) | Configuration for the EC2 runner token and JIT-config SSM housekeeper. |
object({
schedule_expression = optional(string, "rate(1 day)")
enabled = optional(bool, true)
lambda_memory_size = optional(number, 512)
lambda_timeout = optional(number, 60)
config = object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
})
})
|
{
"config": {}
}
| no | -| [scale\_down\_lambda\_memory\_size](#input\_scale\_down\_lambda\_memory\_size) | Memory size for the EC2 scale-down Lambda function. | `number` | `512` | no | -| [scale\_up\_lambda\_memory\_size](#input\_scale\_up\_lambda\_memory\_size) | Memory size for the EC2 scale-up Lambda function. | `number` | `512` | no | -| [ssm\_paths](#input\_ssm\_paths) | Shared SSM path names used by EC2 runner lanes. |
object({
runners = string
})
| n/a | yes | -| [ssm\_root\_path](#input\_ssm\_root\_path) | Shared SSM root path beneath which EC2 lane paths are created. | `string` | n/a | yes | -| [subnet\_ids](#input\_subnet\_ids) | Default subnet IDs for EC2 runner resources. | `list(string)` | n/a | yes | -| [tags](#input\_tags) | Tags shared by all EC2 lane resources. | `map(string)` | `{}` | no | -| [tracing\_config](#input\_tracing\_config) | Tracing configuration for EC2 control-plane Lambda functions. |
object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
})
| `{}` | no | -| [user\_agent](#input\_user\_agent) | User agent used by EC2 runner control-plane GitHub API calls. | `string` | `"github-aws-runners"` | no | -| [vpc\_id](#input\_vpc\_id) | Default VPC for EC2 runner resources. | `string` | n/a | yes | +| ---- | ----------- | ---- | ------- | :------: | +| [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | +| [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | +| [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | +| [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | +| [block\_device\_mappings](#input\_block\_device\_mappings) | The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`. |
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
}
]
| no | +| [cloudwatch\_config](#input\_cloudwatch\_config) | (optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details. | `string` | `null` | no | +| [cpu\_options](#input\_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 | +| [create\_service\_linked\_role\_spot](#input\_create\_service\_linked\_role\_spot) | (optional) create the service linked role for spot instances that is required by the scale-up lambda. | `bool` | `false` | no | +| [credit\_specification](#input\_credit\_specification) | The credit option for CPU usage of a T instance. Can be unset, "standard" or "unlimited". | `string` | `null` | no | +| [ebs\_optimized](#input\_ebs\_optimized) | The EC2 EBS optimized configuration. | `bool` | `false` | no | +| [egress\_rules](#input\_egress\_rules) | List of egress rules for the GitHub runner instances. |
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"
],
"description": null,
"from_port": 0,
"ipv6_cidr_blocks": [
"::/0"
],
"prefix_list_ids": null,
"protocol": "-1",
"security_groups": null,
"self": null,
"to_port": 0
}
]
| no | +| [enable\_cloudwatch\_agent](#input\_enable\_cloudwatch\_agent) | Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`. | `bool` | `true` | no | +| [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | +| [enable\_on\_demand\_failover\_for\_errors](#input\_enable\_on\_demand\_failover\_for\_errors) | Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later. | `list(string)` | `[]` | no | +| [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI. | `bool` | `true` | no | +| [enable\_runner\_detailed\_monitoring](#input\_enable\_runner\_detailed\_monitoring) | Enable detailed monitoring for runners | `bool` | `false` | no | +| [enable\_ssm\_on\_runners](#input\_enable\_ssm\_on\_runners) | Enable to allow access to the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances. | `bool` | n/a | yes | +| [enable\_user\_data\_debug\_logging](#input\_enable\_user\_data\_debug\_logging) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no | +| [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI | `bool` | `true` | no | +| [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | +| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | +| [iam\_overrides](#input\_iam\_overrides) | This map provides the possibility to override some IAM defaults. The following attributes are supported: `instance_profile_name` overrides the instance profile name used in the launch template. `runner_role_arn` overrides the IAM role ARN used for the runner instances. |
object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
})
|
{
"instance_profile_name": null,
"override_instance_profile": false,
"override_runner_role": false,
"runner_role_arn": null
}
| no | +| [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | +| [instance\_max\_spot\_price](#input\_instance\_max\_spot\_price) | Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet. | `string` | `null` | no | +| [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the prefix will be used. | `string` | `null` | no | +| [instance\_target\_capacity\_type](#input\_instance\_target\_capacity\_type) | Default lifecycle used runner instances, can be either `spot` or `on-demand`. | `string` | `"spot"` | no | +| [instance\_type\_priorities](#input\_instance\_type\_priorities) | A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`. | `map(number)` | `null` | 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)` | `null` | no | +| [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no | +| [license\_specifications](#input\_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 | +| [logging\_kms\_key\_id](#input\_logging\_kms\_key\_id) | Specifies the kms key id to encrypt the logs with | `string` | `null` | no | +| [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 | +| [metadata\_options](#input\_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 | +| [overrides](#input\_overrides) | This map provides the possibility to override some defaults. The following attributes are supported: `name_sg` overrides the `Name` tag for all security groups created by this module. `name_runner_agent_instance` overrides the `Name` tag for the ec2 instance defined in the auto launch configuration. `name_docker_machine_runners` overrides the `Name` tag spot instances created by the runner agent. | `map(string)` |
{
"name_runner": "",
"name_sg": ""
}
| no | +| [placement](#input\_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 | +| [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no | +| [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the prefix will be used. | `string` | `null` | no | +| [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | +| [runner\_additional\_security\_group\_ids](#input\_runner\_additional\_security\_group\_ids) | (optional) List of additional security groups IDs to apply to the runner | `list(string)` | `[]` | no | +| [runner\_architecture](#input\_runner\_architecture) | The platform architecture of the runner instance\_type. | `string` | `"x64"` | no | +| [runner\_as\_root](#input\_runner\_as\_root) | Run the action runner under the root user. Variable `runner_run_as` will be ignored. | `bool` | `false` | 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\_ec2\_tags](#input\_runner\_ec2\_tags) | Map of tags that will be added to the launch template instance tag specifications. | `map(string)` | `{}` | no | +| [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\_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\_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, osx, windows). | `string` | `"linux"` | no | +| [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | +| [s3\_runner\_binaries](#input\_s3\_runner\_binaries) | Bucket details for cached GitHub binary. |
object({
arn = string
id = string
key = string
})
| n/a | yes | +| [scale\_errors](#input\_scale\_errors) | List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors | `list(string)` |
[
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]
| no | +| [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
tokens = string
config = string
})
| n/a | yes | +| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes | +| [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name. | `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) | Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly. | `bool` | `false` | 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) | User-data script snippet to insert after GitHub action runner install | `string` | `""` | no | +| [userdata\_pre\_install](#input\_userdata\_pre\_install) | User-data script snippet to insert before GitHub action runner install | `string` | `""` | no | +| [userdata\_template](#input\_userdata\_template) | Alternative user-data template file path, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored. | `string` | `null` | no | +| [vpc\_id](#input\_vpc\_id) | The VPC for the security groups. | `string` | n/a | yes | ## Outputs | Name | Description | -|------|-------------| -| [runners\_map](#output\_runners\_map) | EC2 runner resources keyed by the stable multi-runner lane name. | +| ---- | ----------- | +| [provider](#output\_provider) | EC2 resources and control-plane fragments consumed by the common runners module. | diff --git a/modules/runners/providers/ec2/compute.tf b/modules/runners/providers/ec2/compute.tf new file mode 100644 index 0000000000..b7d53c0f71 --- /dev/null +++ b/modules/runners/providers/ec2/compute.tf @@ -0,0 +1,337 @@ +# EC2 compute implementation selected by the parent runners module. +locals { + tags = merge( + { + "Name" = format("%s-action-runner", var.prefix) + }, + { + "ghr:ssm_config_path" = "${var.ssm_paths.root}/${var.ssm_paths.config}" + }, + var.tags, + ) + + name_sg = var.overrides["name_sg"] == "" ? local.tags["Name"] : var.overrides["name_sg"] + name_runner = var.overrides["name_runner"] == "" ? local.tags["Name"] : var.overrides["name_runner"] + role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path + instance_profile_path = var.instance_profile_path == null ? "/${var.prefix}/" : var.instance_profile_path + userdata_template = var.userdata_template == null ? local.default_userdata_template[var.runner_os] : var.userdata_template + s3_location_runner_distribution = var.enable_runner_binaries_syncer ? "s3://${var.s3_runner_binaries.id}/${var.s3_runner_binaries.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.ami != null ? var.ami : { + filter = local.default_ami[var.runner_os] + owners = ["amazon"] + id_ssm_parameter_arn = null + kms_key_arn = null + } + ami_kms_key_arn = local.ami_config.kms_key_arn != null ? local.ami_config.kms_key_arn : "" + ami_filter = merge(local.default_ami[var.runner_os], local.ami_config.filter) + ami_id_ssm_module_managed = 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_module_managed ? null : try(regex("parameter(/.+)$", local.ami_config.id_ssm_parameter_arn)[0], null) + + arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" + + user_data = var.enable_userdata ? (var.userdata_content == null ? templatefile(local.userdata_template, { + enable_debug_logging = var.enable_user_data_debug_logging + s3_location_runner_distribution = local.s3_location_runner_distribution + pre_install = var.userdata_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.userdata_post_install + hook_job_started = var.runner_hook_job_started + hook_job_completed = var.runner_hook_job_completed + start_runner = templatefile(local.userdata_start_runner[var.runner_os], { + metadata_tags = var.metadata_options != null ? var.metadata_options.instance_metadata_tags : "enabled" + }) + ghes_url = var.ghes_url + ghes_ssl_verify = var.ghes_ssl_verify + + ## retain these for backwards compatibility + environment = var.prefix + enable_cloudwatch_agent = var.enable_cloudwatch_agent + ssm_key_cloudwatch_agent_config = var.enable_cloudwatch_agent ? aws_ssm_parameter.cloudwatch_agent_config_runner[0].name : "" + }) : var.userdata_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" { + 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.id + + tags = merge( + local.tags, + { + # Remove parentheses from AMI name to comply with AWS tag constraints + "ghr:ami_name" = replace(data.aws_ami.runner.name, "/[()]/", "") + }, + { + "ghr:ami_creation_date" = data.aws_ami.runner.creation_date + }, + { + "ghr:ami_deprecation_time" = data.aws_ami.runner.deprecation_time + } + ) +} + +resource "aws_launch_template" "runner" { + name = "${var.prefix}-action-runner" + + dynamic "block_device_mappings" { + for_each = var.block_device_mappings != null ? var.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.metadata_options != null ? [var.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.metadata_options != null ? [] : [0] + + content { + instance_metadata_tags = "enabled" + } + } + + dynamic "credit_specification" { + for_each = var.credit_specification != null ? [var.credit_specification] : [] + content { + cpu_credits = credit_specification.value + } + } + + dynamic "cpu_options" { + for_each = var.cpu_options != null ? [var.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.placement != null ? [var.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.license_specifications + content { + license_configuration_arn = license_specification.value.license_configuration_arn + } + } + + monitoring { + enabled = var.enable_runner_detailed_monitoring + } + + iam_instance_profile { + name = var.iam_overrides["override_instance_profile"] ? var.iam_overrides["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 : var.ami.id_ssm_parameter_arn}" + key_name = var.key_name + ebs_optimized = var.ebs_optimized + + vpc_security_group_ids = !var.associate_public_ipv4_address ? compact(concat( + var.enable_managed_runner_security_group ? [aws_security_group.runner_sg[0].id] : [], + var.runner_additional_security_group_ids, + )) : [] + + tag_specifications { + resource_type = "instance" + tags = merge( + local.tags, + { + "Name" = format("%s", local.name_runner) + }, + { + "ghr:runner_name_prefix" = var.runner_name_prefix + }, + var.runner_ec2_tags + ) + } + + tag_specifications { + resource_type = "volume" + tags = merge( + local.tags, + { + "Name" = format("%s", local.name_runner) + }, + { + "ghr:runner_name_prefix" = var.runner_name_prefix + }, + var.runner_ec2_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.instance_target_capacity_type == "spot" && length(var.enable_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 = merge( + local.tags, + { + "Name" = format("%s", local.name_runner) + }, + { + "ghr:runner_name_prefix" = var.runner_name_prefix + }, + var.runner_ec2_tags + ) + } + } + + tag_specifications { + resource_type = "network-interface" + tags = merge( + local.tags, + { + "Name" = format("%s", local.name_runner) + }, + { + "ghr:runner_name_prefix" = var.runner_name_prefix + }, + var.runner_ec2_tags + ) + } + + user_data = local.encoded_user_data + + tags = local.tags + + update_default_version = true + + dynamic "network_interfaces" { + for_each = var.associate_public_ipv4_address ? [var.associate_public_ipv4_address] : [] + iterator = associate_public_ipv4_address + content { + associate_public_ip_address = associate_public_ipv4_address.value + security_groups = compact(concat( + var.enable_managed_runner_security_group ? [aws_security_group.runner_sg[0].id] : [], + var.runner_additional_security_group_ids, + )) + } + } +} + +resource "aws_security_group" "runner_sg" { + count = var.enable_managed_runner_security_group ? 1 : 0 + name_prefix = "${var.prefix}-github-actions-runner-sg" + description = "Github Actions Runner security group" + + vpc_id = var.vpc_id + + ingress = [] + + dynamic "egress" { + for_each = var.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.tags, + { + "Name" = format("%s", local.name_sg) + }, + ) +} diff --git a/modules/runners/providers/ec2/contracts.tf b/modules/runners/providers/ec2/contracts.tf new file mode 100644 index 0000000000..5779e721a4 --- /dev/null +++ b/modules/runners/providers/ec2/contracts.tf @@ -0,0 +1,70 @@ +resource "aws_iam_policy" "ami_id_ssm_parameter_read" { + count = local.ami_id_ssm_parameter_name != null ? 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.tags + policy = <<-JSON + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameter" + ], + "Resource": [ + "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${trimprefix(local.ami_id_ssm_parameter_name, "/")}" + ] + } + ] + } + JSON +} + +locals { + scale_up_environment_variables = { + AMI_ID_SSM_PARAMETER_NAME = local.ami_id_ssm_parameter_name + INSTANCE_ALLOCATION_STRATEGY = var.instance_allocation_strategy + INSTANCE_MAX_SPOT_PRICE = var.instance_max_spot_price + INSTANCE_TARGET_CAPACITY_TYPE = var.instance_target_capacity_type + INSTANCE_TYPE_PRIORITIES = var.instance_type_priorities != null ? jsonencode(var.instance_type_priorities) : "" + INSTANCE_TYPES = join(",", var.instance_types) + LAUNCH_TEMPLATE_NAME = aws_launch_template.runner.name + SUBNET_IDS = join(",", var.subnet_ids) + ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.enable_on_demand_failover_for_errors) + SCALE_ERRORS = jsonencode(var.scale_errors) + USE_DEDICATED_HOST = var.use_dedicated_host + } + + scale_down_environment_variables = { + RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes + } + + pool_environment_variables = merge(local.scale_up_environment_variables, { + RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes + }) + + runner_role_arn = var.iam_overrides["override_runner_role"] ? var.iam_overrides["runner_role_arn"] : aws_iam_role.runner[0].arn + + scale_up_iam_policy_json = templatefile("${path.module}/policies/lambda-scale-up.json", { + arn_runner_instance_role = local.runner_role_arn + environment = var.prefix + ami_kms_key_arn = local.ami_kms_key_arn + ssm_ami_id_parameter_arn = local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn + }) + + scale_down_iam_policy_json = templatefile("${path.module}/policies/lambda-scale-down.json", { + environment = var.prefix + }) + + pool_iam_policy_json = templatefile("${path.module}/policies/lambda-pool.json", { + arn_runner_instance_role = local.runner_role_arn + ami_kms_key_arn = local.ami_kms_key_arn + ssm_ami_id_parameter_arn = local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn + }) + + service_linked_role_policy_json = var.create_service_linked_role_spot ? templatefile("${path.module}/policies/service-linked-role-create-policy.json", { + aws_partition = var.aws_partition + }) : null +} diff --git a/modules/runners/logging.tf b/modules/runners/providers/ec2/logging.tf similarity index 98% rename from modules/runners/logging.tf rename to modules/runners/providers/ec2/logging.tf index 71fd3f274e..e7ad772187 100644 --- a/modules/runners/logging.tf +++ b/modules/runners/providers/ec2/logging.tf @@ -1,3 +1,4 @@ +# EC2 runner log collection and CloudWatch resources. locals { runner_log_files = ( var.runner_log_files != null diff --git a/modules/runners/providers/ec2/main.tf b/modules/runners/providers/ec2/main.tf deleted file mode 100644 index a5fbae0b41..0000000000 --- a/modules/runners/providers/ec2/main.tf +++ /dev/null @@ -1,131 +0,0 @@ -module "runners" { - source = "../.." - for_each = var.lanes - - aws_region = var.aws_region - aws_partition = var.aws_partition - vpc_id = coalesce(each.value.provider.vpc_id, var.vpc_id) - subnet_ids = coalesce(each.value.provider.subnet_ids, var.subnet_ids) - prefix = "${var.prefix}-${each.key}" - tags = merge(var.tags, { - "ghr:environment" = "${var.prefix}-${each.key}" - }) - - s3_runner_binaries = each.value.provider.enable_runner_binaries_syncer ? var.runner_binaries["${each.value.runner.runner_os}_${each.value.runner.runner_architecture}"] : null - - ssm_paths = { - root = "${var.ssm_root_path}/${each.key}" - tokens = "${var.ssm_paths.runners}/tokens" - config = "${var.ssm_paths.runners}/config" - } - - runner_os = each.value.runner.runner_os - instance_types = each.value.provider.instance_types - instance_target_capacity_type = each.value.provider.instance_target_capacity_type - instance_allocation_strategy = each.value.provider.instance_allocation_strategy - instance_type_priorities = each.value.provider.instance_type_priorities - instance_max_spot_price = each.value.provider.instance_max_spot_price - block_device_mappings = each.value.provider.block_device_mappings - - runner_architecture = each.value.runner.runner_architecture - ami = each.value.provider.ami - - sqs_build_queue = { "arn" : each.value.queue.arn, "url" : each.value.queue.url } - github_app_parameters = var.github_app_parameters - ebs_optimized = each.value.provider.ebs_optimized - enable_on_demand_failover_for_errors = each.value.provider.enable_on_demand_failover_for_errors - scale_errors = each.value.provider.scale_errors - enable_organization_runners = each.value.runner.enable_organization_runners - enable_ephemeral_runners = each.value.runner.enable_ephemeral_runners - enable_jit_config = each.value.runner.enable_jit_config - enable_job_queued_check = each.value.runner.enable_job_queued_check - disable_runner_autoupdate = each.value.runner.disable_runner_autoupdate - enable_managed_runner_security_group = var.enable_managed_runner_security_group - enable_runner_detailed_monitoring = each.value.provider.enable_runner_detailed_monitoring - scale_down_schedule_expression = each.value.runner.scale_down_schedule_expression - minimum_running_time_in_minutes = each.value.runner.minimum_running_time_in_minutes - runner_boot_time_in_minutes = each.value.runner.runner_boot_time_in_minutes - runner_disable_default_labels = each.value.runner.runner_disable_default_labels - runner_labels = each.value.runner.runner_disable_default_labels ? sort(distinct(each.value.runner.runner_extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.runner_os, each.value.runner.runner_architecture], each.value.runner.runner_extra_labels))) - runner_as_root = each.value.runner.runner_as_root - runner_run_as = each.value.runner.runner_run_as - runners_maximum_count = each.value.runner.runners_maximum_count - idle_config = each.value.provider.idle_config - enable_ssm_on_runners = each.value.provider.enable_ssm_on_runners - egress_rules = var.runner_egress_rules - runner_additional_security_group_ids = try(coalescelist(each.value.provider.runner_additional_security_group_ids, var.runner_additional_security_group_ids), []) - metadata_options = each.value.provider.runner_metadata_options - credit_specification = each.value.provider.credit_specification - cpu_options = each.value.provider.cpu_options - placement = each.value.provider.placement - license_specifications = each.value.provider.license_specifications - use_dedicated_host = each.value.provider.use_dedicated_host - - enable_runner_binaries_syncer = each.value.provider.enable_runner_binaries_syncer - lambda_s3_bucket = var.lambda_s3_bucket - runners_lambda_s3_key = var.runners_lambda_s3_key - runners_lambda_s3_object_version = var.runners_lambda_s3_object_version - lambda_runtime = var.lambda_runtime - lambda_architecture = var.lambda_architecture - lambda_zip = var.runners_lambda_zip - lambda_scale_up_memory_size = var.scale_up_lambda_memory_size - lambda_event_source_mapping_batch_size = coalesce(each.value.queue.lambda_event_source_mapping_batch_size, var.lambda_event_source_mapping_batch_size) - lambda_event_source_mapping_maximum_batching_window_in_seconds = coalesce(each.value.queue.lambda_event_source_mapping_maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) - lambda_timeout_scale_up = var.runners_scale_up_lambda_timeout - lambda_scale_down_memory_size = var.scale_down_lambda_memory_size - lambda_timeout_scale_down = var.runners_scale_down_lambda_timeout - lambda_subnet_ids = var.lambda_subnet_ids - lambda_security_group_ids = var.lambda_security_group_ids - lambda_tags = var.lambda_tags - tracing_config = var.tracing_config - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id - log_class = var.log_class - enable_cloudwatch_agent = each.value.provider.enable_cloudwatch_agent - cloudwatch_config = try(coalesce(each.value.provider.cloudwatch_config, var.cloudwatch_config), null) - runner_log_files = each.value.provider.runner_log_files - runner_group_name = each.value.runner.runner_group_name - runner_name_prefix = each.value.runner.runner_name_prefix - parameter_store_tags = var.parameter_store_tags - - scale_up_reserved_concurrent_executions = each.value.runner.scale_up_reserved_concurrent_executions - - instance_profile_path = var.instance_profile_path - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary - - enable_userdata = each.value.provider.enable_userdata - userdata_template = each.value.provider.userdata_template - userdata_content = each.value.provider.userdata_content - userdata_pre_install = each.value.provider.userdata_pre_install - userdata_post_install = each.value.provider.userdata_post_install - runner_hook_job_started = each.value.provider.runner_hook_job_started - runner_hook_job_completed = each.value.provider.runner_hook_job_completed - key_name = var.key_name - runner_ec2_tags = each.value.provider.runner_ec2_tags - - create_service_linked_role_spot = each.value.provider.create_service_linked_role_spot - - runner_iam_role_managed_policy_arns = each.value.runner.runner_iam_role_managed_policy_arns - iam_overrides = each.value.runner.iam_overrides - - ghes_url = var.ghes_url - ghes_ssl_verify = var.ghes_ssl_verify - user_agent = var.user_agent - - kms_key_arn = var.kms_key_arn - - log_level = var.log_level - - pool_config = each.value.runner.pool_config - pool_lambda_timeout = var.pool_lambda_timeout - pool_runner_owner = each.value.runner.pool_runner_owner - pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions - associate_public_ipv4_address = var.associate_public_ipv4_address - - ssm_housekeeper = var.runners_ssm_housekeeper - - job_retry = each.value.runner.job_retry - - metrics = var.metrics -} diff --git a/modules/runners/providers/ec2/outputs.tf b/modules/runners/providers/ec2/outputs.tf index 40d811b740..79816a6dde 100644 --- a/modules/runners/providers/ec2/outputs.tf +++ b/modules/runners/providers/ec2/outputs.tf @@ -1,22 +1,27 @@ -output "runners_map" { - description = "EC2 runner resources keyed by the stable multi-runner lane name." - value = { for runner_key, runner in module.runners : runner_key => { - launch_template_name = runner.launch_template.name - launch_template_id = runner.launch_template.id - launch_template_version = runner.launch_template.latest_version - launch_template_ami_id = runner.launch_template.image_id - lambda_up = runner.lambda_scale_up - lambda_up_log_group = runner.lambda_scale_up_log_group - lambda_down = runner.lambda_scale_down - lambda_down_log_group = runner.lambda_scale_down_log_group - lambda_pool = runner.lambda_pool - lambda_pool_log_group = runner.lambda_pool_log_group - role_runner = runner.role_runner - role_scale_up = runner.role_scale_up - role_scale_down = runner.role_scale_down - role_pool = runner.role_pool - runners_log_groups = runner.runners_log_groups - logfiles = runner.logfiles +output "provider" { + description = "EC2 resources and control-plane fragments consumed by the common runners module." + value = { + type = "ec2" + scale_up = { + environment_variables = local.scale_up_environment_variables + 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_parameter_name != null + managed_policy_arn = local.ami_id_ssm_parameter_name != null ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null } + scale_down = { + environment_variables = local.scale_down_environment_variables + iam_policy_json = local.scale_down_iam_policy_json + } + pool = { + environment_variables = local.pool_environment_variables + iam_policy_json = local.pool_iam_policy_json + managed_policy_enabled = local.ami_id_ssm_parameter_name != null + managed_policy_arn = local.ami_id_ssm_parameter_name != null ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null + } + launch_template = aws_launch_template.runner + role_runner = aws_iam_role.runner + runners_log_groups = try(aws_cloudwatch_log_group.gh_runners, []) + logfiles = local.logfiles } } diff --git a/modules/runners/policies-runner.tf b/modules/runners/providers/ec2/policies-runner.tf similarity index 99% rename from modules/runners/policies-runner.tf rename to modules/runners/providers/ec2/policies-runner.tf index 0c08487a0f..11b7012520 100644 --- a/modules/runners/policies-runner.tf +++ b/modules/runners/providers/ec2/policies-runner.tf @@ -1,3 +1,4 @@ +# IAM used by EC2 runner instances. data "aws_caller_identity" "current" {} resource "aws_iam_role" "runner" { diff --git a/modules/runners/policies/instance-cloudwatch-policy.json b/modules/runners/providers/ec2/policies/instance-cloudwatch-policy.json similarity index 100% rename from modules/runners/policies/instance-cloudwatch-policy.json rename to modules/runners/providers/ec2/policies/instance-cloudwatch-policy.json diff --git a/modules/runners/policies/instance-create-tags-policy.json b/modules/runners/providers/ec2/policies/instance-create-tags-policy.json similarity index 100% rename from modules/runners/policies/instance-create-tags-policy.json rename to modules/runners/providers/ec2/policies/instance-create-tags-policy.json diff --git a/modules/runners/policies/instance-describe-tags-policy.json b/modules/runners/providers/ec2/policies/instance-describe-tags-policy.json similarity index 100% rename from modules/runners/policies/instance-describe-tags-policy.json rename to modules/runners/providers/ec2/policies/instance-describe-tags-policy.json diff --git a/modules/runners/policies/instance-ec2.json b/modules/runners/providers/ec2/policies/instance-ec2.json similarity index 100% rename from modules/runners/policies/instance-ec2.json rename to modules/runners/providers/ec2/policies/instance-ec2.json diff --git a/modules/runners/policies/instance-role-trust-policy.json b/modules/runners/providers/ec2/policies/instance-role-trust-policy.json similarity index 100% rename from modules/runners/policies/instance-role-trust-policy.json rename to modules/runners/providers/ec2/policies/instance-role-trust-policy.json diff --git a/modules/runners/policies/instance-s3-policy.json b/modules/runners/providers/ec2/policies/instance-s3-policy.json similarity index 100% rename from modules/runners/policies/instance-s3-policy.json rename to modules/runners/providers/ec2/policies/instance-s3-policy.json diff --git a/modules/runners/policies/instance-ssm-parameters-policy.json b/modules/runners/providers/ec2/policies/instance-ssm-parameters-policy.json similarity index 100% rename from modules/runners/policies/instance-ssm-parameters-policy.json rename to modules/runners/providers/ec2/policies/instance-ssm-parameters-policy.json diff --git a/modules/runners/policies/instance-ssm-policy.json b/modules/runners/providers/ec2/policies/instance-ssm-policy.json similarity index 100% rename from modules/runners/policies/instance-ssm-policy.json rename to modules/runners/providers/ec2/policies/instance-ssm-policy.json diff --git a/modules/runners/providers/ec2/policies/lambda-pool.json b/modules/runners/providers/ec2/policies/lambda-pool.json new file mode 100644 index 0000000000..eb9f5202b7 --- /dev/null +++ b/modules/runners/providers/ec2/policies/lambda-pool.json @@ -0,0 +1,43 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeTags", + "ec2:RunInstances", + "ec2:CreateFleet", + "ec2:CreateTags" + ], + "Resource": ["*"] + }, + { + "Effect": "Allow", + "Action": "iam:PassRole", + "Resource": "${arn_runner_instance_role}" + }, + { + "Effect": "Allow", + "Action": ["ssm:GetParameter", "ssm:GetParameters"], + "Resource": ["${ssm_ami_id_parameter_arn}"] + } +%{ if ami_kms_key_arn != "" ~} + ,{ + "Effect": "Allow", + "Action": ["kms:DescribeKey", "kms:ReEncrypt*", "kms:Decrypt"], + "Resource": "${ami_kms_key_arn}" + }, + { + "Effect": "Allow", + "Action": ["kms:CreateGrant"], + "Resource": "${ami_kms_key_arn}", + "Condition": { + "Bool": { + "aws:ViaAWSService": "true" + } + } + } +%{ endif ~} + ] +} diff --git a/modules/runners/providers/ec2/policies/lambda-scale-down.json b/modules/runners/providers/ec2/policies/lambda-scale-down.json new file mode 100644 index 0000000000..b92d0595b1 --- /dev/null +++ b/modules/runners/providers/ec2/policies/lambda-scale-down.json @@ -0,0 +1,30 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["ec2:DescribeInstances", "ec2:DescribeTags"], + "Resource": ["*"] + }, + { + "Effect": "Allow", + "Action": ["ec2:TerminateInstances", "ec2:CreateTags", "ec2:DeleteTags"], + "Resource": ["*"], + "Condition": { + "StringEquals": { + "ec2:ResourceTag/ghr:Application": "github-action-runner" + } + } + }, + { + "Effect": "Allow", + "Action": ["ec2:TerminateInstances", "ec2:CreateTags", "ec2:DeleteTags"], + "Resource": ["*"], + "Condition": { + "StringEquals": { + "ec2:ResourceTag/gh:environment": "${environment}" + } + } + } + ] +} diff --git a/modules/runners/providers/ec2/policies/lambda-scale-up.json b/modules/runners/providers/ec2/policies/lambda-scale-up.json new file mode 100644 index 0000000000..8e2c4ac624 --- /dev/null +++ b/modules/runners/providers/ec2/policies/lambda-scale-up.json @@ -0,0 +1,64 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeLaunchTemplateVersions", + "ec2:DescribeTags", + "ec2:RunInstances", + "ec2:CreateFleet", + "ec2:CreateTags" + ], + "Resource": ["*"] + }, + { + "Effect": "Allow", + "Action": ["ec2:TerminateInstances"], + "Resource": ["*"], + "Condition": { + "StringEquals": { + "ec2:ResourceTag/ghr:Application": "github-action-runner" + } + } + }, + { + "Effect": "Allow", + "Action": ["ec2:TerminateInstances"], + "Resource": ["*"], + "Condition": { + "StringEquals": { + "ec2:ResourceTag/gh:environment": "${environment}" + } + } + }, + { + "Effect": "Allow", + "Action": "iam:PassRole", + "Resource": "${arn_runner_instance_role}" + }, + { + "Effect": "Allow", + "Action": ["ssm:GetParameter", "ssm:GetParameters"], + "Resource": ["${ssm_ami_id_parameter_arn}"] + } +%{ if ami_kms_key_arn != "" ~} + ,{ + "Effect": "Allow", + "Action": ["kms:DescribeKey", "kms:ReEncrypt*", "kms:Decrypt"], + "Resource": "${ami_kms_key_arn}" + }, + { + "Effect": "Allow", + "Action": ["kms:CreateGrant"], + "Resource": "${ami_kms_key_arn}", + "Condition": { + "Bool": { + "aws:ViaAWSService": "true" + } + } + } +%{ endif ~} + ] +} diff --git a/modules/runners/policies/service-linked-role-create-policy.json b/modules/runners/providers/ec2/policies/service-linked-role-create-policy.json similarity index 100% rename from modules/runners/policies/service-linked-role-create-policy.json rename to modules/runners/providers/ec2/policies/service-linked-role-create-policy.json diff --git a/modules/runners/providers/ec2/runner-config.tf b/modules/runners/providers/ec2/runner-config.tf new file mode 100644 index 0000000000..989612748a --- /dev/null +++ b/modules/runners/providers/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_as_root ? "root" : var.runner_run_as + tags = local.tags +} + +resource "aws_ssm_parameter" "runner_enable_cloudwatch" { + name = "${var.ssm_paths.root}/${var.ssm_paths.config}/enable_cloudwatch" + type = "String" + value = var.enable_cloudwatch_agent + tags = local.tags +} diff --git a/modules/runners/templates/cloudwatch_config.json b/modules/runners/providers/ec2/templates/cloudwatch_config.json similarity index 100% rename from modules/runners/templates/cloudwatch_config.json rename to modules/runners/providers/ec2/templates/cloudwatch_config.json diff --git a/modules/runners/templates/install-runner-osx.sh b/modules/runners/providers/ec2/templates/install-runner-osx.sh similarity index 100% rename from modules/runners/templates/install-runner-osx.sh rename to modules/runners/providers/ec2/templates/install-runner-osx.sh diff --git a/modules/runners/templates/install-runner.ps1 b/modules/runners/providers/ec2/templates/install-runner.ps1 similarity index 100% rename from modules/runners/templates/install-runner.ps1 rename to modules/runners/providers/ec2/templates/install-runner.ps1 diff --git a/modules/runners/templates/install-runner.sh b/modules/runners/providers/ec2/templates/install-runner.sh similarity index 100% rename from modules/runners/templates/install-runner.sh rename to modules/runners/providers/ec2/templates/install-runner.sh diff --git a/modules/runners/templates/start-runner-osx.sh b/modules/runners/providers/ec2/templates/start-runner-osx.sh similarity index 100% rename from modules/runners/templates/start-runner-osx.sh rename to modules/runners/providers/ec2/templates/start-runner-osx.sh diff --git a/modules/runners/templates/start-runner.ps1 b/modules/runners/providers/ec2/templates/start-runner.ps1 similarity index 100% rename from modules/runners/templates/start-runner.ps1 rename to modules/runners/providers/ec2/templates/start-runner.ps1 diff --git a/modules/runners/templates/start-runner.sh b/modules/runners/providers/ec2/templates/start-runner.sh similarity index 100% rename from modules/runners/templates/start-runner.sh rename to modules/runners/providers/ec2/templates/start-runner.sh diff --git a/modules/runners/templates/user-data-osx.sh b/modules/runners/providers/ec2/templates/user-data-osx.sh similarity index 100% rename from modules/runners/templates/user-data-osx.sh rename to modules/runners/providers/ec2/templates/user-data-osx.sh diff --git a/modules/runners/templates/user-data.ps1 b/modules/runners/providers/ec2/templates/user-data.ps1 similarity index 100% rename from modules/runners/templates/user-data.ps1 rename to modules/runners/providers/ec2/templates/user-data.ps1 diff --git a/modules/runners/templates/user-data.sh b/modules/runners/providers/ec2/templates/user-data.sh similarity index 100% rename from modules/runners/templates/user-data.sh rename to modules/runners/providers/ec2/templates/user-data.sh diff --git a/modules/runners/providers/ec2/tests/provider.tftest.hcl b/modules/runners/providers/ec2/tests/provider.tftest.hcl index f9a9b8b06f..c43ac7af60 100644 --- a/modules/runners/providers/ec2/tests/provider.tftest.hcl +++ b/modules/runners/providers/ec2/tests/provider.tftest.hcl @@ -1,69 +1,89 @@ mock_provider "aws" { - mock_data "aws_iam_policy_document" { + mock_data "aws_ami" { defaults = { - json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + id = "ami-1234567890abcdef0" + name = "runner-test" + creation_date = "2026-01-01T00:00:00.000Z" + deprecation_time = "" } } } variables { - aws_region = "eu-west-1" - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - prefix = "provider-test" - ssm_root_path = "/github-action-runners/provider-test" - ssm_paths = { - runners = "runners" - } + aws_region = "eu-west-1" + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + prefix = "provider-test" - github_app_parameters = { - key_base64 = { - name = "/github-runner/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { - name = "/github-runner/app-id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id" + kms_key_arn = null } - lambda_s3_bucket = "lambda-artifacts" - runners_lambda_s3_key = "runners.zip" + instance_types = ["m5.large"] - lanes = { - linux = { - runner = { - runner_os = "linux" - runner_architecture = "x64" - runners_maximum_count = 2 - enable_organization_runners = true - pool_config = [{ - schedule_expression = "cron(0 8 * * ? *)" - size = 1 - }] - } - provider = { - instance_types = ["m5.large"] - enable_runner_binaries_syncer = false - } - queue = { - arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" - } - } + s3_runner_binaries = null + enable_runner_binaries_syncer = false + enable_ssm_on_runners = false + enable_cloudwatch_agent = false + enable_managed_runner_security_group = true + + iam_overrides = { + override_instance_profile = true + instance_profile_name = "provider-test-runner-profile" + override_runner_role = true + runner_role_arn = "arn:aws:iam::123456789012:role/provider-test-runner" + } + + ssm_paths = { + root = "/github-runner/provider-test" + tokens = "tokens" + config = "config" } } -run "plan_preserves_lane_key_through_ec2_provider" { +run "exports_ec2_only_control_plane_fragments" { command = plan assert { - condition = keys(module.runners) == ["linux"] - error_message = "The EC2 provider must preserve the multi-runner lane key." + condition = output.provider.type == "ec2" + error_message = "The provider contract must identify EC2." + } + + assert { + condition = output.provider.scale_up.environment_variables["INSTANCE_TYPES"] == "m5.large" + error_message = "The provider contract must expose EC2 scale-up environment variables." + } + + assert { + condition = output.provider.scale_down.environment_variables["RUNNER_BOOT_TIME_IN_MINUTES"] == 5 + error_message = "The provider contract must expose the EC2 scale-down boot grace period." + } + + assert { + condition = strcontains(output.provider.scale_up.iam_policy_json, "ec2:RunInstances") + error_message = "The EC2 provider must own EC2 scale-up permissions." + } + + assert { + condition = !strcontains(output.provider.scale_up.iam_policy_json, "sqs:ReceiveMessage") + error_message = "The EC2 provider must not own common build-queue permissions." + } + + assert { + condition = strcontains(output.provider.pool.iam_policy_json, "iam:PassRole") + error_message = "The EC2 provider must expose pool permissions for its runner role." + } + + assert { + condition = output.provider.scale_up.managed_policy_enabled + error_message = "An external AMI SSM parameter must enable the scale-up managed policy attachment at plan time." } assert { - condition = keys(output.runners_map) == ["linux"] - error_message = "The EC2 provider output must preserve the public runner map key." + condition = output.provider.pool.managed_policy_enabled + error_message = "An external AMI SSM parameter must enable the pool managed policy attachment at plan time." } } diff --git a/modules/runners/providers/ec2/variables.tf b/modules/runners/providers/ec2/variables.tf index 176b980a82..c39c8b8451 100644 --- a/modules/runners/providers/ec2/variables.tf +++ b/modules/runners/providers/ec2/variables.tf @@ -1,489 +1,523 @@ -variable "lanes" { - description = "Resolved EC2 runner lanes keyed by the stable multi-runner lane name." - type = map(object({ - runner = object({ - runner_os = string - runner_architecture = string - disable_runner_autoupdate = optional(bool, false) - enable_ephemeral_runners = optional(bool, false) - enable_job_queued_check = optional(bool, null) - enable_jit_config = optional(bool, null) - enable_organization_runners = optional(bool, false) - minimum_running_time_in_minutes = optional(number, null) - pool_runner_owner = optional(string, null) - runner_as_root = optional(bool, false) - runner_boot_time_in_minutes = optional(number, 5) - runner_disable_default_labels = optional(bool, false) - runner_extra_labels = optional(list(string), []) - runner_group_name = optional(string, "Default") - runner_name_prefix = optional(string, "") - runner_run_as = optional(string, "ec2-user") - runners_maximum_count = number - runner_iam_role_managed_policy_arns = optional(list(string), []) - scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)") - scale_up_reserved_concurrent_executions = optional(number, 1) - pool_config = optional(list(object({ - schedule_expression = string - schedule_expression_timezone = optional(string) - size = number - })), []) - job_retry = optional(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) - }), {}) - iam_overrides = optional(object({ - override_instance_profile = optional(bool, null) - instance_profile_name = optional(string, null) - override_runner_role = optional(bool, null) - runner_role_arn = optional(string, null) - }), { - override_instance_profile = false - instance_profile_name = null - override_runner_role = false - runner_role_arn = null - }) - }) - - provider = object({ - runner_metadata_options = optional(map(any), { - instance_metadata_tags = "enabled" - http_endpoint = "enabled" - http_tokens = "required" - http_put_response_hop_limit = 1 - }) - ami = optional(object({ - filter = optional(map(list(string)), { state = ["available"] }) - owners = optional(list(string), ["amazon"]) - id_ssm_parameter_arn = optional(string, null) - kms_key_arn = optional(string, null) - }), 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 - }]) - cloudwatch_config = optional(string, null) - create_service_linked_role_spot = optional(bool, false) - credit_specification = optional(string, null) - ebs_optimized = optional(bool, false) - enable_cloudwatch_agent = optional(bool, true) - enable_runner_binaries_syncer = optional(bool, true) - enable_runner_detailed_monitoring = optional(bool, false) - enable_ssm_on_runners = optional(bool, false) - enable_userdata = optional(bool, true) - instance_allocation_strategy = optional(string, "lowest-price") - instance_max_spot_price = optional(string, null) - instance_target_capacity_type = optional(string, "spot") - instance_type_priorities = optional(map(number), null) - instance_types = list(string) - runner_additional_security_group_ids = optional(list(string), []) - enable_on_demand_failover_for_errors = optional(list(string), []) - scale_errors = optional(list(string), [ - "UnfulfillableCapacity", - "MaxSpotInstanceCountExceeded", - "TargetCapacityLimitExceededException", - "RequestLimitExceeded", - "ResourceLimitExceeded", - "MaxSpotInstanceCountExceeded", - "MaxSpotFleetRequestCountExceeded", - "InsufficientInstanceCapacity", - "InsufficientCapacityOnHost", - ]) - subnet_ids = optional(list(string), null) - vpc_id = optional(string, null) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), []) - 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 - })), []) - use_dedicated_host = optional(bool, false) - runner_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) - runner_ec2_tags = optional(map(string), {}) - runner_hook_job_completed = optional(string, "") - runner_hook_job_started = optional(string, "") - userdata_content = optional(string, null) - userdata_post_install = optional(string, "") - userdata_pre_install = optional(string, "") - userdata_template = optional(string, null) - }) - - queue = object({ - arn = string - url = string - lambda_event_source_mapping_batch_size = optional(number, null) - lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null) - }) - })) +variable "ami" { + description = < Date: Thu, 6 Aug 2026 23:29:44 +0000 Subject: [PATCH 04/27] docs: auto update terraform docs --- modules/multi-runner/README.md | 16 +++--- modules/runners/README.md | 18 +++--- modules/runners/job-retry/README.md | 12 ++-- modules/runners/pool/README.md | 12 ++-- modules/runners/providers/ec2/README.md | 12 ++-- .../fixtures/pre-provider-split/README.md | 57 +++++++++++++++++++ 6 files changed, 92 insertions(+), 35 deletions(-) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 5f392de0fe..639b5fd442 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -83,7 +83,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -91,14 +91,14 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | -| [random](#provider\_random) | 3.9.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | +| [random](#provider\_random) | ~> 3.0 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -109,7 +109,7 @@ module "multi-runner" { ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -120,7 +120,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | | [ami\_housekeeper\_lambda\_s3\_key](#input\_ami\_housekeeper\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | @@ -204,7 +204,7 @@ module "multi-runner" { ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | diff --git a/modules/runners/README.md b/modules/runners/README.md index baa2feda61..eb5364e958 100644 --- a/modules/runners/README.md +++ b/modules/runners/README.md @@ -53,20 +53,20 @@ yarn run dist ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ec2](#module\_ec2) | ./providers/ec2 | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | @@ -74,7 +74,7 @@ yarn run dist ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | @@ -120,7 +120,7 @@ yarn run dist ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | @@ -232,17 +232,17 @@ yarn run dist ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [lambda\_pool](#output\_lambda\_pool) | n/a | | [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a | | [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a | | [lambda\_scale\_down\_log\_group](#output\_lambda\_scale\_down\_log\_group) | n/a | | [lambda\_scale\_up](#output\_lambda\_scale\_up) | n/a | | [lambda\_scale\_up\_log\_group](#output\_lambda\_scale\_up\_log\_group) | n/a | -| [launch\_template](#output\_launch\_template) | n/a | +| [launch\_template](#output\_launch\_template) | EC2 launch template when the selected compute provider exposes one. | | [logfiles](#output\_logfiles) | List of logfiles to send to CloudWatch. Object description: `log_group_name`: Name of the log group, `file_path`: path to the log file, `log_stream_name`: name of the log stream. | | [role\_pool](#output\_role\_pool) | n/a | -| [role\_runner](#output\_role\_runner) | n/a | +| [role\_runner](#output\_role\_runner) | EC2 runner roles when the selected compute provider exposes them. | | [role\_scale\_down](#output\_role\_scale\_down) | n/a | | [role\_scale\_up](#output\_role\_scale\_up) | n/a | | [runners\_log\_groups](#output\_runners\_log\_groups) | List of log groups from different log files of runner machine. | diff --git a/modules/runners/job-retry/README.md b/modules/runners/job-retry/README.md index a200075891..9f462a6f9e 100644 --- a/modules/runners/job-retry/README.md +++ b/modules/runners/job-retry/README.md @@ -11,26 +11,26 @@ The module is an inner module and used by the runner module when the opt-in feat ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [job\_retry](#module\_job\_retry) | ../../lambda | n/a | ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_iam_role_policy.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_lambda_event_source_mapping.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | | [aws_lambda_permission.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | @@ -41,13 +41,13 @@ The module is an inner module and used by the runner module when the opt-in feat ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runner_name_prefix`: Prefix used to identify runners belonging to this lane.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | n/a | | [lambda](#output\_lambda) | n/a | diff --git a/modules/runners/pool/README.md b/modules/runners/pool/README.md index b865902c50..a6962c832b 100644 --- a/modules/runners/pool/README.md +++ b/modules/runners/pool/README.md @@ -9,15 +9,15 @@ The pool is an opt-in feature. To be able to use the count on a module level to ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.21 | ## Modules @@ -26,7 +26,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_log_group.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | @@ -48,7 +48,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no | | [config](#input\_config) | Lookup details in parent module. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_arn = string
role_path = string
ssm_token_path = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
})
| n/a | yes | | [runner\_provider](#input\_runner\_provider) | Compute provider configuration for the pool Lambda. |
object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
| n/a | yes | @@ -57,7 +57,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [lambda](#output\_lambda) | n/a | | [lambda\_log\_group](#output\_lambda\_log\_group) | n/a | | [role\_pool](#output\_role\_pool) | n/a | diff --git a/modules/runners/providers/ec2/README.md b/modules/runners/providers/ec2/README.md index 590a5ff212..464ca10fad 100644 --- a/modules/runners/providers/ec2/README.md +++ b/modules/runners/providers/ec2/README.md @@ -10,15 +10,15 @@ EC2 is the only active compute provider. A future provider must implement the sa ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -27,7 +27,7 @@ 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 | @@ -53,7 +53,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | @@ -120,6 +120,6 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [provider](#output\_provider) | EC2 resources and control-plane fragments consumed by the common runners module. | diff --git a/modules/runners/tests-upgrade/fixtures/pre-provider-split/README.md b/modules/runners/tests-upgrade/fixtures/pre-provider-split/README.md index 29ea4abb41..9e748e53c7 100644 --- a/modules/runners/tests-upgrade/fixtures/pre-provider-split/README.md +++ b/modules/runners/tests-upgrade/fixtures/pre-provider-split/README.md @@ -7,3 +7,60 @@ blocks are exercised. The external-AMI and module-managed-AMI runs cover the two mutually exclusive count paths for `runner_ami_id` and `ami_id_ssm_parameter_read`. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.14.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | + +## 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_iam_role.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.create_tag](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.describe_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.dist_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ec2](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.runner_session_manager_aws_managed](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ssm_parameters](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.managed_policies](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.xray_tracing](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | 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 | +| [aws_ami.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [prefix](#input\_prefix) | Prefix used to reproduce the historical runner resource addresses. | `string` | n/a | yes | +| [use\_external\_ami](#input\_use\_external\_ami) | Selects the historical external-AMI policy path instead of the module-managed AMI parameter path. | `bool` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [moved\_resource\_ids](#output\_moved\_resource\_ids) | n/a | + \ No newline at end of file From 38b6b81727aba7eb78e599b3133b2f5adad74ae6 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 01:47:45 +0200 Subject: [PATCH 05/27] refactor(multi-runner): add parallel EC2 provider stack --- .github/workflows/terraform.yml | 19 +- docs/index.md | 2 +- .../ec2/README.md | 17 +- .../ec2/compute.tf | 0 .../ec2/contracts.tf | 0 .../ec2/logging.tf | 0 .../ec2/outputs.tf | 10 +- .../ec2/policies-runner.tf | 0 .../policies/instance-cloudwatch-policy.json | 25 + .../policies/instance-create-tags-policy.json | 0 .../instance-describe-tags-policy.json | 10 + .../ec2/policies/instance-ec2.json | 0 .../policies/instance-role-trust-policy.json | 0 .../ec2/policies/instance-s3-policy.json | 0 .../instance-ssm-parameters-policy.json | 0 .../ec2/policies/instance-ssm-policy.json | 46 + .../ec2/policies/lambda-pool.json | 0 .../ec2/policies/lambda-scale-down.json | 0 .../ec2/policies/lambda-scale-up.json | 0 .../service-linked-role-create-policy.json | 0 .../ec2/runner-config.tf | 0 .../ec2/templates/cloudwatch_config.json | 12 + .../ec2/templates/install-runner-osx.sh | 0 .../ec2/templates/install-runner.ps1 | 0 .../ec2/templates/install-runner.sh | 0 .../ec2/templates/start-runner-osx.sh | 0 .../ec2/templates/start-runner.ps1 | 0 .../ec2/templates/start-runner.sh | 0 .../ec2/templates/user-data-osx.sh | 0 .../ec2/templates/user-data.ps1 | 47 + .../ec2/templates/user-data.sh | 0 .../ec2/tests/provider.tftest.hcl | 33 +- .../ec2/variables.tf | 0 .../ec2/versions.tf | 0 modules/multi-runner/README.md | 11 +- modules/multi-runner/multi-runner-config.tf | 15 +- modules/multi-runner/outputs.tf | 51 +- modules/multi-runner/runners.tf | 136 ++- .../tests/provider-routing.tftest.hcl | 148 ++- modules/runner-stack/README.md | 246 +++++ modules/runner-stack/job-retry.tf | 44 + modules/runner-stack/job-retry/README.md | 53 ++ modules/runner-stack/job-retry/main.tf | 99 ++ modules/runner-stack/job-retry/outputs.tf | 11 + .../job-retry/policies/lambda.json | 44 + modules/runner-stack/job-retry/variables.tf | 99 ++ modules/runner-stack/job-retry/versions.tf | 10 + modules/runner-stack/local.tf | 8 + modules/runner-stack/main.tf | 91 ++ modules/runner-stack/outputs.tf | 43 + .../runner-stack/policies-lambda-common.tf | 27 + .../policies/lambda-cloudwatch.json | 10 + .../policies/lambda-publish-sqs-policy.json | 10 + .../policies/lambda-scale-down.json | 17 + .../policies/lambda-scale-up.json | 31 + .../policies/lambda-ssm-housekeeper.json | 13 + modules/runner-stack/pool.tf | 63 ++ modules/runner-stack/pool/README.md | 64 ++ modules/runner-stack/pool/main.tf | 231 +++++ modules/runner-stack/pool/outputs.tf | 11 + .../pool/policies/lambda-pool.json | 45 + .../pool/tests/provider.tftest.hcl | 0 modules/runner-stack/pool/variables.tf | 99 ++ modules/runner-stack/pool/versions.tf | 10 + modules/runner-stack/runner-config.tf | 27 + .../runner-stack/scale-down-state-diagram.md | 150 +++ modules/runner-stack/scale-down.tf | 135 +++ modules/runner-stack/scale-up.tf | 171 ++++ modules/runner-stack/ssm-housekeeper.tf | 122 +++ modules/runner-stack/tests/README.md | 72 ++ modules/runner-stack/tests/pool.tftest.hcl | 136 +++ modules/runner-stack/variables.tf | 889 ++++++++++++++++++ .../versions.tf | 2 +- modules/runners/README.md | 32 +- modules/runners/job-retry.tf | 1 - modules/runners/job-retry/README.md | 2 +- modules/runners/job-retry/main.tf | 1 - modules/runners/job-retry/variables.tf | 2 - modules/runners/logging.tf | 85 ++ modules/runners/main.tf | 399 ++++++-- modules/runners/moved.tf | 94 -- modules/runners/outputs.tf | 10 +- modules/runners/policies-lambda-common.tf | 24 + modules/runners/policies-runner.tf | 84 ++ .../policies/instance-cloudwatch-policy.json | 0 .../policies/instance-create-tags-policy.json | 20 + .../instance-describe-tags-policy.json | 0 modules/runners/policies/instance-ec2.json | 15 + .../policies/instance-role-trust-policy.json | 13 + .../runners/policies/instance-s3-policy.json | 11 + .../instance-ssm-parameters-policy.json | 31 + .../policies/instance-ssm-policy.json | 0 .../runners/policies/lambda-scale-down.json | 62 +- modules/runners/policies/lambda-scale-up.json | 146 ++- .../service-linked-role-create-policy.json | 10 + modules/runners/pool.tf | 55 +- modules/runners/pool/README.md | 8 +- modules/runners/pool/main.tf | 105 ++- .../runners/pool/policies/lambda-pool.json | 54 +- modules/runners/pool/variables.tf | 84 +- modules/runners/pool/versions.tf | 2 +- modules/runners/runner-config.tf | 14 + modules/runners/scale-down.tf | 29 +- modules/runners/scale-up.tf | 54 +- .../ec2 => }/templates/cloudwatch_config.json | 0 .../runners/templates/install-runner-osx.sh | 61 ++ modules/runners/templates/install-runner.ps1 | 13 + modules/runners/templates/install-runner.sh | 73 ++ modules/runners/templates/start-runner-osx.sh | 185 ++++ modules/runners/templates/start-runner.ps1 | 206 ++++ modules/runners/templates/start-runner.sh | 280 ++++++ modules/runners/templates/user-data-osx.sh | 39 + .../ec2 => }/templates/user-data.ps1 | 0 modules/runners/templates/user-data.sh | 81 ++ modules/runners/tests-upgrade/README.md | 13 - .../fixtures/pre-provider-split/README.md | 66 -- .../fixtures/pre-provider-split/main.tf | 287 ------ .../fixtures/pre-provider-split/outputs.tf | 23 - .../tests-upgrade/state-migration.tftest.hcl | 249 ----- modules/runners/tests-upgrade/verify-moves.jq | 61 -- modules/runners/tests/pool.tftest.hcl | 73 +- modules/runners/variables.tf | 11 - 122 files changed, 5599 insertions(+), 1234 deletions(-) rename modules/{runners/providers => compute-providers}/ec2/README.md (96%) rename modules/{runners/providers => compute-providers}/ec2/compute.tf (100%) rename modules/{runners/providers => compute-providers}/ec2/contracts.tf (100%) rename modules/{runners/providers => compute-providers}/ec2/logging.tf (100%) rename modules/{runners/providers => compute-providers}/ec2/outputs.tf (82%) rename modules/{runners/providers => compute-providers}/ec2/policies-runner.tf (100%) create mode 100644 modules/compute-providers/ec2/policies/instance-cloudwatch-policy.json rename modules/{runners/providers => compute-providers}/ec2/policies/instance-create-tags-policy.json (100%) create mode 100644 modules/compute-providers/ec2/policies/instance-describe-tags-policy.json rename modules/{runners/providers => compute-providers}/ec2/policies/instance-ec2.json (100%) rename modules/{runners/providers => compute-providers}/ec2/policies/instance-role-trust-policy.json (100%) rename modules/{runners/providers => compute-providers}/ec2/policies/instance-s3-policy.json (100%) rename modules/{runners/providers => compute-providers}/ec2/policies/instance-ssm-parameters-policy.json (100%) create mode 100644 modules/compute-providers/ec2/policies/instance-ssm-policy.json rename modules/{runners/providers => compute-providers}/ec2/policies/lambda-pool.json (100%) rename modules/{runners/providers => compute-providers}/ec2/policies/lambda-scale-down.json (100%) rename modules/{runners/providers => compute-providers}/ec2/policies/lambda-scale-up.json (100%) rename modules/{runners/providers => compute-providers}/ec2/policies/service-linked-role-create-policy.json (100%) rename modules/{runners/providers => compute-providers}/ec2/runner-config.tf (100%) create mode 100644 modules/compute-providers/ec2/templates/cloudwatch_config.json rename modules/{runners/providers => compute-providers}/ec2/templates/install-runner-osx.sh (100%) rename modules/{runners/providers => compute-providers}/ec2/templates/install-runner.ps1 (100%) rename modules/{runners/providers => compute-providers}/ec2/templates/install-runner.sh (100%) rename modules/{runners/providers => compute-providers}/ec2/templates/start-runner-osx.sh (100%) rename modules/{runners/providers => compute-providers}/ec2/templates/start-runner.ps1 (100%) rename modules/{runners/providers => compute-providers}/ec2/templates/start-runner.sh (100%) rename modules/{runners/providers => compute-providers}/ec2/templates/user-data-osx.sh (100%) create mode 100644 modules/compute-providers/ec2/templates/user-data.ps1 rename modules/{runners/providers => compute-providers}/ec2/templates/user-data.sh (100%) rename modules/{runners/providers => compute-providers}/ec2/tests/provider.tftest.hcl (60%) rename modules/{runners/providers => compute-providers}/ec2/variables.tf (100%) rename modules/{runners/providers => compute-providers}/ec2/versions.tf (100%) create mode 100644 modules/runner-stack/README.md create mode 100644 modules/runner-stack/job-retry.tf create mode 100644 modules/runner-stack/job-retry/README.md create mode 100644 modules/runner-stack/job-retry/main.tf create mode 100644 modules/runner-stack/job-retry/outputs.tf create mode 100644 modules/runner-stack/job-retry/policies/lambda.json create mode 100644 modules/runner-stack/job-retry/variables.tf create mode 100644 modules/runner-stack/job-retry/versions.tf create mode 100644 modules/runner-stack/local.tf create mode 100644 modules/runner-stack/main.tf create mode 100644 modules/runner-stack/outputs.tf create mode 100644 modules/runner-stack/policies-lambda-common.tf create mode 100644 modules/runner-stack/policies/lambda-cloudwatch.json create mode 100644 modules/runner-stack/policies/lambda-publish-sqs-policy.json create mode 100644 modules/runner-stack/policies/lambda-scale-down.json create mode 100644 modules/runner-stack/policies/lambda-scale-up.json create mode 100644 modules/runner-stack/policies/lambda-ssm-housekeeper.json create mode 100644 modules/runner-stack/pool.tf create mode 100644 modules/runner-stack/pool/README.md create mode 100644 modules/runner-stack/pool/main.tf create mode 100644 modules/runner-stack/pool/outputs.tf create mode 100644 modules/runner-stack/pool/policies/lambda-pool.json rename modules/{runners => runner-stack}/pool/tests/provider.tftest.hcl (100%) create mode 100644 modules/runner-stack/pool/variables.tf create mode 100644 modules/runner-stack/pool/versions.tf create mode 100644 modules/runner-stack/runner-config.tf create mode 100644 modules/runner-stack/scale-down-state-diagram.md create mode 100644 modules/runner-stack/scale-down.tf create mode 100644 modules/runner-stack/scale-up.tf create mode 100644 modules/runner-stack/ssm-housekeeper.tf create mode 100644 modules/runner-stack/tests/README.md create mode 100644 modules/runner-stack/tests/pool.tftest.hcl create mode 100644 modules/runner-stack/variables.tf rename modules/{runners/tests-upgrade/fixtures/pre-provider-split => runner-stack}/versions.tf (78%) create mode 100644 modules/runners/logging.tf delete mode 100644 modules/runners/moved.tf create mode 100644 modules/runners/policies-runner.tf rename modules/runners/{providers/ec2 => }/policies/instance-cloudwatch-policy.json (100%) create mode 100644 modules/runners/policies/instance-create-tags-policy.json rename modules/runners/{providers/ec2 => }/policies/instance-describe-tags-policy.json (100%) create mode 100644 modules/runners/policies/instance-ec2.json create mode 100644 modules/runners/policies/instance-role-trust-policy.json create mode 100644 modules/runners/policies/instance-s3-policy.json create mode 100644 modules/runners/policies/instance-ssm-parameters-policy.json rename modules/runners/{providers/ec2 => }/policies/instance-ssm-policy.json (100%) create mode 100644 modules/runners/policies/service-linked-role-create-policy.json rename modules/runners/{providers/ec2 => }/templates/cloudwatch_config.json (100%) create mode 100644 modules/runners/templates/install-runner-osx.sh create mode 100644 modules/runners/templates/install-runner.ps1 create mode 100644 modules/runners/templates/install-runner.sh create mode 100644 modules/runners/templates/start-runner-osx.sh create mode 100644 modules/runners/templates/start-runner.ps1 create mode 100644 modules/runners/templates/start-runner.sh create mode 100644 modules/runners/templates/user-data-osx.sh rename modules/runners/{providers/ec2 => }/templates/user-data.ps1 (100%) create mode 100644 modules/runners/templates/user-data.sh delete mode 100644 modules/runners/tests-upgrade/README.md delete mode 100644 modules/runners/tests-upgrade/fixtures/pre-provider-split/README.md delete mode 100644 modules/runners/tests-upgrade/fixtures/pre-provider-split/main.tf delete mode 100644 modules/runners/tests-upgrade/fixtures/pre-provider-split/outputs.tf delete mode 100644 modules/runners/tests-upgrade/state-migration.tftest.hcl delete mode 100644 modules/runners/tests-upgrade/verify-moves.jq diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index cb443dc241..dbf2885633 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -85,8 +85,9 @@ jobs: "download-lambda", "lambda", "multi-runner", - "runners/providers/ec2", + "compute-providers/ec2", "runner-binaries-syncer", + "runner-stack", "runners", "setup-iam-permissions", "ssm", @@ -216,8 +217,9 @@ jobs: module: - modules/runners - modules/multi-runner - - modules/runners/pool - - modules/runners/providers/ec2 + - modules/runner-stack + - modules/runner-stack/pool + - modules/compute-providers/ec2 defaults: run: working-directory: ${{ matrix.module }} @@ -237,14 +239,3 @@ jobs: run: terraform init -backend=false -input=false - name: terraform test run: terraform test -test-directory=tests - - if: matrix.module == 'modules/runners' - name: Install state-upgrade test tools - run: apk add --no-cache jq - - if: matrix.module == 'modules/runners' - name: terraform init state upgrade test - run: terraform init -backend=false -input=false -test-directory=tests-upgrade - - if: matrix.module == 'modules/runners' - name: terraform test state upgrade - run: | - terraform test -test-directory=tests-upgrade -json -verbose > state-upgrade-test.json - jq -e -s -f tests-upgrade/verify-moves.jq state-upgrade-test.json diff --git a/docs/index.md b/docs/index.md index 35072a2427..5b6da2fc9e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,7 +101,7 @@ Besides these permissions, the lambdas also need permission to CloudWatch (for l ## Terraform main modules -Currently we support two main modules. The `runners` module creates the provider-neutral control plane and selects an internal compute implementation with `provider_type`. The `multi-runner` module adds lane normalization, queues, and webhook routing so multiple runners can be created in one deployment. EC2 is the only Terraform-managed provider today; its child module owns EC2 compute and runner-instance permissions while the shared layer owns scale-up, scale-down, pool, retry, and Lambda roles. microVM, CodeBuild, and other providers are future work. +Currently we support two main modules. The existing `runners` module remains the stable EC2 implementation, and the `multi-runner` module creates multiple runner lanes in one deployment. Stable `multi_runner_config` lanes continue to use the unchanged `runners` module. Experimental `multi_runner_config_v2` lanes use the new provider-oriented `runner-stack`, where the common control plane owns scale-up, scale-down, pool, retry, housekeeper, and Lambda roles while `compute-providers/ec2` owns EC2 resources and runner-instance permissions. EC2 is the only active Terraform-managed provider; microVM, CodeBuild, and other provider modules are future work. Both modules are built on top of the same base modules. When using the multi-runner module you can deploy different runners with only one deployment. diff --git a/modules/runners/providers/ec2/README.md b/modules/compute-providers/ec2/README.md similarity index 96% rename from modules/runners/providers/ec2/README.md rename to modules/compute-providers/ec2/README.md index 464ca10fad..db07fd612f 100644 --- a/modules/runners/providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -2,7 +2,7 @@ This internal module owns the EC2 compute implementation used by the common `modules/runners` control plane. It creates the runner launch template, security group, instance role and profile, EC2 bootstrap parameters, and runner log groups. -The module returns a provider contract containing only the EC2-specific environment variables and IAM policy fragments required by scale-up, scale-down, and pool. The parent module owns the Lambda functions, execution roles, schedules, queues, retry flow, and SSM housekeeper. +The module returns two deliberately separate outputs. `control_plane` is the common provider contract and contains only the EC2-specific environment variables and IAM policy fragments required by scale-up, scale-down, and pool. `resources` contains the EC2 launch template, runner role, runner log groups, and log-file configuration. The parent module owns the Lambda functions, execution roles, schedules, queues, retry flow, and SSM housekeeper. EC2 is the only active compute provider. A future provider must implement the same contract before it can be selected by `provider_type`. @@ -10,15 +10,15 @@ EC2 is the only active compute provider. A future provider must implement the sa ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | ## Modules @@ -27,7 +27,7 @@ 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 | @@ -53,7 +53,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | @@ -120,6 +120,7 @@ No modules. ## Outputs | Name | Description | -|------|-------------| -| [provider](#output\_provider) | EC2 resources and control-plane fragments consumed by the common runners module. | +| ---- | ----------- | +| [control\_plane](#output\_control\_plane) | Provider-neutral control-plane contract containing the EC2 environment and IAM fragments. | +| [resources](#output\_resources) | EC2-specific resources and bootstrap logging details. | diff --git a/modules/runners/providers/ec2/compute.tf b/modules/compute-providers/ec2/compute.tf similarity index 100% rename from modules/runners/providers/ec2/compute.tf rename to modules/compute-providers/ec2/compute.tf diff --git a/modules/runners/providers/ec2/contracts.tf b/modules/compute-providers/ec2/contracts.tf similarity index 100% rename from modules/runners/providers/ec2/contracts.tf rename to modules/compute-providers/ec2/contracts.tf diff --git a/modules/runners/providers/ec2/logging.tf b/modules/compute-providers/ec2/logging.tf similarity index 100% rename from modules/runners/providers/ec2/logging.tf rename to modules/compute-providers/ec2/logging.tf diff --git a/modules/runners/providers/ec2/outputs.tf b/modules/compute-providers/ec2/outputs.tf similarity index 82% rename from modules/runners/providers/ec2/outputs.tf rename to modules/compute-providers/ec2/outputs.tf index 79816a6dde..de051f8af5 100644 --- a/modules/runners/providers/ec2/outputs.tf +++ b/modules/compute-providers/ec2/outputs.tf @@ -1,5 +1,5 @@ -output "provider" { - description = "EC2 resources and control-plane fragments consumed by the common runners module." +output "control_plane" { + description = "Provider-neutral control-plane contract containing the EC2 environment and IAM fragments." value = { type = "ec2" scale_up = { @@ -19,6 +19,12 @@ output "provider" { managed_policy_enabled = local.ami_id_ssm_parameter_name != null managed_policy_arn = local.ami_id_ssm_parameter_name != null ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null } + } +} + +output "resources" { + description = "EC2-specific resources and bootstrap logging details." + value = { launch_template = aws_launch_template.runner role_runner = aws_iam_role.runner runners_log_groups = try(aws_cloudwatch_log_group.gh_runners, []) diff --git a/modules/runners/providers/ec2/policies-runner.tf b/modules/compute-providers/ec2/policies-runner.tf similarity index 100% rename from modules/runners/providers/ec2/policies-runner.tf rename to modules/compute-providers/ec2/policies-runner.tf diff --git a/modules/compute-providers/ec2/policies/instance-cloudwatch-policy.json b/modules/compute-providers/ec2/policies/instance-cloudwatch-policy.json new file mode 100644 index 0000000000..afd3993c00 --- /dev/null +++ b/modules/compute-providers/ec2/policies/instance-cloudwatch-policy.json @@ -0,0 +1,25 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "cloudwatch:PutMetricData", + "ec2:DescribeVolumes", + "ec2:DescribeTags", + "logs:PutLogEvents", + "logs:DescribeLogStreams", + "logs:DescribeLogGroups", + "logs:CreateLogStream" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameter" + ], + "Resource": "${ssm_parameter_arn}/*" + } + ] +} diff --git a/modules/runners/providers/ec2/policies/instance-create-tags-policy.json b/modules/compute-providers/ec2/policies/instance-create-tags-policy.json similarity index 100% rename from modules/runners/providers/ec2/policies/instance-create-tags-policy.json rename to modules/compute-providers/ec2/policies/instance-create-tags-policy.json diff --git a/modules/compute-providers/ec2/policies/instance-describe-tags-policy.json b/modules/compute-providers/ec2/policies/instance-describe-tags-policy.json new file mode 100644 index 0000000000..474cd65023 --- /dev/null +++ b/modules/compute-providers/ec2/policies/instance-describe-tags-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "ec2:DescribeTags", + "Resource": "*" + } + ] +} diff --git a/modules/runners/providers/ec2/policies/instance-ec2.json b/modules/compute-providers/ec2/policies/instance-ec2.json similarity index 100% rename from modules/runners/providers/ec2/policies/instance-ec2.json rename to modules/compute-providers/ec2/policies/instance-ec2.json diff --git a/modules/runners/providers/ec2/policies/instance-role-trust-policy.json b/modules/compute-providers/ec2/policies/instance-role-trust-policy.json similarity index 100% rename from modules/runners/providers/ec2/policies/instance-role-trust-policy.json rename to modules/compute-providers/ec2/policies/instance-role-trust-policy.json diff --git a/modules/runners/providers/ec2/policies/instance-s3-policy.json b/modules/compute-providers/ec2/policies/instance-s3-policy.json similarity index 100% rename from modules/runners/providers/ec2/policies/instance-s3-policy.json rename to modules/compute-providers/ec2/policies/instance-s3-policy.json diff --git a/modules/runners/providers/ec2/policies/instance-ssm-parameters-policy.json b/modules/compute-providers/ec2/policies/instance-ssm-parameters-policy.json similarity index 100% rename from modules/runners/providers/ec2/policies/instance-ssm-parameters-policy.json rename to modules/compute-providers/ec2/policies/instance-ssm-parameters-policy.json diff --git a/modules/compute-providers/ec2/policies/instance-ssm-policy.json b/modules/compute-providers/ec2/policies/instance-ssm-policy.json new file mode 100644 index 0000000000..2587c0a2e8 --- /dev/null +++ b/modules/compute-providers/ec2/policies/instance-ssm-policy.json @@ -0,0 +1,46 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "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" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2messages:AcknowledgeMessage", + "ec2messages:DeleteMessage", + "ec2messages:FailMessage", + "ec2messages:GetEndpoint", + "ec2messages:GetMessages", + "ec2messages:SendReply" + ], + "Resource": "*" + } + ] +} diff --git a/modules/runners/providers/ec2/policies/lambda-pool.json b/modules/compute-providers/ec2/policies/lambda-pool.json similarity index 100% rename from modules/runners/providers/ec2/policies/lambda-pool.json rename to modules/compute-providers/ec2/policies/lambda-pool.json diff --git a/modules/runners/providers/ec2/policies/lambda-scale-down.json b/modules/compute-providers/ec2/policies/lambda-scale-down.json similarity index 100% rename from modules/runners/providers/ec2/policies/lambda-scale-down.json rename to modules/compute-providers/ec2/policies/lambda-scale-down.json diff --git a/modules/runners/providers/ec2/policies/lambda-scale-up.json b/modules/compute-providers/ec2/policies/lambda-scale-up.json similarity index 100% rename from modules/runners/providers/ec2/policies/lambda-scale-up.json rename to modules/compute-providers/ec2/policies/lambda-scale-up.json diff --git a/modules/runners/providers/ec2/policies/service-linked-role-create-policy.json b/modules/compute-providers/ec2/policies/service-linked-role-create-policy.json similarity index 100% rename from modules/runners/providers/ec2/policies/service-linked-role-create-policy.json rename to modules/compute-providers/ec2/policies/service-linked-role-create-policy.json diff --git a/modules/runners/providers/ec2/runner-config.tf b/modules/compute-providers/ec2/runner-config.tf similarity index 100% rename from modules/runners/providers/ec2/runner-config.tf rename to modules/compute-providers/ec2/runner-config.tf diff --git a/modules/compute-providers/ec2/templates/cloudwatch_config.json b/modules/compute-providers/ec2/templates/cloudwatch_config.json new file mode 100644 index 0000000000..47b9bede8a --- /dev/null +++ b/modules/compute-providers/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/runners/providers/ec2/templates/install-runner-osx.sh b/modules/compute-providers/ec2/templates/install-runner-osx.sh similarity index 100% rename from modules/runners/providers/ec2/templates/install-runner-osx.sh rename to modules/compute-providers/ec2/templates/install-runner-osx.sh diff --git a/modules/runners/providers/ec2/templates/install-runner.ps1 b/modules/compute-providers/ec2/templates/install-runner.ps1 similarity index 100% rename from modules/runners/providers/ec2/templates/install-runner.ps1 rename to modules/compute-providers/ec2/templates/install-runner.ps1 diff --git a/modules/runners/providers/ec2/templates/install-runner.sh b/modules/compute-providers/ec2/templates/install-runner.sh similarity index 100% rename from modules/runners/providers/ec2/templates/install-runner.sh rename to modules/compute-providers/ec2/templates/install-runner.sh diff --git a/modules/runners/providers/ec2/templates/start-runner-osx.sh b/modules/compute-providers/ec2/templates/start-runner-osx.sh similarity index 100% rename from modules/runners/providers/ec2/templates/start-runner-osx.sh rename to modules/compute-providers/ec2/templates/start-runner-osx.sh diff --git a/modules/runners/providers/ec2/templates/start-runner.ps1 b/modules/compute-providers/ec2/templates/start-runner.ps1 similarity index 100% rename from modules/runners/providers/ec2/templates/start-runner.ps1 rename to modules/compute-providers/ec2/templates/start-runner.ps1 diff --git a/modules/runners/providers/ec2/templates/start-runner.sh b/modules/compute-providers/ec2/templates/start-runner.sh similarity index 100% rename from modules/runners/providers/ec2/templates/start-runner.sh rename to modules/compute-providers/ec2/templates/start-runner.sh diff --git a/modules/runners/providers/ec2/templates/user-data-osx.sh b/modules/compute-providers/ec2/templates/user-data-osx.sh similarity index 100% rename from modules/runners/providers/ec2/templates/user-data-osx.sh rename to modules/compute-providers/ec2/templates/user-data-osx.sh diff --git a/modules/compute-providers/ec2/templates/user-data.ps1 b/modules/compute-providers/ec2/templates/user-data.ps1 new file mode 100644 index 0000000000..a1e3a4da66 --- /dev/null +++ b/modules/compute-providers/ec2/templates/user-data.ps1 @@ -0,0 +1,47 @@ + +$ErrorActionPreference = "Continue" +$VerbosePreference = "Continue" +Start-Transcript -Path "C:\UserData.log" -Append + +${pre_install} + +# Install Chocolatey +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +$env:chocolateyUseWindowsCompression = 'true' +Invoke-WebRequest https://chocolatey.org/install.ps1 -UseBasicParsing | Invoke-Expression + +# Add Chocolatey to powershell profile +$ChocoProfileValue = @' +$ChocolateyProfile = "$env:ChocolateyInstall\helpers\chocolateyProfile.psm1" +if (Test-Path($ChocolateyProfile)) { + Import-Module "$ChocolateyProfile" +} + +refreshenv +'@ +# Write it to the $profile location +Set-Content -Path "$PsHome\Microsoft.PowerShell_profile.ps1" -Value $ChocoProfileValue -Force +# Source it +. "$PsHome\Microsoft.PowerShell_profile.ps1" + + +refreshenv + +Write-Host "Installing cloudwatch agent..." +Invoke-WebRequest -Uri https://s3.amazonaws.com/amazoncloudwatch-agent/windows/amd64/latest/amazon-cloudwatch-agent.msi -OutFile C:\amazon-cloudwatch-agent.msi +$cloudwatchParams = '/i', 'C:\amazon-cloudwatch-agent.msi', '/qn', '/L*v', 'C:\CloudwatchInstall.log' +Start-Process "msiexec.exe" $cloudwatchParams -Wait -NoNewWindow +Remove-Item C:\amazon-cloudwatch-agent.msi + + +# Install dependent tools +Write-Host "Installing additional development tools" +choco install git awscli -y +refreshenv + +${install_runner} +${post_install} +${start_runner} + +Stop-Transcript + diff --git a/modules/runners/providers/ec2/templates/user-data.sh b/modules/compute-providers/ec2/templates/user-data.sh similarity index 100% rename from modules/runners/providers/ec2/templates/user-data.sh rename to modules/compute-providers/ec2/templates/user-data.sh diff --git a/modules/runners/providers/ec2/tests/provider.tftest.hcl b/modules/compute-providers/ec2/tests/provider.tftest.hcl similarity index 60% rename from modules/runners/providers/ec2/tests/provider.tftest.hcl rename to modules/compute-providers/ec2/tests/provider.tftest.hcl index c43ac7af60..3d1af38aa5 100644 --- a/modules/runners/providers/ec2/tests/provider.tftest.hcl +++ b/modules/compute-providers/ec2/tests/provider.tftest.hcl @@ -44,46 +44,61 @@ variables { } } -run "exports_ec2_only_control_plane_fragments" { +run "separates_control_plane_contract_from_ec2_resources" { command = plan assert { - condition = output.provider.type == "ec2" + condition = output.control_plane.type == "ec2" error_message = "The provider contract must identify EC2." } assert { - condition = output.provider.scale_up.environment_variables["INSTANCE_TYPES"] == "m5.large" + condition = output.control_plane.scale_up.environment_variables["INSTANCE_TYPES"] == "m5.large" error_message = "The provider contract must expose EC2 scale-up environment variables." } assert { - condition = output.provider.scale_down.environment_variables["RUNNER_BOOT_TIME_IN_MINUTES"] == 5 + condition = output.control_plane.scale_down.environment_variables["RUNNER_BOOT_TIME_IN_MINUTES"] == 5 error_message = "The provider contract must expose the EC2 scale-down boot grace period." } assert { - condition = strcontains(output.provider.scale_up.iam_policy_json, "ec2:RunInstances") + condition = strcontains(output.control_plane.scale_up.iam_policy_json, "ec2:RunInstances") error_message = "The EC2 provider must own EC2 scale-up permissions." } assert { - condition = !strcontains(output.provider.scale_up.iam_policy_json, "sqs:ReceiveMessage") + condition = !strcontains(output.control_plane.scale_up.iam_policy_json, "sqs:ReceiveMessage") error_message = "The EC2 provider must not own common build-queue permissions." } assert { - condition = strcontains(output.provider.pool.iam_policy_json, "iam:PassRole") + condition = strcontains(output.control_plane.pool.iam_policy_json, "iam:PassRole") error_message = "The EC2 provider must expose pool permissions for its runner role." } assert { - condition = output.provider.scale_up.managed_policy_enabled + condition = output.control_plane.scale_up.managed_policy_enabled error_message = "An external AMI SSM parameter must enable the scale-up managed policy attachment at plan time." } assert { - condition = output.provider.pool.managed_policy_enabled + condition = output.control_plane.pool.managed_policy_enabled error_message = "An external AMI SSM parameter must enable the pool managed policy attachment at plan time." } + + assert { + condition = !contains(keys(output.control_plane), "launch_template") + error_message = "The common control-plane contract must not expose EC2 resources." + } + + assert { + condition = toset(keys(output.resources)) == toset(["launch_template", "role_runner", "runners_log_groups", "logfiles"]) + error_message = "EC2-specific artifacts must be exposed only through the resources output." + } + + assert { + condition = !contains(keys(output.resources), "scale_up") + error_message = "The EC2 resources output must not contain the common control-plane contract." + } } diff --git a/modules/runners/providers/ec2/variables.tf b/modules/compute-providers/ec2/variables.tf similarity index 100% rename from modules/runners/providers/ec2/variables.tf rename to modules/compute-providers/ec2/variables.tf diff --git a/modules/runners/providers/ec2/versions.tf b/modules/compute-providers/ec2/versions.tf similarity index 100% rename from modules/runners/providers/ec2/versions.tf rename to modules/compute-providers/ec2/versions.tf diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 639b5fd442..4c67cfd476 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -8,9 +8,15 @@ The module takes a configuration as input containing a matcher for the labels. T ## Provider boundary -The multi-runner module owns provider-neutral lane normalization, queues, shared runner-binary discovery, and webhook routing. Each normalized lane is deployed through `modules/runners`, which selects its internal compute provider with `provider_type`. The EC2 child owns EC2 compute, runner-instance IAM, bootstrap, and provider-specific Lambda fragments; the shared runner module owns scale-up, scale-down, pool, retry, and Lambda roles. +The multi-runner module owns provider-neutral lane normalization, queues, shared runner-binary discovery, and webhook routing. Stable `multi_runner_config` lanes continue to use the existing `modules/runners` module at their historical `module.runners["lane"]` addresses. The stable module and its state layout are not part of the experimental provider refactor. -Both the stable `multi_runner_config` input and experimental `multi_runner_config_v2` input use this same boundary. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. Existing lane keys and common control-plane addresses remain stable. Internal `moved` blocks migrate only the resources that are now owned by the EC2 child. +Setting `multi_runner_config_v2` opts all selected lanes into `modules/runner-stack` at `module.runner_stacks["lane"]`. That stack owns the common scale-up, scale-down, pool, retry, and Lambda-role resources and dispatches provider-specific compute, IAM, bootstrap, and Lambda fragments using `provider.type`. The v1 and v2 runner module maps are mutually exclusive, while queue, webhook, and public output lane keys remain unchanged. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. + +Stable v1 entries in `runners_map` retain their existing flat output shape. Experimental v2 entries keep common control-plane Lambda and role outputs at the lane level, while compute-provider resources are available only under `provider`. For EC2 lanes, use `runners_map["lane"].provider.ec2.launch_template`, `.role_runner`, `.runners_log_groups`, and `.logfiles`; the corresponding flat v1 attributes are intentionally not duplicated in v2 entries. + +### Experimental v2 migration + +`multi_runner_config_v2` is experimental. Deployments that already used an earlier v2 implementation may have runner state at `module.runners["lane"]`; the provider-oriented stack uses `module.runner_stacks["lane"]` instead. Terraform cannot apply a generic automatic move because the original `module.runners` address remains reserved for stable v1 deployments. Review the plan and migrate experimental v2 state explicitly before upgrading, or allow those experimental runner resources to be replaced. Deployments that continue using stable `multi_runner_config` require no address migration. For each configuration: @@ -102,6 +108,7 @@ module "multi-runner" { | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | +| [runner\_stacks](#module\_runner\_stacks) | ../runner-stack | n/a | | [runners](#module\_runners) | ../runners | n/a | | [ssm](#module\_ssm) | ../ssm | n/a | | [webhook](#module\_webhook) | ../webhook | n/a | diff --git a/modules/multi-runner/multi-runner-config.tf b/modules/multi-runner/multi-runner-config.tf index 7332fd3d5c..e657999fbe 100644 --- a/modules/multi-runner/multi-runner-config.tf +++ b/modules/multi-runner/multi-runner-config.tf @@ -80,7 +80,8 @@ locals { } } - multi_runner_config = length(var.multi_runner_config_v2) > 0 ? var.multi_runner_config_v2 : local.multi_runner_config_v1_as_v2 + use_multi_runner_config_v2 = length(var.multi_runner_config_v2) > 0 + multi_runner_config = local.use_multi_runner_config_v2 ? var.multi_runner_config_v2 : local.multi_runner_config_v1_as_v2 runner_extra_labels = { for k, v in local.multi_runner_config : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.runner_extra_labels))) @@ -96,6 +97,18 @@ locals { }) } + # Keep stable v1 lanes at their historical module.runners addresses. The + # experimental v2 input opts into the new provider-oriented runner stack. + runner_config_v1 = { + for k, v in local.runner_config : k => v + if !local.use_multi_runner_config_v2 + } + + runner_config_v2 = { + for k, v in local.runner_config : k => v + if local.use_multi_runner_config_v2 + } + runner_matcher_config = { for k, v in local.runner_config : k => { id = v.id diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index a08e3a7ff2..a93a1ad34b 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -1,24 +1,39 @@ output "runners_map" { - value = { for runner_key, runner in module.runners : runner_key => { - launch_template_name = try(runner.launch_template.name, null) - launch_template_id = try(runner.launch_template.id, null) - launch_template_version = try(runner.launch_template.latest_version, null) - launch_template_ami_id = try(runner.launch_template.image_id, null) - lambda_up = runner.lambda_scale_up - lambda_up_log_group = runner.lambda_scale_up_log_group - lambda_down = runner.lambda_scale_down - lambda_down_log_group = runner.lambda_scale_down_log_group - lambda_pool = runner.lambda_pool - lambda_pool_log_group = runner.lambda_pool_log_group - role_runner = runner.role_runner - role_scale_up = runner.role_scale_up - role_scale_down = runner.role_scale_down - role_pool = runner.role_pool - runners_log_groups = runner.runners_log_groups - logfiles = runner.logfiles + value = merge( + { for runner_key, runner in module.runners : runner_key => { + launch_template_name = try(runner.launch_template.name, null) + launch_template_id = try(runner.launch_template.id, null) + launch_template_version = try(runner.launch_template.latest_version, null) + launch_template_ami_id = try(runner.launch_template.image_id, null) + lambda_up = runner.lambda_scale_up + lambda_up_log_group = runner.lambda_scale_up_log_group + lambda_down = runner.lambda_scale_down + lambda_down_log_group = runner.lambda_scale_down_log_group + lambda_pool = runner.lambda_pool + lambda_pool_log_group = runner.lambda_pool_log_group + role_runner = runner.role_runner + role_scale_up = runner.role_scale_up + role_scale_down = runner.role_scale_down + role_pool = runner.role_pool + runners_log_groups = runner.runners_log_groups + logfiles = runner.logfiles + } + }, + { for runner_key, runner in module.runner_stacks : runner_key => { + provider = runner.provider + lambda_up = runner.lambda_scale_up + lambda_up_log_group = runner.lambda_scale_up_log_group + lambda_down = runner.lambda_scale_down + lambda_down_log_group = runner.lambda_scale_down_log_group + lambda_pool = runner.lambda_pool + lambda_pool_log_group = runner.lambda_pool_log_group + role_scale_up = runner.role_scale_up + role_scale_down = runner.role_scale_down + role_pool = runner.role_pool + } } - } + ) } output "binaries_syncer_map" { diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index e70d2a27d5..bcae53c1e5 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -1,6 +1,138 @@ module "runners" { - source = "../runners" - for_each = local.runner_config + source = "../runners" + for_each = local.runner_config_v1 + + aws_region = var.aws_region + aws_partition = var.aws_partition + vpc_id = coalesce(each.value.provider.ec2.vpc_id, var.vpc_id) + subnet_ids = coalesce(each.value.provider.ec2.subnet_ids, var.subnet_ids) + prefix = "${var.prefix}-${each.key}" + tags = merge(local.tags, { + "ghr:environment" = "${var.prefix}-${each.key}" + }) + + s3_runner_binaries = each.value.provider.ec2.enable_runner_binaries_syncer ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.runner_os}_${each.value.runner.runner_architecture}"] : null + + ssm_paths = { + root = "${local.ssm_root_path}/${each.key}" + tokens = "${var.ssm_paths.runners}/tokens" + config = "${var.ssm_paths.runners}/config" + } + + runner_os = each.value.runner.runner_os + instance_types = each.value.provider.ec2.instance_types + instance_target_capacity_type = each.value.provider.ec2.instance_target_capacity_type + instance_allocation_strategy = each.value.provider.ec2.instance_allocation_strategy + instance_type_priorities = each.value.provider.ec2.instance_type_priorities + instance_max_spot_price = each.value.provider.ec2.instance_max_spot_price + block_device_mappings = each.value.provider.ec2.block_device_mappings + + runner_architecture = each.value.runner.runner_architecture + ami = each.value.provider.ec2.ami + + sqs_build_queue = { "arn" : each.value.arn, "url" : each.value.url } + github_app_parameters = local.github_app_parameters + ebs_optimized = each.value.provider.ec2.ebs_optimized + enable_on_demand_failover_for_errors = each.value.provider.ec2.enable_on_demand_failover_for_errors + scale_errors = each.value.provider.ec2.scale_errors + enable_organization_runners = each.value.runner.enable_organization_runners + enable_ephemeral_runners = each.value.runner.enable_ephemeral_runners + enable_jit_config = each.value.runner.enable_jit_config + enable_job_queued_check = each.value.runner.enable_job_queued_check + disable_runner_autoupdate = each.value.runner.disable_runner_autoupdate + enable_managed_runner_security_group = var.enable_managed_runner_security_group + enable_runner_detailed_monitoring = each.value.provider.ec2.enable_runner_detailed_monitoring + scale_down_schedule_expression = each.value.runner.scale_down_schedule_expression + minimum_running_time_in_minutes = each.value.runner.minimum_running_time_in_minutes + runner_boot_time_in_minutes = each.value.runner.runner_boot_time_in_minutes + runner_disable_default_labels = each.value.runner.runner_disable_default_labels + runner_labels = each.value.runner.runner_disable_default_labels ? sort(distinct(each.value.runner.runner_extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.runner_os, each.value.runner.runner_architecture], each.value.runner.runner_extra_labels))) + runner_as_root = each.value.runner.runner_as_root + runner_run_as = each.value.runner.runner_run_as + runners_maximum_count = each.value.runner.runners_maximum_count + idle_config = each.value.runner.idle_config + enable_ssm_on_runners = each.value.provider.ec2.enable_ssm_on_runners + egress_rules = var.runner_egress_rules + runner_additional_security_group_ids = try(coalescelist(each.value.provider.ec2.runner_additional_security_group_ids, var.runner_additional_security_group_ids), []) + metadata_options = each.value.provider.ec2.runner_metadata_options + credit_specification = each.value.provider.ec2.credit_specification + cpu_options = each.value.provider.ec2.cpu_options + placement = each.value.provider.ec2.placement + license_specifications = each.value.provider.ec2.license_specifications + use_dedicated_host = each.value.provider.ec2.use_dedicated_host + + enable_runner_binaries_syncer = each.value.provider.ec2.enable_runner_binaries_syncer + lambda_s3_bucket = var.lambda_s3_bucket + runners_lambda_s3_key = var.runners_lambda_s3_key + runners_lambda_s3_object_version = var.runners_lambda_s3_object_version + lambda_runtime = var.lambda_runtime + lambda_architecture = var.lambda_architecture + lambda_zip = var.runners_lambda_zip + lambda_scale_up_memory_size = var.scale_up_lambda_memory_size + lambda_event_source_mapping_batch_size = coalesce(each.value.queue.lambda_event_source_mapping_batch_size, var.lambda_event_source_mapping_batch_size) + lambda_event_source_mapping_maximum_batching_window_in_seconds = coalesce(each.value.queue.lambda_event_source_mapping_maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) + lambda_timeout_scale_up = var.runners_scale_up_lambda_timeout + lambda_scale_down_memory_size = var.scale_down_lambda_memory_size + lambda_timeout_scale_down = var.runners_scale_down_lambda_timeout + lambda_subnet_ids = var.lambda_subnet_ids + lambda_security_group_ids = var.lambda_security_group_ids + lambda_tags = var.lambda_tags + tracing_config = var.tracing_config + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + log_class = var.log_class + enable_cloudwatch_agent = each.value.provider.ec2.enable_cloudwatch_agent + cloudwatch_config = try(coalesce(each.value.provider.ec2.cloudwatch_config, var.cloudwatch_config), null) + runner_log_files = each.value.provider.ec2.runner_log_files + runner_group_name = each.value.runner.runner_group_name + runner_name_prefix = each.value.runner.runner_name_prefix + parameter_store_tags = var.parameter_store_tags + + scale_up_reserved_concurrent_executions = each.value.runner.scale_up_reserved_concurrent_executions + + instance_profile_path = var.instance_profile_path + role_path = var.role_path + role_permissions_boundary = var.role_permissions_boundary + + enable_userdata = each.value.provider.ec2.enable_userdata + userdata_template = each.value.provider.ec2.userdata_template + userdata_content = each.value.provider.ec2.userdata_content + userdata_pre_install = each.value.provider.ec2.userdata_pre_install + userdata_post_install = each.value.provider.ec2.userdata_post_install + runner_hook_job_started = each.value.provider.ec2.runner_hook_job_started + runner_hook_job_completed = each.value.provider.ec2.runner_hook_job_completed + key_name = var.key_name + runner_ec2_tags = each.value.provider.ec2.runner_ec2_tags + + create_service_linked_role_spot = each.value.provider.ec2.create_service_linked_role_spot + + runner_iam_role_managed_policy_arns = each.value.provider.ec2.runner_iam_role_managed_policy_arns + iam_overrides = each.value.provider.ec2.iam_overrides + + ghes_url = var.ghes_url + ghes_ssl_verify = var.ghes_ssl_verify + user_agent = var.user_agent + + kms_key_arn = var.kms_key_arn + + log_level = var.log_level + + pool_config = each.value.runner.pool_config + pool_lambda_timeout = var.pool_lambda_timeout + pool_runner_owner = each.value.runner.pool_runner_owner + pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions + associate_public_ipv4_address = var.associate_public_ipv4_address + + ssm_housekeeper = var.runners_ssm_housekeeper + + job_retry = each.value.runner.job_retry + + metrics = var.metrics +} + +module "runner_stacks" { + source = "../runner-stack" + for_each = local.runner_config_v2 provider_type = each.value.runnerProvider aws_region = var.aws_region diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 8eab304ad0..b2a8539a7d 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -25,7 +25,7 @@ variables { runners_lambda_s3_key = "runners.zip" } -run "stable_v1_routes_through_ec2_provider" { +run "stable_v1_keeps_legacy_runner_module" { command = plan variables { @@ -52,8 +52,13 @@ run "stable_v1_routes_through_ec2_provider" { } assert { - condition = keys(module.runners) == keys(local.runner_config) - error_message = "The common runners facade must dispatch every normalized lane." + condition = keys(local.runner_config_v1) == ["linux"] && length(local.runner_config_v2) == 0 + error_message = "Stable multi_runner_config lanes must remain isolated in the v1 lane map." + } + + assert { + condition = keys(module.runners) == ["linux"] && length(module.runner_stacks) == 0 + error_message = "Stable multi_runner_config lanes must retain the historical module.runners address." } assert { @@ -65,9 +70,33 @@ run "stable_v1_routes_through_ec2_provider" { condition = keys(output.runners_map) == ["linux"] error_message = "Stable multi_runner_config must preserve the public runner map key." } + + assert { + condition = toset(keys(output.runners_map["linux"])) == toset( + [ + "launch_template_name", + "launch_template_id", + "launch_template_version", + "launch_template_ami_id", + "lambda_up", + "lambda_up_log_group", + "lambda_down", + "lambda_down_log_group", + "lambda_pool", + "lambda_pool_log_group", + "role_runner", + "role_scale_up", + "role_scale_down", + "role_pool", + "runners_log_groups", + "logfiles", + ] + ) + error_message = "Stable multi_runner_config must retain its existing flat runners_map entry shape." + } } -run "experimental_v2_routes_through_ec2_provider" { +run "experimental_v2_routes_through_provider_stack" { command = plan variables { @@ -109,8 +138,13 @@ run "experimental_v2_routes_through_ec2_provider" { } assert { - condition = keys(module.runners) == keys(local.runner_config) - error_message = "The common runners facade must dispatch every normalized experimental lane." + condition = length(local.runner_config_v1) == 0 && keys(local.runner_config_v2) == ["linux"] + error_message = "Experimental multi_runner_config_v2 lanes must remain isolated in the v2 lane map." + } + + assert { + condition = length(module.runners) == 0 && keys(module.runner_stacks) == ["linux"] + error_message = "Experimental multi_runner_config_v2 lanes must dispatch through module.runner_stacks." } assert { @@ -123,6 +157,45 @@ run "experimental_v2_routes_through_ec2_provider" { error_message = "Experimental multi_runner_config_v2 must preserve the public runner map key." } + assert { + condition = toset(keys(output.runners_map["linux"])) == toset( + [ + "provider", + "lambda_up", + "lambda_up_log_group", + "lambda_down", + "lambda_down_log_group", + "lambda_pool", + "lambda_pool_log_group", + "role_scale_up", + "role_scale_down", + "role_pool", + ] + ) + error_message = "Experimental v2 runners_map entries must expose provider resources only through the nested provider object." + } + + assert { + condition = ( + output.runners_map["linux"].provider.type == "ec2" + && can(output.runners_map["linux"].provider.ec2.launch_template) + && can(output.runners_map["linux"].provider.ec2.role_runner) + && can(output.runners_map["linux"].provider.ec2.runners_log_groups) + && can(output.runners_map["linux"].provider.ec2.logfiles) + ) + error_message = "Experimental v2 EC2 resources must be available under runners_map..provider.ec2." + } + + assert { + condition = ( + !can(output.runners_map["linux"].launch_template_name) + && !can(output.runners_map["linux"].role_runner) + && !can(output.runners_map["linux"].runners_log_groups) + && !can(output.runners_map["linux"].logfiles) + ) + error_message = "Experimental v2 entries must not duplicate provider resources as flat output attributes." + } + assert { condition = local.runner_config_by_provider.ec2["linux"].runner.idle_config[0].idleCount == 1 error_message = "Provider-neutral idle configuration must remain in the common runner contract." @@ -134,6 +207,69 @@ run "experimental_v2_routes_through_ec2_provider" { } } +run "experimental_v2_takes_precedence_without_legacy_addresses" { + command = plan + + variables { + multi_runner_config = { + legacy = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 2 + enable_runner_binaries_syncer = false + enable_organization_runners = true + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + + multi_runner_config_v2 = { + experimental = { + runner = { + runner_os = "linux" + runner_architecture = "x64" + runners_maximum_count = 2 + enable_organization_runners = true + } + provider = { + type = "ec2" + ec2 = { + instance_types = ["m5.large"] + enable_runner_binaries_syncer = false + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "experimental"]] + } + } + } + } + + assert { + condition = length(module.runners) == 0 && keys(module.runner_stacks) == ["experimental"] + error_message = "Setting multi_runner_config_v2 must not instantiate any stable v1 runner modules." + } + + assert { + condition = keys(aws_sqs_queue.queued_builds) == ["experimental"] && keys(local.runner_matcher_config) == ["experimental"] + error_message = "Queues and webhook routing must use only the selected v2 lane keys." + } + + assert { + condition = keys(output.runners_map) == ["experimental"] + error_message = "The merged public runner map must expose only the selected v2 lane keys." + } + + assert { + condition = output.runners_map["experimental"].provider.type == "ec2" && can(output.runners_map["experimental"].provider.ec2.launch_template) + error_message = "The selected v2 lane must retain its nested EC2 provider output." + } +} + run "experimental_v2_rejects_future_providers" { command = plan diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md new file mode 100644 index 0000000000..89d409e8d6 --- /dev/null +++ b/modules/runner-stack/README.md @@ -0,0 +1,246 @@ +# Module - Scale runners + +> This module is treated as internal module, breaking changes will not trigger a major release bump. + +This module creates a provider-neutral runner control plane and selects its compute implementation with `provider_type`. EC2 is the only active provider today and remains the default. + +The common layer owns scale-up, scale-down, pool, job retry, Lambda execution roles, shared SSM configuration, and the SSM housekeeper. [`../compute-providers/ec2`](../compute-providers/ec2) owns the launch template, runner instance role and policies, EC2 bootstrap parameters, runner log groups, and the EC2 environment and IAM fragments merged into the common Lambda resources. Future providers can implement the same output contract without copying the control plane. + +## Overview + +### Action runners on EC2 + +The action runners are created via a launch template; in the launch template only the subnet needs to be provided. During launch the installation is handled via a user data script. The configuration is fetched from SSM parameter store. + +### Lambda scale up + +The scale up lambda is triggered by events on a SQS queue. Events on this queue are delayed, which will give the workflow some time to start running on available runners. For each event the lambda will check if the workflow is still queued and no other limits are reached. In that case the lambda will create a new EC2 instance. The lambda only needs to know which launch template to use and which subnets are available. From the available subnets a random one will be chosen. Once the instance is created the event is assumed as handled, and we assume the workflow wil start at some moment once the created instance is ready. + +### Lambda scale down + +The scale down lambda is triggered via a CloudWatch event. The event is triggered by a cron expression defined in the variable `scale_down_schedule_expression` (https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html). For scaling down GitHub does not provide a good API yet, therefore we run the scaling down based on this event every x minutes. Each time the lambda is triggered it tries to remove all runners older than x minutes (configurable) managed in this deployment. In case the runner can be removed from GitHub, which means it is not executing a workflow, the lambda will terminate the EC2 instance. + +--8<-- "modules/runner-stack/scale-down-state-diagram.md:mkdocs_scale_down_state_diagram" + +## Lambda Function + +The Lambda function is written in [TypeScript](https://www.typescriptlang.org/) and requires Node 12.x and yarn. Sources are located in [./lambdas/runners]. Two lambda functions share the same sources, there is one entry point for `scaleDown` and another one for `scaleUp`. + +### Install + +```bash +cd lambdas/runners +yarn install +``` + +### Test + +Test are implemented with [vitest][https://vitest.dev/]), calls to AWS and GitHub are mocked. + +```bash +yarn run test +``` + +### Package + +To compile all TypeScript/JavaScript sources in a single file [ncc](https://github.com/zeit/ncc) is used. + +```bash +yarn run dist +``` + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | +| [job\_retry](#module\_job\_retry) | ./job-retry | n/a | +| [pool](#module\_pool) | ./pool | n/a | + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_event_target.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_log_group.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_cloudwatch_log_group.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_cloudwatch_log_group.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.job_retry_sqs_publish](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_down_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_down_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_up_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_up_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.service_linked_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ssm_housekeeper_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ssm_housekeeper_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.scale_down_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.scale_up_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambda_event_source_mapping.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | +| [aws_lambda_function.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_function.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_function.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_permission.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_lambda_permission.scale_runners_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_lambda_permission.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_ssm_parameter.disable_default_labels](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.jit_config_enabled](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.runner_agent_mode](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.token_path](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_xray](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 | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | +| [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | +| [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | +| [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | +| [block\_device\_mappings](#input\_block\_device\_mappings) | The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`. |
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
}
]
| no | +| [cloudwatch\_config](#input\_cloudwatch\_config) | (optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details. | `string` | `null` | no | +| [cpu\_options](#input\_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 | +| [create\_service\_linked\_role\_spot](#input\_create\_service\_linked\_role\_spot) | (optional) create the service linked role for spot instances that is required by the scale-up lambda. | `bool` | `false` | no | +| [credit\_specification](#input\_credit\_specification) | The credit option for CPU usage of a T instance. Can be unset, "standard" or "unlimited". | `string` | `null` | no | +| [disable\_runner\_autoupdate](#input\_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/) | `bool` | `false` | no | +| [ebs\_optimized](#input\_ebs\_optimized) | The EC2 EBS optimized configuration. | `bool` | `false` | no | +| [egress\_rules](#input\_egress\_rules) | List of egress rules for the GitHub runner instances. |
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"
],
"description": null,
"from_port": 0,
"ipv6_cidr_blocks": [
"::/0"
],
"prefix_list_ids": null,
"protocol": "-1",
"security_groups": null,
"self": null,
"to_port": 0
}
]
| no | +| [enable\_cloudwatch\_agent](#input\_enable\_cloudwatch\_agent) | Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`. | `bool` | `true` | no | +| [enable\_ephemeral\_runners](#input\_enable\_ephemeral\_runners) | Enable ephemeral runners, runners will only be used once. | `bool` | `false` | no | +| [enable\_jit\_config](#input\_enable\_jit\_config) | Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI. | `bool` | `null` | no | +| [enable\_job\_queued\_check](#input\_enable\_job\_queued\_check) | Only scale if the job event received by the scale up lambda is is in the state queued. By default enabled for non ephemeral runners and disabled for ephemeral. Set this variable to overwrite the default behavior. | `bool` | `null` | no | +| [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | +| [enable\_on\_demand\_failover\_for\_errors](#input\_enable\_on\_demand\_failover\_for\_errors) | Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later. | `list(string)` | `[]` | no | +| [enable\_organization\_runners](#input\_enable\_organization\_runners) | Register runners to organization, instead of repo level | `bool` | n/a | yes | +| [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI. | `bool` | `true` | no | +| [enable\_runner\_detailed\_monitoring](#input\_enable\_runner\_detailed\_monitoring) | Enable detailed monitoring for runners | `bool` | `false` | no | +| [enable\_ssm\_on\_runners](#input\_enable\_ssm\_on\_runners) | Enable to allow access to the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances. | `bool` | n/a | yes | +| [enable\_user\_data\_debug\_logging](#input\_enable\_user\_data\_debug\_logging) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no | +| [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI | `bool` | `true` | no | +| [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | +| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | +| [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters. |
object({
key_base64 = map(string)
id = map(string)
})
| n/a | yes | +| [iam\_overrides](#input\_iam\_overrides) | This map provides the possibility to override some IAM defaults. The following attributes are supported: `instance_profile_name` overrides the instance profile name used in the launch template. `runner_role_arn` overrides the IAM role ARN used for the runner instances. |
object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
})
|
{
"instance_profile_name": null,
"override_instance_profile": false,
"override_runner_role": false,
"runner_role_arn": null
}
| no | +| [idle\_config](#input\_idle\_config) | List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle. |
list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
}))
| `[]` | no | +| [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | +| [instance\_max\_spot\_price](#input\_instance\_max\_spot\_price) | Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet. | `string` | `null` | no | +| [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the prefix will be used. | `string` | `null` | no | +| [instance\_target\_capacity\_type](#input\_instance\_target\_capacity\_type) | Default lifecycle used runner instances, can be either `spot` or `on-demand`. | `string` | `"spot"` | no | +| [instance\_type\_priorities](#input\_instance\_type\_priorities) | A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`. | `map(number)` | `null` | 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)` | `null` | no | +| [job\_retry](#input\_job\_retry) | 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\_reserved\_concurrent\_executions': Amount of reserved concurrent executions for the job retry check lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations.
`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_reserved_concurrent_executions = optional(number, 1)

lambda_timeout = optional(number, 30)

max_attempts = optional(number, 1)
})
| `{}` | no | +| [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no | +| [kms\_key\_arn](#input\_kms\_key\_arn) | Optional CMK Key ARN to be used for Parameter Store. | `string` | `null` | no | +| [lambda\_architecture](#input\_lambda\_architecture) | AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions. | `string` | `"arm64"` | no | +| [lambda\_event\_source\_mapping\_batch\_size](#input\_lambda\_event\_source\_mapping\_batch\_size) | Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default of 10 events will be used. | `number` | `10` | no | +| [lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds](#input\_lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds) | Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10. Defaults to 0. | `number` | `0` | no | +| [lambda\_runtime](#input\_lambda\_runtime) | AWS Lambda runtime. | `string` | `"nodejs24.x"` | no | +| [lambda\_s3\_bucket](#input\_lambda\_s3\_bucket) | S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly. | `string` | `null` | no | +| [lambda\_scale\_down\_memory\_size](#input\_lambda\_scale\_down\_memory\_size) | Memory size limit in MB for scale down lambda. | `number` | `512` | no | +| [lambda\_scale\_up\_memory\_size](#input\_lambda\_scale\_up\_memory\_size) | Memory size limit in MB for scale-up lambda. | `number` | `512` | no | +| [lambda\_security\_group\_ids](#input\_lambda\_security\_group\_ids) | List of security group IDs associated with the Lambda function. | `list(string)` | `[]` | no | +| [lambda\_subnet\_ids](#input\_lambda\_subnet\_ids) | List of subnets in which the lambda will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | `[]` | no | +| [lambda\_tags](#input\_lambda\_tags) | Map of tags that will be added to all the lambda function resources. Note these are additional tags to the default tags. | `map(string)` | `{}` | no | +| [lambda\_timeout\_scale\_down](#input\_lambda\_timeout\_scale\_down) | Time out for the scale down lambda in seconds. | `number` | `60` | no | +| [lambda\_timeout\_scale\_up](#input\_lambda\_timeout\_scale\_up) | Time out for the scale up lambda in seconds. | `number` | `60` | no | +| [lambda\_zip](#input\_lambda\_zip) | File location of the lambda zip file. | `string` | `null` | no | +| [license\_specifications](#input\_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 | +| [log\_class](#input\_log\_class) | The log class of the CloudWatch log groups for the lambda functions. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. | `string` | `"STANDARD"` | no | +| [log\_level](#input\_log\_level) | Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. | `string` | `"info"` | no | +| [logging\_kms\_key\_id](#input\_logging\_kms\_key\_id) | Specifies the kms key id to encrypt the logs with | `string` | `null` | no | +| [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 | +| [metadata\_options](#input\_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 | +| [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 | +| [minimum\_running\_time\_in\_minutes](#input\_minimum\_running\_time\_in\_minutes) | The time an ec2 action runner should be running at minimum before terminated if non busy. If not set the default is calculated based on the OS. | `number` | `null` | no | +| [overrides](#input\_overrides) | This map provides the possibility to override some defaults. The following attributes are supported: `name_sg` overrides the `Name` tag for all security groups created by this module. `name_runner_agent_instance` overrides the `Name` tag for the ec2 instance defined in the auto launch configuration. `name_docker_machine_runners` overrides the `Name` tag spot instances created by the runner agent. | `map(string)` |
{
"name_runner": "",
"name_sg": ""
}
| no | +| [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | +| [placement](#input\_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 | +| [pool\_config](#input\_pool\_config) | The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone ` to override the schedule time zone (defaults to UTC). |
list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
| `[]` | no | +| [pool\_include\_busy\_runners](#input\_pool\_include\_busy\_runners) | Include busy runners in the pool calculation. By default busy runners are not included in the pool. | `bool` | `false` | no | +| [pool\_lambda\_memory\_size](#input\_pool\_lambda\_memory\_size) | Lambda Memory size limit in MB for pool lambda | `number` | `512` | no | +| [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | +| [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | +| [pool\_runner\_owner](#input\_pool\_runner\_owner) | The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported. | `string` | `null` | no | +| [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no | +| [provider\_type](#input\_provider\_type) | Compute provider used by the common runner control plane. | `string` | `"ec2"` | no | +| [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the prefix will be used. | `string` | `null` | no | +| [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | +| [runner\_additional\_security\_group\_ids](#input\_runner\_additional\_security\_group\_ids) | (optional) List of additional security groups IDs to apply to the runner | `list(string)` | `[]` | no | +| [runner\_architecture](#input\_runner\_architecture) | The platform architecture of the runner instance\_type. | `string` | `"x64"` | no | +| [runner\_as\_root](#input\_runner\_as\_root) | Run the action runner under the root user. Variable `runner_run_as` will be ignored. | `bool` | `false` | 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\_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`. | `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 | +| [runner\_group\_name](#input\_runner\_group\_name) | Name of the runner group. | `string` | `"Default"` | no | +| [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\_labels](#input\_runner\_labels) | All the labels for the runners (GitHub) including the default one's(e.g: self-hosted, linux, x64, label1, label2). Separate each label by a comma | `list(string)` | n/a | yes | +| [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\_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, osx, windows). | `string` | `"linux"` | no | +| [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | +| [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for runners lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | +| [runners\_lambda\_s3\_object\_version](#input\_runners\_lambda\_s3\_object\_version) | S3 object version for runners lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no | +| [runners\_maximum\_count](#input\_runners\_maximum\_count) | The maximum number of runners that will be created. Setting the variable to `-1` desiables the maximum check. | `number` | `3` | no | +| [s3\_runner\_binaries](#input\_s3\_runner\_binaries) | Bucket details for cached GitHub binary. |
object({
arn = string
id = string
key = string
})
| n/a | yes | +| [scale\_down\_schedule\_expression](#input\_scale\_down\_schedule\_expression) | Scheduler expression to check every x for scale down. | `string` | `"cron(*/5 * * * ? *)"` | no | +| [scale\_errors](#input\_scale\_errors) | List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors | `list(string)` |
[
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]
| no | +| [scale\_up\_reserved\_concurrent\_executions](#input\_scale\_up\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | +| [sqs\_build\_queue](#input\_sqs\_build\_queue) | SQS queue to consume accepted build events. |
object({
arn = string
url = string
})
| n/a | yes | +| [ssm\_housekeeper](#input\_ssm\_housekeeper) | Configuration for the SSM housekeeper lambda. This lambda deletes token / JIT config from SSM.

`schedule_expression`: is used to configure the schedule for the lambda.
`state`: state of the cloudwatch event rule. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
`lambda_memory_size`: lambda memory size limit.
`lambda_timeout`: timeout for the lambda in seconds.
`config`: configuration for the lambda function. Token path will be read by default from the module. |
object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
lambda_memory_size = optional(number, 512)
lambda_timeout = optional(number, 60)
config = object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
})
})
|
{
"config": {}
}
| no | +| [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
tokens = string
config = string
})
| n/a | yes | +| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes | +| [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name. | `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) | Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly. | `bool` | `false` | no | +| [user\_agent](#input\_user\_agent) | User agent used for API calls. | `string` | `null` | 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) | User-data script snippet to insert after GitHub action runner install | `string` | `""` | no | +| [userdata\_pre\_install](#input\_userdata\_pre\_install) | User-data script snippet to insert before GitHub action runner install | `string` | `""` | no | +| [userdata\_template](#input\_userdata\_template) | Alternative user-data template file path, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored. | `string` | `null` | no | +| [vpc\_id](#input\_vpc\_id) | The VPC for the security groups. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [lambda\_pool](#output\_lambda\_pool) | n/a | +| [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a | +| [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a | +| [lambda\_scale\_down\_log\_group](#output\_lambda\_scale\_down\_log\_group) | n/a | +| [lambda\_scale\_up](#output\_lambda\_scale\_up) | n/a | +| [lambda\_scale\_up\_log\_group](#output\_lambda\_scale\_up\_log\_group) | n/a | +| [provider](#output\_provider) | Selected compute provider type and its provider-specific resources. | +| [role\_pool](#output\_role\_pool) | n/a | +| [role\_scale\_down](#output\_role\_scale\_down) | n/a | +| [role\_scale\_up](#output\_role\_scale\_up) | n/a | + diff --git a/modules/runner-stack/job-retry.tf b/modules/runner-stack/job-retry.tf new file mode 100644 index 0000000000..4adb27ad1a --- /dev/null +++ b/modules/runner-stack/job-retry.tf @@ -0,0 +1,44 @@ + +locals { + job_retry_enabled = var.job_retry != null && var.job_retry.enable ? true : false + + job_retry = { + prefix = var.prefix + tags = local.tags + aws_partition = var.aws_partition + architecture = var.lambda_architecture + runtime = var.lambda_runtime + security_group_ids = var.lambda_security_group_ids + subnet_ids = var.lambda_subnet_ids + kms_key_arn = var.kms_key_arn + lambda_tags = var.lambda_tags + log_level = var.log_level + log_class = var.log_class + logging_kms_key_id = var.logging_kms_key_id + logging_retention_in_days = var.logging_retention_in_days + metrics = var.metrics + role_path = var.role_path + role_permissions_boundary = var.role_permissions_boundary + s3_bucket = var.lambda_s3_bucket + s3_key = var.runners_lambda_s3_key + s3_object_version = var.runners_lambda_s3_object_version + zip = var.lambda_zip + tracing_config = var.tracing_config + github_app_parameters = var.github_app_parameters + enable_organization_runners = var.enable_organization_runners + runner_name_prefix = var.runner_name_prefix + sqs_build_queue = var.sqs_build_queue + ghes_url = var.ghes_url + lambda_event_source_mapping_batch_size = var.lambda_event_source_mapping_batch_size + lambda_event_source_mapping_maximum_batching_window_in_seconds = var.lambda_event_source_mapping_maximum_batching_window_in_seconds + memory_size = var.job_retry.lambda_memory_size + timeout = var.job_retry.lambda_timeout + } +} + +module "job_retry" { + source = "./job-retry" + count = local.job_retry_enabled ? 1 : 0 + + config = local.job_retry +} diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md new file mode 100644 index 0000000000..f57cc1dbfd --- /dev/null +++ b/modules/runner-stack/job-retry/README.md @@ -0,0 +1,53 @@ +# Module - Job Retry + +This module is listening to a SQS queue where the scale-up lambda publishes messages for jobs that needs to trigger a retry if still queued. The job retry module lambda function is handling the messages, checking if the job is queued. Next for queued jobs a message is published to the build queue for the scale-up lambda. The scale-up lambda will handle the message as any other workflow job event. + +## Usages + +The module is an inner module used by the runner stack when the opt-in feature for job retry is enabled. The module is not intended to be used standalone. + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | >= 6.21 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | >= 6.21 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [job\_retry](#module\_job\_retry) | ../../lambda | n/a | + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_iam_role_policy.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_lambda_event_source_mapping.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | +| [aws_lambda_permission.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_sqs_queue.job_retry_check_queue](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | +| [aws_sqs_queue_policy.job_retry_check_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | +| [aws_iam_policy_document.deny_insecure_transport](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runner_name_prefix`: Prefix used to identify runners belonging to this lane.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | n/a | +| [lambda](#output\_lambda) | n/a | + diff --git a/modules/runner-stack/job-retry/main.tf b/modules/runner-stack/job-retry/main.tf new file mode 100644 index 0000000000..9c3377f59d --- /dev/null +++ b/modules/runner-stack/job-retry/main.tf @@ -0,0 +1,99 @@ +locals { + lambda_zip = var.config.zip == null ? "${path.module}/../../../lambdas/functions/control-plane/runners.zip" : var.config.zip + name = "job-retry" + + environment_variables = { + ENABLE_ORGANIZATION_RUNNERS = var.config.enable_organization_runners + ENABLE_METRIC_JOB_RETRY = var.config.metrics.enable && var.config.metrics.metric.enable_job_retry + ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.metrics.enable && var.config.metrics.metric.enable_github_app_rate_limit + GHES_URL = var.config.ghes_url + USER_AGENT = var.config.user_agent + JOB_QUEUE_SCALE_UP_URL = var.config.sqs_build_queue.url + PARAMETER_GITHUB_APP_ID_NAME = var.config.github_app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github_app_parameters.key_base64.name + RUNNER_NAME_PREFIX = var.config.runner_name_prefix + } + + config = merge(var.config, { + name = local.name, + handler = "index.jobRetryCheck", + zip = local.lambda_zip, + environment_variables = local.environment_variables + metrics_namespace = var.config.metrics.namespace + }) +} + +resource "aws_sqs_queue_policy" "job_retry_check_queue_policy" { + queue_url = aws_sqs_queue.job_retry_check_queue.id + policy = data.aws_iam_policy_document.deny_insecure_transport.json +} + +resource "aws_sqs_queue" "job_retry_check_queue" { + name = "${var.config.prefix}-job-retry" + visibility_timeout_seconds = local.config.timeout + + sqs_managed_sse_enabled = var.config.queue_encryption.sqs_managed_sse_enabled + kms_master_key_id = var.config.queue_encryption.kms_master_key_id + kms_data_key_reuse_period_seconds = var.config.queue_encryption.kms_data_key_reuse_period_seconds + + tags = var.config.tags +} + +module "job_retry" { + source = "../../lambda" + lambda = local.config +} + +resource "aws_lambda_event_source_mapping" "job_retry" { + event_source_arn = aws_sqs_queue.job_retry_check_queue.arn + function_name = module.job_retry.lambda.function.arn + batch_size = var.config.lambda_event_source_mapping_batch_size + maximum_batching_window_in_seconds = var.config.lambda_event_source_mapping_maximum_batching_window_in_seconds +} + +resource "aws_lambda_permission" "job_retry" { + statement_id = "AllowExecutionFromSQS" + action = "lambda:InvokeFunction" + function_name = module.job_retry.lambda.function.function_name + principal = "sqs.amazonaws.com" + source_arn = aws_sqs_queue.job_retry_check_queue.arn +} + +resource "aws_iam_role_policy" "job_retry" { + name = "job_retry-policy" + role = module.job_retry.lambda.role.name + policy = templatefile("${path.module}/policies/lambda.json", { + kms_key_arn = var.config.kms_key_arn != null ? var.config.kms_key_arn : "" + sqs_build_queue_arn = var.config.sqs_build_queue.arn + sqs_job_retry_queue_arn = aws_sqs_queue.job_retry_check_queue.arn + github_app_id_arn = var.config.github_app_parameters.id.arn + github_app_key_base64_arn = var.config.github_app_parameters.key_base64.arn + }) +} + +data "aws_iam_policy_document" "deny_insecure_transport" { + statement { + sid = "DenyInsecureTransport" + + effect = "Deny" + + principals { + type = "AWS" + identifiers = ["*"] + } + + actions = [ + "sqs:*" + ] + + resources = [ + "*" + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} diff --git a/modules/runner-stack/job-retry/outputs.tf b/modules/runner-stack/job-retry/outputs.tf new file mode 100644 index 0000000000..38bb0d6030 --- /dev/null +++ b/modules/runner-stack/job-retry/outputs.tf @@ -0,0 +1,11 @@ +output "lambda" { + value = { + function = module.job_retry.lambda + log_group = module.job_retry.lambda.log_group + role = module.job_retry.lambda.role + } +} + +output "job_retry_check_queue" { + value = aws_sqs_queue.job_retry_check_queue +} diff --git a/modules/runner-stack/job-retry/policies/lambda.json b/modules/runner-stack/job-retry/policies/lambda.json new file mode 100644 index 0000000000..f1c9efd569 --- /dev/null +++ b/modules/runner-stack/job-retry/policies/lambda.json @@ -0,0 +1,44 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters" + ], + "Resource": [ + "${github_app_key_base64_arn}", + "${github_app_id_arn}" + ] + }, + { + "Effect": "Allow", + "Action": [ + "sqs:ReceiveMessage", + "sqs:GetQueueAttributes", + "sqs:DeleteMessage" + ], + "Resource": "${sqs_job_retry_queue_arn}" + }, + { + "Effect": "Allow", + "Action": [ + "sqs:SendMessage", + "sqs:GetQueueAttributes" + ], + "Resource": "${sqs_build_queue_arn}" +%{ if kms_key_arn != "" ~} + }, + { + "Effect": "Allow", + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey" + ], + "Resource": "${kms_key_arn}" +%{ endif ~} + } + ] +} diff --git a/modules/runner-stack/job-retry/variables.tf b/modules/runner-stack/job-retry/variables.tf new file mode 100644 index 0000000000..e71712d7a2 --- /dev/null +++ b/modules/runner-stack/job-retry/variables.tf @@ -0,0 +1,99 @@ +variable "config" { + description = <<-EOF + Configuration for the spot termination watcher lambda function. + + `aws_partition`: Partition for the base arn if not 'aws' + `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86_64' functions. + `environment_variables`: Environment variables for the lambda. + `enable_organization_runners`: Enable organization runners. + `enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning. + 'ghes_url': Optional GitHub Enterprise Server URL. + 'user_agent': Optional User-Agent header for GitHub API requests. + 'github_app_parameters': Parameter Store for GitHub App Parameters. + 'kms_key_arn': Optional CMK Key ARN instead of using the default AWS managed key. + `lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used. + `lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch_size is greater than 10. + `lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing. + `lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment. + `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. + `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with + `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. + `memory_size`: Memory size limit in MB of the lambda. + `metrics`: Configuration to enable metrics creation by the lambda. + `prefix`: The prefix used for naming resources. + `role_path`: The path that will be added to the role, if not set the environment name will be used. + `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda. + `runner_name_prefix`: Prefix used to identify runners belonging to this lane. + `runtime`: AWS Lambda runtime. + `s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly. + `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. + `security_group_ids`: List of security group IDs associated with the Lambda function. + 'sqs_build_queue': SQS queue for build events to re-publish job request. + `subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. + `tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked. + `tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment. + `timeout`: Time out of the lambda in seconds. + `tracing_config`: Configuration for lambda tracing. + `zip`: File location of the lambda zip file. + EOF + type = object({ + aws_partition = optional(string, null) + architecture = optional(string, null) + enable_organization_runners = bool + environment_variables = optional(map(string), {}) + ghes_url = optional(string, null) + user_agent = optional(string, null) + github_app_parameters = object({ + key_base64 = map(string) + id = map(string) + }) + kms_key_arn = optional(string, null) + lambda_event_source_mapping_batch_size = optional(number, 10) + lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0) + lambda_tags = optional(map(string), {}) + log_level = optional(string, null) + logging_kms_key_id = optional(string, null) + logging_retention_in_days = optional(number, null) + memory_size = optional(number, null) + metrics = optional(object({ + enable = optional(bool, false) + namespace = optional(string, null) + metric = optional(object({ + enable_github_app_rate_limit = optional(bool, true) + enable_job_retry = optional(bool, true) + }), {}) + }), {}) + prefix = optional(string, null) + principals = optional(list(object({ + type = string + identifiers = list(string) + })), []) + queue_encryption = optional(object({ + kms_data_key_reuse_period_seconds = optional(number, null) + kms_master_key_id = optional(string, null) + sqs_managed_sse_enabled = optional(bool, true) + }), {}) + role_path = optional(string, null) + role_permissions_boundary = optional(string, null) + runner_name_prefix = optional(string, "") + runtime = optional(string, null) + security_group_ids = optional(list(string), []) + subnet_ids = optional(list(string), []) + s3_bucket = optional(string, null) + s3_key = optional(string, null) + s3_object_version = optional(string, null) + sqs_build_queue = object({ + url = string + arn = string + }) + tags = optional(map(string), {}) + timeout = optional(number, 30) + tracing_config = optional(object({ + mode = optional(string, null) + capture_http_requests = optional(bool, false) + capture_error = optional(bool, false) + }), {}) + zip = optional(string, null) + }) +} diff --git a/modules/runner-stack/job-retry/versions.tf b/modules/runner-stack/job-retry/versions.tf new file mode 100644 index 0000000000..42a40b33fd --- /dev/null +++ b/modules/runner-stack/job-retry/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.21" + } + } +} diff --git a/modules/runner-stack/local.tf b/modules/runner-stack/local.tf new file mode 100644 index 0000000000..7cdc3a0a0c --- /dev/null +++ b/modules/runner-stack/local.tf @@ -0,0 +1,8 @@ +locals { + parameter_store_tags = jsonencode([ + for key, value in merge(var.tags, var.parameter_store_tags) : { + Key = key + Value = value + } + ]) +} diff --git a/modules/runner-stack/main.tf b/modules/runner-stack/main.tf new file mode 100644 index 0000000000..be531b8cc5 --- /dev/null +++ b/modules/runner-stack/main.tf @@ -0,0 +1,91 @@ +locals { + tags = merge( + { + "Name" = format("%s-action-runner", var.prefix) + }, + { + "ghr:ssm_config_path" = "${var.ssm_paths.root}/${var.ssm_paths.config}" + }, + var.tags, + ) + + role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path + lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/control-plane/runners.zip" : var.lambda_zip + kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" + enable_job_queued_check = var.enable_job_queued_check == null ? !var.enable_ephemeral_runners : var.enable_job_queued_check + token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" + arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" + provider_type = lower(trimspace(var.provider_type)) + + provider = one(module.ec2[*].control_plane) +} + +data "aws_caller_identity" "current" {} + +module "ec2" { + count = local.provider_type == "ec2" ? 1 : 0 + source = "../compute-providers/ec2" + + ami = var.ami + aws_region = var.aws_region + vpc_id = var.vpc_id + subnet_ids = var.subnet_ids + overrides = var.overrides + iam_overrides = var.iam_overrides + tags = var.tags + prefix = var.prefix + s3_runner_binaries = var.s3_runner_binaries + block_device_mappings = var.block_device_mappings + ebs_optimized = var.ebs_optimized + instance_target_capacity_type = var.instance_target_capacity_type + instance_allocation_strategy = var.instance_allocation_strategy + instance_type_priorities = var.instance_type_priorities + instance_max_spot_price = var.instance_max_spot_price + runner_os = var.runner_os + instance_types = var.instance_types + enable_userdata = var.enable_userdata + userdata_template = var.userdata_template + userdata_content = var.userdata_content + userdata_pre_install = var.userdata_pre_install + userdata_post_install = var.userdata_post_install + runner_hook_job_started = var.runner_hook_job_started + runner_hook_job_completed = var.runner_hook_job_completed + runner_boot_time_in_minutes = var.runner_boot_time_in_minutes + role_permissions_boundary = var.role_permissions_boundary + role_path = var.role_path + instance_profile_path = var.instance_profile_path + runner_as_root = var.runner_as_root + runner_run_as = var.runner_run_as + runner_architecture = var.runner_architecture + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + enable_ssm_on_runners = var.enable_ssm_on_runners + create_service_linked_role_spot = var.create_service_linked_role_spot + aws_partition = var.aws_partition + runner_iam_role_managed_policy_arns = var.runner_iam_role_managed_policy_arns + enable_cloudwatch_agent = var.enable_cloudwatch_agent + enable_managed_runner_security_group = var.enable_managed_runner_security_group + cloudwatch_config = var.cloudwatch_config + runner_log_files = var.runner_log_files + ghes_url = var.ghes_url + ghes_ssl_verify = var.ghes_ssl_verify + key_name = var.key_name + runner_additional_security_group_ids = var.runner_additional_security_group_ids + enable_runner_detailed_monitoring = var.enable_runner_detailed_monitoring + egress_rules = var.egress_rules + runner_ec2_tags = var.runner_ec2_tags + metadata_options = var.metadata_options + enable_runner_binaries_syncer = var.enable_runner_binaries_syncer + enable_user_data_debug_logging = var.enable_user_data_debug_logging + ssm_paths = var.ssm_paths + runner_name_prefix = var.runner_name_prefix + tracing_config = var.tracing_config + credit_specification = var.credit_specification + cpu_options = var.cpu_options + placement = var.placement + license_specifications = var.license_specifications + associate_public_ipv4_address = var.associate_public_ipv4_address + enable_on_demand_failover_for_errors = var.enable_on_demand_failover_for_errors + scale_errors = var.scale_errors + use_dedicated_host = var.use_dedicated_host +} diff --git a/modules/runner-stack/outputs.tf b/modules/runner-stack/outputs.tf new file mode 100644 index 0000000000..6092e628ac --- /dev/null +++ b/modules/runner-stack/outputs.tf @@ -0,0 +1,43 @@ +output "provider" { + description = "Selected compute provider type and its provider-specific resources." + value = { + type = local.provider.type + ec2 = module.ec2[0].resources + } +} + +output "lambda_scale_up" { + value = aws_lambda_function.scale_up +} + +output "lambda_scale_up_log_group" { + value = aws_cloudwatch_log_group.scale_up +} + +output "role_scale_up" { + value = aws_iam_role.scale_up +} + +output "lambda_scale_down" { + value = aws_lambda_function.scale_down +} + +output "lambda_scale_down_log_group" { + value = aws_cloudwatch_log_group.scale_down +} + +output "role_scale_down" { + value = aws_iam_role.scale_down +} + +output "lambda_pool" { + value = try(module.pool[0].lambda, null) +} + +output "lambda_pool_log_group" { + value = try(module.pool[0].lambda_log_group, null) +} + +output "role_pool" { + value = try(module.pool[0].role_pool, null) +} diff --git a/modules/runner-stack/policies-lambda-common.tf b/modules/runner-stack/policies-lambda-common.tf new file mode 100644 index 0000000000..e3ce5c427f --- /dev/null +++ b/modules/runner-stack/policies-lambda-common.tf @@ -0,0 +1,27 @@ +data "aws_iam_policy_document" "lambda_assume_role_policy" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "lambda_xray" { + count = var.tracing_config.mode != null ? 1 : 0 + statement { + actions = [ + "xray:BatchGetTraces", + "xray:GetTraceSummaries", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" + ] + effect = "Allow" + resources = [ + "*" + ] + sid = "AllowXRay" + } +} diff --git a/modules/runner-stack/policies/lambda-cloudwatch.json b/modules/runner-stack/policies/lambda-cloudwatch.json new file mode 100644 index 0000000000..ad9246bcb3 --- /dev/null +++ b/modules/runner-stack/policies/lambda-cloudwatch.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "${log_group_arn}*" + } + ] +} diff --git a/modules/runner-stack/policies/lambda-publish-sqs-policy.json b/modules/runner-stack/policies/lambda-publish-sqs-policy.json new file mode 100644 index 0000000000..031560874b --- /dev/null +++ b/modules/runner-stack/policies/lambda-publish-sqs-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["sqs:SendMessage", "sqs:GetQueueAttributes"], + "Resource": ${sqs_resource_arns} + } + ] +} diff --git a/modules/runner-stack/policies/lambda-scale-down.json b/modules/runner-stack/policies/lambda-scale-down.json new file mode 100644 index 0000000000..dbd77b2383 --- /dev/null +++ b/modules/runner-stack/policies/lambda-scale-down.json @@ -0,0 +1,17 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["ssm:GetParameter", "ssm:GetParameters"], + "Resource": ["${github_app_key_base64_arn}", "${github_app_id_arn}"] + } +%{ if kms_key_arn != "" ~} + ,{ + "Effect": "Allow", + "Action": ["kms:Decrypt"], + "Resource": "${kms_key_arn}" + } +%{ endif ~} + ] +} diff --git a/modules/runner-stack/policies/lambda-scale-up.json b/modules/runner-stack/policies/lambda-scale-up.json new file mode 100644 index 0000000000..c7a98ffa88 --- /dev/null +++ b/modules/runner-stack/policies/lambda-scale-up.json @@ -0,0 +1,31 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["ssm:PutParameter", "ssm:AddTagsToResource"], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": ["ssm:GetParameter", "ssm:GetParameters"], + "Resource": [ + "${github_app_key_base64_arn}", + "${github_app_id_arn}", + "${ssm_config_path}/*" + ] + }, + { + "Effect": "Allow", + "Action": ["sqs:ReceiveMessage", "sqs:GetQueueAttributes", "sqs:DeleteMessage"], + "Resource": "${sqs_arn}" + } +%{ if kms_key_arn != "" ~} + ,{ + "Effect": "Allow", + "Action": ["kms:Decrypt"], + "Resource": "${kms_key_arn}" + } +%{ endif ~} + ] +} diff --git a/modules/runner-stack/policies/lambda-ssm-housekeeper.json b/modules/runner-stack/policies/lambda-ssm-housekeeper.json new file mode 100644 index 0000000000..5e49baafaa --- /dev/null +++ b/modules/runner-stack/policies/lambda-ssm-housekeeper.json @@ -0,0 +1,13 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ssm:DeleteParameter", + "ssm:GetParametersByPath" + ], + "Resource": "${ssm_token_path}*" + } + ] +} diff --git a/modules/runner-stack/pool.tf b/modules/runner-stack/pool.tf new file mode 100644 index 0000000000..79c56aeae6 --- /dev/null +++ b/modules/runner-stack/pool.tf @@ -0,0 +1,63 @@ +module "pool" { + count = length(var.pool_config) == 0 ? 0 : 1 + + source = "./pool" + + config = { + prefix = var.prefix + ghes = { + ssl_verify = var.ghes_ssl_verify + url = var.ghes_url + } + user_agent = var.user_agent + github_app_parameters = var.github_app_parameters + runners_maximum_count = var.runners_maximum_count + kms_key_arn = local.kms_key_arn + lambda = { + log_level = var.log_level + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + log_class = var.log_class + reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions + s3_bucket = var.lambda_s3_bucket + s3_key = var.runners_lambda_s3_key + s3_object_version = var.runners_lambda_s3_object_version + security_group_ids = var.lambda_security_group_ids + subnet_ids = var.lambda_subnet_ids + architecture = var.lambda_architecture + memory_size = var.pool_lambda_memory_size + runtime = var.lambda_runtime + timeout = var.pool_lambda_timeout + zip = local.lambda_zip + parameter_store_tags = local.parameter_store_tags + } + pool = var.pool_config + include_busy_runners = var.pool_include_busy_runners + role_path = local.role_path + role_permissions_boundary = var.role_permissions_boundary + runner = { + disable_runner_autoupdate = var.disable_runner_autoupdate + ephemeral = var.enable_ephemeral_runners + enable_jit_config = var.enable_jit_config + labels = var.runner_labels + group_name = var.runner_group_name + name_prefix = var.runner_name_prefix + pool_owner = var.pool_runner_owner + } + ssm_token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" + ssm_config_path = "${var.ssm_paths.root}/${var.ssm_paths.config}" + tags = local.tags + lambda_tags = var.lambda_tags + arn_ssm_parameters_path_config = local.arn_ssm_parameters_path_config + } + + aws_partition = var.aws_partition + tracing_config = var.tracing_config + runner_provider = { + type = local.provider.type + environment_variables = local.provider.pool.environment_variables + iam_policy_json = local.provider.pool.iam_policy_json + managed_policy_enabled = local.provider.pool.managed_policy_enabled + managed_policy_arn = local.provider.pool.managed_policy_arn + } +} diff --git a/modules/runner-stack/pool/README.md b/modules/runner-stack/pool/README.md new file mode 100644 index 0000000000..2f83465bf1 --- /dev/null +++ b/modules/runner-stack/pool/README.md @@ -0,0 +1,64 @@ +# Pool module + +This module creates the AWS resources required to maintain a pool of runners. However terraform modules are always exposed and theoretically can be used anywhere. This module is seen as a strict inner module. + +## Why a submodule for the pool + +The pool is an opt-in feature. To be able to use the count on a module level to avoid counts per resources a module is created. All inputs of the module are already defined on a higher level. See the mapping of the variables in [`pool.tf`](../pool.tf) + +## 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_cloudwatch_log_group.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.pool_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.pool_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.pool_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambda_function.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_scheduler_schedule.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/scheduler_schedule) | resource | +| [aws_scheduler_schedule_group.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/scheduler_schedule_group) | resource | +| [aws_iam_policy_document.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_xray](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.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.scheduler_assume](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) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no | +| [config](#input\_config) | Lookup details in parent module. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_arn = string
role_path = string
ssm_token_path = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
})
| n/a | yes | +| [runner\_provider](#input\_runner\_provider) | Compute provider configuration for the pool Lambda. |
object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
| n/a | yes | +| [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 | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [lambda](#output\_lambda) | n/a | +| [lambda\_log\_group](#output\_lambda\_log\_group) | n/a | +| [role\_pool](#output\_role\_pool) | n/a | + diff --git a/modules/runner-stack/pool/main.tf b/modules/runner-stack/pool/main.tf new file mode 100644 index 0000000000..aac9f23266 --- /dev/null +++ b/modules/runner-stack/pool/main.tf @@ -0,0 +1,231 @@ +locals { + pool_name_prefix = ( + length("${var.config.prefix}-pool") <= 38 + ? "${var.config.prefix}-pool" + : "${substr("${var.config.prefix}-pool", 0, 29)}-${substr(md5("${var.config.prefix}-pool"), 0, 8)}" + ) + + common_environment_variables = { + DISABLE_RUNNER_AUTOUPDATE = var.config.runner.disable_runner_autoupdate + ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral + ENABLE_JIT_CONFIG = var.config.runner.enable_jit_config + ENVIRONMENT = var.config.prefix + GHES_URL = var.config.ghes.url + USER_AGENT = var.config.user_agent + LOG_LEVEL = upper(var.config.lambda.log_level) + NODE_TLS_REJECT_UNAUTHORIZED = var.config.ghes.url != null && !var.config.ghes.ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = var.config.github_app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github_app_parameters.key_base64.name + POWERTOOLS_LOGGER_LOG_EVENT = var.config.lambda.log_level == "debug" ? "true" : "false" + RUNNER_LABELS = lower(join(",", var.config.runner.labels)) + RUNNER_GROUP_NAME = var.config.runner.group_name + RUNNER_NAME_PREFIX = var.config.runner.name_prefix + RUNNER_OWNER = var.config.runner.pool_owner + RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count + SSM_TOKEN_PATH = var.config.ssm_token_path + SSM_CONFIG_PATH = var.config.ssm_config_path + POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-pool" + POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + SSM_PARAMETER_STORE_TAGS = var.config.lambda.parameter_store_tags + INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners + } +} + +resource "aws_lambda_function" "pool" { + + s3_bucket = var.config.lambda.s3_bucket != null ? var.config.lambda.s3_bucket : null + s3_key = var.config.lambda.s3_key != null ? var.config.lambda.s3_key : null + s3_object_version = var.config.lambda.s3_object_version != null ? var.config.lambda.s3_object_version : null + filename = var.config.lambda.s3_bucket == null ? var.config.lambda.zip : null + source_code_hash = var.config.lambda.s3_bucket == null ? filebase64sha256(var.config.lambda.zip) : null + function_name = "${var.config.prefix}-pool" + role = aws_iam_role.pool.arn + handler = "index.adjustPool" + architectures = [var.config.lambda.architecture] + runtime = var.config.lambda.runtime + timeout = var.config.lambda.timeout + reserved_concurrent_executions = var.config.lambda.reserved_concurrent_executions + memory_size = var.config.lambda.memory_size + tags = merge(var.config.tags, var.config.lambda_tags) + + environment { + variables = merge(var.runner_provider.environment_variables, local.common_environment_variables) + } + + dynamic "vpc_config" { + for_each = var.config.lambda.subnet_ids != null && var.config.lambda.security_group_ids != null ? [true] : [] + content { + security_group_ids = var.config.lambda.security_group_ids + subnet_ids = var.config.lambda.subnet_ids + } + } + + dynamic "tracing_config" { + for_each = var.tracing_config.mode != null ? [true] : [] + content { + mode = var.tracing_config.mode + } + } +} + +resource "aws_cloudwatch_log_group" "pool" { + name = "/aws/lambda/${aws_lambda_function.pool.function_name}" + retention_in_days = var.config.lambda.logging_retention_in_days + kms_key_id = var.config.lambda.logging_kms_key_id + log_group_class = var.config.lambda.log_class + tags = var.config.tags +} + +resource "aws_iam_role" "pool" { + name = "${substr("${var.config.prefix}-pool-lambda", 0, 54)}-${substr(md5("${var.config.prefix}-pool-lambda"), 0, 8)}" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json + path = var.config.role_path + permissions_boundary = var.config.role_permissions_boundary + tags = var.config.tags +} + +resource "aws_iam_role_policy" "pool" { + name = "pool-policy" + role = aws_iam_role.pool.name + policy = data.aws_iam_policy_document.pool.json +} + +data "aws_iam_policy_document" "pool" { + source_policy_documents = [ + templatefile("${path.module}/policies/lambda-pool.json", { + arn_ssm_parameters_path_config = var.config.arn_ssm_parameters_path_config + github_app_id_arn = var.config.github_app_parameters.id.arn + github_app_key_base64_arn = var.config.github_app_parameters.key_base64.arn + kms_key_arn = var.config.kms_key_arn + }), + var.runner_provider.iam_policy_json, + ] +} + +resource "aws_iam_role_policy" "pool_logging" { + name = "logging-policy" + role = aws_iam_role.pool.name + policy = templatefile("${path.module}/../policies/lambda-cloudwatch.json", { + log_group_arn = aws_cloudwatch_log_group.pool.arn + }) +} + +resource "aws_iam_role_policy_attachment" "pool_vpc_execution_role" { + count = length(var.config.lambda.subnet_ids) > 0 ? 1 : 0 + role = aws_iam_role.pool.name + policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +data "aws_iam_policy_document" "lambda_assume_role_policy" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role_policy_attachment" "provider" { + count = var.runner_provider.managed_policy_enabled ? 1 : 0 + role = aws_iam_role.pool.name + policy_arn = var.runner_provider.managed_policy_arn +} + +# lambda xray policy +data "aws_iam_policy_document" "lambda_xray" { + count = var.tracing_config.mode != null ? 1 : 0 + statement { + actions = [ + "xray:BatchGetTraces", + "xray:GetTraceSummaries", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" + ] + effect = "Allow" + resources = [ + "*" + ] + sid = "AllowXRay" + } +} + +resource "aws_iam_role_policy" "pool_xray" { + count = var.tracing_config.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.pool.name +} + +resource "aws_scheduler_schedule_group" "pool" { + name_prefix = local.pool_name_prefix + + tags = var.config.tags +} + +data "aws_iam_policy_document" "scheduler_assume" { + statement { + sid = "ScheduleGroupAssumeRole" + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["scheduler.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "aws:SourceArn" + values = [aws_scheduler_schedule_group.pool.arn] + } + } +} + +data "aws_iam_policy_document" "scheduler" { + statement { + sid = "InvokePoolLambda" + actions = ["lambda:InvokeFunction"] + resources = [aws_lambda_function.pool.arn] + } +} + +resource "aws_iam_role" "scheduler" { + name_prefix = local.pool_name_prefix + + path = var.config.role_path + permissions_boundary = var.config.role_permissions_boundary + + assume_role_policy = data.aws_iam_policy_document.scheduler_assume.json + tags = var.config.tags +} + +resource "aws_iam_role_policy" "scheduler" { + name = "terraform" + role = aws_iam_role.scheduler.name + policy = data.aws_iam_policy_document.scheduler.json +} + +resource "aws_scheduler_schedule" "pool" { + for_each = { for i, v in var.config.pool : i => v } + + name = "${var.config.prefix}-pool-${each.key}-rule" + group_name = aws_scheduler_schedule_group.pool.name + + flexible_time_window { + mode = "OFF" + } + + schedule_expression = each.value.schedule_expression + schedule_expression_timezone = each.value.schedule_expression_timezone + + target { + arn = aws_lambda_function.pool.arn + role_arn = aws_iam_role.scheduler.arn + input = jsonencode({ + poolSize = each.value.size + type = var.runner_provider.type + }) + } +} diff --git a/modules/runner-stack/pool/outputs.tf b/modules/runner-stack/pool/outputs.tf new file mode 100644 index 0000000000..7a4d70ca9f --- /dev/null +++ b/modules/runner-stack/pool/outputs.tf @@ -0,0 +1,11 @@ +output "role_pool" { + value = aws_iam_role.pool +} + +output "lambda" { + value = aws_lambda_function.pool +} + +output "lambda_log_group" { + value = aws_cloudwatch_log_group.pool +} diff --git a/modules/runner-stack/pool/policies/lambda-pool.json b/modules/runner-stack/pool/policies/lambda-pool.json new file mode 100644 index 0000000000..b44f135a64 --- /dev/null +++ b/modules/runner-stack/pool/policies/lambda-pool.json @@ -0,0 +1,45 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ssm:AddTagsToResource", + "ssm:PutParameter" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": [ + "${arn_ssm_parameters_path_config}", + "${arn_ssm_parameters_path_config}/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters" + ], + "Resource": [ + "${github_app_key_base64_arn}", + "${github_app_id_arn}" + ] +%{ if kms_key_arn != "" ~} + }, + { + "Effect": "Allow", + "Action": [ + "kms:Decrypt" + ], + "Resource": "${kms_key_arn}" +%{ endif ~} + } + ] +} diff --git a/modules/runners/pool/tests/provider.tftest.hcl b/modules/runner-stack/pool/tests/provider.tftest.hcl similarity index 100% rename from modules/runners/pool/tests/provider.tftest.hcl rename to modules/runner-stack/pool/tests/provider.tftest.hcl diff --git a/modules/runner-stack/pool/variables.tf b/modules/runner-stack/pool/variables.tf new file mode 100644 index 0000000000..833fdb4956 --- /dev/null +++ b/modules/runner-stack/pool/variables.tf @@ -0,0 +1,99 @@ +variable "config" { + description = "Lookup details in parent module." + type = object({ + lambda = object({ + log_level = string + logging_retention_in_days = number + logging_kms_key_id = string + log_class = string + reserved_concurrent_executions = number + s3_bucket = string + s3_key = string + s3_object_version = string + security_group_ids = list(string) + runtime = string + architecture = string + memory_size = number + timeout = number + zip = string + subnet_ids = list(string) + parameter_store_tags = string + }) + tags = map(string) + ghes = object({ + url = string + ssl_verify = string + }) + github_app_parameters = object({ + key_base64 = map(string) + id = map(string) + }) + runner = object({ + disable_runner_autoupdate = bool + ephemeral = bool + enable_jit_config = bool + labels = list(string) + group_name = string + name_prefix = string + pool_owner = string + }) + runners_maximum_count = number + prefix = string + pool = list(object({ + schedule_expression = string + schedule_expression_timezone = string + size = number + })) + include_busy_runners = bool + role_permissions_boundary = string + kms_key_arn = string + role_path = string + ssm_token_path = string + ssm_config_path = string + arn_ssm_parameters_path_config = string + lambda_tags = map(string) + user_agent = string + }) +} + +variable "runner_provider" { + description = "Compute provider configuration for the pool Lambda." + type = object({ + type = string + environment_variables = map(string) + iam_policy_json = string + managed_policy_enabled = bool + managed_policy_arn = optional(string, null) + }) + + validation { + condition = trimspace(var.runner_provider.type) != "" + error_message = "The compute provider type must not be empty." + } + + validation { + condition = can(jsondecode(var.runner_provider.iam_policy_json)) + error_message = "The compute provider IAM policy must be valid JSON." + } + + validation { + condition = !var.runner_provider.managed_policy_enabled || var.runner_provider.managed_policy_arn != null + error_message = "The compute provider managed policy ARN must be set when its attachment is enabled." + } +} + +variable "aws_partition" { + description = "(optional) partition for the arn if not 'aws'" + type = string + default = "aws" +} + +variable "tracing_config" { + description = "Configuration for lambda tracing." + type = object({ + mode = optional(string, null) + capture_http_requests = optional(bool, false) + capture_error = optional(bool, false) + }) + default = {} +} diff --git a/modules/runner-stack/pool/versions.tf b/modules/runner-stack/pool/versions.tf new file mode 100644 index 0000000000..42a40b33fd --- /dev/null +++ b/modules/runner-stack/pool/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.21" + } + } +} diff --git a/modules/runner-stack/runner-config.tf b/modules/runner-stack/runner-config.tf new file mode 100644 index 0000000000..2c2de2c203 --- /dev/null +++ b/modules/runner-stack/runner-config.tf @@ -0,0 +1,27 @@ +resource "aws_ssm_parameter" "runner_agent_mode" { + name = "${var.ssm_paths.root}/${var.ssm_paths.config}/agent_mode" + type = "String" + value = var.enable_ephemeral_runners ? "ephemeral" : "persistent" + tags = local.tags +} + +resource "aws_ssm_parameter" "disable_default_labels" { + name = "${var.ssm_paths.root}/${var.ssm_paths.config}/disable_default_labels" + type = "String" + value = var.runner_disable_default_labels + tags = local.tags +} + +resource "aws_ssm_parameter" "jit_config_enabled" { + name = "${var.ssm_paths.root}/${var.ssm_paths.config}/enable_jit_config" + type = "String" + value = var.enable_jit_config == null ? var.enable_ephemeral_runners : var.enable_jit_config + tags = local.tags +} + +resource "aws_ssm_parameter" "token_path" { + name = "${var.ssm_paths.root}/${var.ssm_paths.config}/token_path" + type = "String" + value = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" + tags = local.tags +} diff --git a/modules/runner-stack/scale-down-state-diagram.md b/modules/runner-stack/scale-down-state-diagram.md new file mode 100644 index 0000000000..64e32bc141 --- /dev/null +++ b/modules/runner-stack/scale-down-state-diagram.md @@ -0,0 +1,150 @@ +# GitHub Actions Runner Scale-Down State Diagram + + + +The scale-down Lambda function runs on a scheduled basis (every 5 minutes by default) to manage GitHub Actions runner instances. It performs a two-phase cleanup process: first terminating confirmed orphaned instances, then evaluating active runners to maintain the desired idle capacity while removing unnecessary instances. + +```mermaid +stateDiagram-v2 + [*] --> ScheduledExecution : Cron Trigger every 5 min + + ScheduledExecution --> Phase1_OrphanTermination : Start Phase 1 + + state Phase1_OrphanTermination { + [*] --> ListOrphanInstances : Query EC2 for ghr orphan true + + ListOrphanInstances --> CheckOrphanType : For each orphan + + state CheckOrphanType <> + CheckOrphanType --> HasRunnerIdTag : Has ghr github runner id + CheckOrphanType --> TerminateOrphan : No runner ID tag + + HasRunnerIdTag --> LastChanceCheck : Query GitHub API + + state LastChanceCheck <> + LastChanceCheck --> ConfirmedOrphan : Offline and busy + LastChanceCheck --> FalsePositive : Exists and not problematic + + ConfirmedOrphan --> TerminateOrphan + FalsePositive --> RemoveOrphanTag + + TerminateOrphan --> NextOrphan : Continue processing + RemoveOrphanTag --> NextOrphan + + NextOrphan --> CheckOrphanType : More orphans? + NextOrphan --> Phase2_ActiveRunners : All processed + } + + Phase1_OrphanTermination --> Phase2_ActiveRunners : Phase 1 Complete + + state Phase2_ActiveRunners { + [*] --> ListActiveRunners : Query non-orphan EC2 instances + + ListActiveRunners --> GroupByOwner : Sort by owner and repo + + GroupByOwner --> ProcessOwnerGroup : For each owner + + state ProcessOwnerGroup { + [*] --> SortByStrategy : Apply eviction strategy + SortByStrategy --> ProcessRunner : Oldest first or newest first + + ProcessRunner --> QueryGitHub : Get GitHub runners for owner + + QueryGitHub --> MatchRunner : Find runner by instance ID suffix + + state MatchRunner <> + MatchRunner --> FoundInGitHub : Runner exists in GitHub + MatchRunner --> NotFoundInGitHub : Runner not in GitHub + + state FoundInGitHub { + [*] --> CheckMinimumTime : Has minimum runtime passed? + + state CheckMinimumTime <> + CheckMinimumTime --> TooYoung : Runtime less than minimum + CheckMinimumTime --> CheckIdleQuota : Runtime greater than or equal to minimum + + TooYoung --> NextRunner + + state CheckIdleQuota <> + CheckIdleQuota --> KeepIdle : Idle quota available + CheckIdleQuota --> CheckBusyState : Quota full + + KeepIdle --> NextRunner + + state CheckBusyState <> + CheckBusyState --> KeepBusy : Runner busy + CheckBusyState --> TerminateIdle : Runner idle + + KeepBusy --> NextRunner + TerminateIdle --> DeregisterFromGitHub + DeregisterFromGitHub --> TerminateInstance + TerminateInstance --> NextRunner + } + + state NotFoundInGitHub { + [*] --> CheckBootTime : Has boot time exceeded? + + state CheckBootTime <> + CheckBootTime --> StillBooting : Boot time less than threshold + CheckBootTime --> MarkOrphan : Boot time greater than or equal to threshold + + StillBooting --> NextRunner + MarkOrphan --> TagAsOrphan : Set ghr orphan true + TagAsOrphan --> NextRunner + } + + NextRunner --> ProcessRunner : More runners in group? + NextRunner --> NextOwnerGroup : Group complete + } + + NextOwnerGroup --> ProcessOwnerGroup : More owner groups? + NextOwnerGroup --> ExecutionComplete : All groups processed + } + + Phase2_ActiveRunners --> ExecutionComplete : Phase 2 Complete + + ExecutionComplete --> [*] : Wait for next cron trigger + + note right of LastChanceCheck + Uses ghr github runner id tag + for precise GitHub API lookup + end note + + note right of MatchRunner + Matches GitHub runner name + ending with EC2 instance ID + end note + + note right of CheckMinimumTime + Minimum running time in minutes + (Linux: 5min, Windows: 15min, OSX: 20min) + end note + + note right of CheckBootTime + Runner boot time in minutes + Default configuration value + end note +``` + + + +## Key Decision Points + +| State | Condition | Action | +|-------|-----------|--------| +| **Orphan w/ Runner ID** | GitHub: offline + busy | Terminate (confirmed orphan) | +| **Orphan w/ Runner ID** | GitHub: exists + healthy | Remove orphan tag (false positive) | +| **Orphan w/o Runner ID** | Always | Terminate (no way to verify) | +| **Active Runner Found** | Runtime < minimum | Keep (too young) | +| **Active Runner Found** | Idle quota available | Keep as idle | +| **Active Runner Found** | Quota full + idle | Terminate + deregister | +| **Active Runner Found** | Quota full + busy | Keep running | +| **Active Runner Missing** | Boot time exceeded | Mark as orphan | +| **Active Runner Missing** | Still booting | Wait | + +## Configuration Parameters + +- **Cron Schedule**: `cron(*/5 * * * ? *)` (every 5 minutes) +- **Minimum Runtime**: Linux 5min, Windows 15min, OSX 20min +- **Boot Timeout**: Configurable via `runner_boot_time_in_minutes` +- **Idle Config**: Per-environment configuration for desired idle runners diff --git a/modules/runner-stack/scale-down.tf b/modules/runner-stack/scale-down.tf new file mode 100644 index 0000000000..2a471839b3 --- /dev/null +++ b/modules/runner-stack/scale-down.tf @@ -0,0 +1,135 @@ +locals { + # Windows Runners can take their sweet time to do anything + # For an AWS vended AMI with an x86 Mac instance or an Apple silicon Mac instance, + # the launch time can range from approximately 6 minutes to 20 minutes. + min_runtime_defaults = { + "windows" = 15 + "linux" = 5 + "osx" = 20 + } +} +resource "aws_lambda_function" "scale_down" { + s3_bucket = var.lambda_s3_bucket != null ? var.lambda_s3_bucket : null + s3_key = var.runners_lambda_s3_key != null ? var.runners_lambda_s3_key : null + s3_object_version = var.runners_lambda_s3_object_version != null ? var.runners_lambda_s3_object_version : null + filename = var.lambda_s3_bucket == null ? local.lambda_zip : null + source_code_hash = var.lambda_s3_bucket == null ? filebase64sha256(local.lambda_zip) : null + function_name = "${var.prefix}-scale-down" + role = aws_iam_role.scale_down.arn + handler = "index.scaleDownHandler" + runtime = var.lambda_runtime + timeout = var.lambda_timeout_scale_down + tags = merge(local.tags, var.lambda_tags) + memory_size = var.lambda_scale_down_memory_size + architectures = [var.lambda_architecture] + + environment { + variables = merge(local.provider.scale_down.environment_variables, { + ENVIRONMENT = var.prefix + ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.metrics.enable && var.metrics.metric.enable_github_app_rate_limit + GHES_URL = var.ghes_url + USER_AGENT = var.user_agent + LOG_LEVEL = upper(var.log_level) + MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner_os]) + NODE_TLS_REJECT_UNAUTHORIZED = var.ghes_url != null && !var.ghes_ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = var.github_app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github_app_parameters.key_base64.name + POWERTOOLS_LOGGER_LOG_EVENT = var.log_level == "debug" ? "true" : "false" + SCALE_DOWN_CONFIG = jsonencode(var.idle_config) + POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-down" + POWERTOOLS_METRICS_NAMESPACE = var.metrics.namespace + POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + RUNNER_PROVIDER_TYPE = local.provider.type + }) + } + + dynamic "vpc_config" { + for_each = var.lambda_subnet_ids != null && var.lambda_security_group_ids != null ? [true] : [] + content { + security_group_ids = var.lambda_security_group_ids + subnet_ids = var.lambda_subnet_ids + } + } + + dynamic "tracing_config" { + for_each = var.tracing_config.mode != null ? [true] : [] + content { + mode = var.tracing_config.mode + } + } +} + +resource "aws_cloudwatch_log_group" "scale_down" { + name = "/aws/lambda/${aws_lambda_function.scale_down.function_name}" + retention_in_days = var.logging_retention_in_days + kms_key_id = var.logging_kms_key_id + log_group_class = var.log_class + tags = var.tags +} + +resource "aws_cloudwatch_event_rule" "scale_down" { + name = "${var.prefix}-scale-down-rule" + schedule_expression = var.scale_down_schedule_expression + tags = var.tags +} + +resource "aws_cloudwatch_event_target" "scale_down" { + rule = aws_cloudwatch_event_rule.scale_down.name + arn = aws_lambda_function.scale_down.arn +} + +resource "aws_lambda_permission" "scale_down" { + statement_id = "AllowExecutionFromCloudWatch" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.scale_down.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.scale_down.arn +} + +resource "aws_iam_role" "scale_down" { + name = "${substr("${var.prefix}-scale-down-lambda", 0, 54)}-${substr(md5("${var.prefix}-scale-down-lambda"), 0, 8)}" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json + path = local.role_path + permissions_boundary = var.role_permissions_boundary + tags = local.tags +} + +resource "aws_iam_role_policy" "scale_down" { + name = "scale-down-policy" + role = aws_iam_role.scale_down.name + policy = data.aws_iam_policy_document.scale_down.json +} + +data "aws_iam_policy_document" "scale_down" { + source_policy_documents = [ + templatefile("${path.module}/policies/lambda-scale-down.json", { + github_app_id_arn = var.github_app_parameters.id.arn + github_app_key_base64_arn = var.github_app_parameters.key_base64.arn + kms_key_arn = local.kms_key_arn + }), + local.provider.scale_down.iam_policy_json, + ] +} + +resource "aws_iam_role_policy" "scale_down_logging" { + name = "logging-policy" + role = aws_iam_role.scale_down.name + policy = templatefile("${path.module}/policies/lambda-cloudwatch.json", { + log_group_arn = aws_cloudwatch_log_group.scale_down.arn + }) +} + +resource "aws_iam_role_policy_attachment" "scale_down_vpc_execution_role" { + count = length(var.lambda_subnet_ids) > 0 ? 1 : 0 + role = aws_iam_role.scale_down.name + policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy" "scale_down_xray" { + count = var.tracing_config.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.scale_down.name +} diff --git a/modules/runner-stack/scale-up.tf b/modules/runner-stack/scale-up.tf new file mode 100644 index 0000000000..1d2b4970b8 --- /dev/null +++ b/modules/runner-stack/scale-up.tf @@ -0,0 +1,171 @@ +locals { + job_retry_config = local.job_retry_enabled ? { + enable = var.job_retry.enable + maxAttempts = var.job_retry.max_attempts + delayInSeconds = var.job_retry.delay_in_seconds + delayBackoff = var.job_retry.delay_backoff + queueUrl = module.job_retry[0].job_retry_check_queue.url + } : {} +} + +resource "aws_lambda_function" "scale_up" { + s3_bucket = var.lambda_s3_bucket != null ? var.lambda_s3_bucket : null + s3_key = var.runners_lambda_s3_key != null ? var.runners_lambda_s3_key : null + s3_object_version = var.runners_lambda_s3_object_version != null ? var.runners_lambda_s3_object_version : null + filename = var.lambda_s3_bucket == null ? local.lambda_zip : null + source_code_hash = var.lambda_s3_bucket == null ? filebase64sha256(local.lambda_zip) : null + function_name = "${var.prefix}-scale-up" + role = aws_iam_role.scale_up.arn + handler = "index.scaleUpHandler" + runtime = var.lambda_runtime + timeout = var.lambda_timeout_scale_up + reserved_concurrent_executions = var.scale_up_reserved_concurrent_executions + memory_size = var.lambda_scale_up_memory_size + tags = merge(local.tags, var.lambda_tags) + architectures = [var.lambda_architecture] + environment { + variables = merge(local.provider.scale_up.environment_variables, { + DISABLE_RUNNER_AUTOUPDATE = var.disable_runner_autoupdate + ENABLE_EPHEMERAL_RUNNERS = var.enable_ephemeral_runners + ENABLE_JIT_CONFIG = var.enable_jit_config + ENABLE_JOB_QUEUED_CHECK = local.enable_job_queued_check + ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.metrics.enable && var.metrics.metric.enable_github_app_rate_limit + ENABLE_ORGANIZATION_RUNNERS = var.enable_organization_runners + ENVIRONMENT = var.prefix + GHES_URL = var.ghes_url + USER_AGENT = var.user_agent + LOG_LEVEL = upper(var.log_level) + MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner_os]) + NODE_TLS_REJECT_UNAUTHORIZED = var.ghes_url != null && !var.ghes_ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = var.github_app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github_app_parameters.key_base64.name + POWERTOOLS_LOGGER_LOG_EVENT = var.log_level == "debug" ? "true" : "false" + POWERTOOLS_METRICS_NAMESPACE = var.metrics.namespace + POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + RUNNER_LABELS = lower(join(",", var.runner_labels)) + RUNNER_GROUP_NAME = var.runner_group_name + RUNNER_NAME_PREFIX = var.runner_name_prefix + RUNNER_PROVIDER_TYPE = local.provider.type + RUNNERS_MAXIMUM_COUNT = var.runners_maximum_count + POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-up" + SSM_TOKEN_PATH = local.token_path + SSM_CONFIG_PATH = "${var.ssm_paths.root}/${var.ssm_paths.config}" + SSM_PARAMETER_STORE_TAGS = local.parameter_store_tags + JOB_RETRY_CONFIG = jsonencode(local.job_retry_config) + }) + } + + dynamic "vpc_config" { + for_each = var.lambda_subnet_ids != null && var.lambda_security_group_ids != null ? [true] : [] + content { + security_group_ids = var.lambda_security_group_ids + subnet_ids = var.lambda_subnet_ids + } + } + + dynamic "tracing_config" { + for_each = var.tracing_config.mode != null ? [true] : [] + content { + mode = var.tracing_config.mode + } + } +} + +resource "aws_cloudwatch_log_group" "scale_up" { + name = "/aws/lambda/${aws_lambda_function.scale_up.function_name}" + retention_in_days = var.logging_retention_in_days + kms_key_id = var.logging_kms_key_id + log_group_class = var.log_class + tags = var.tags +} + +resource "aws_lambda_event_source_mapping" "scale_up" { + event_source_arn = var.sqs_build_queue.arn + function_name = aws_lambda_function.scale_up.arn + function_response_types = ["ReportBatchItemFailures"] + batch_size = var.lambda_event_source_mapping_batch_size + maximum_batching_window_in_seconds = var.lambda_event_source_mapping_maximum_batching_window_in_seconds + tags = var.tags +} + +resource "aws_lambda_permission" "scale_runners_lambda" { + statement_id = "AllowExecutionFromSQS" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.scale_up.function_name + principal = "sqs.amazonaws.com" + source_arn = var.sqs_build_queue.arn +} + +resource "aws_iam_role" "scale_up" { + name = "${substr("${var.prefix}-scale-up-lambda", 0, 54)}-${substr(md5("${var.prefix}-scale-up-lambda"), 0, 8)}" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json + path = local.role_path + permissions_boundary = var.role_permissions_boundary + tags = local.tags +} + +resource "aws_iam_role_policy" "scale_up" { + name = "scale-up-policy" + role = aws_iam_role.scale_up.name + policy = data.aws_iam_policy_document.scale_up.json +} + +data "aws_iam_policy_document" "scale_up" { + source_policy_documents = [ + templatefile("${path.module}/policies/lambda-scale-up.json", { + sqs_arn = var.sqs_build_queue.arn + github_app_id_arn = var.github_app_parameters.id.arn + github_app_key_base64_arn = var.github_app_parameters.key_base64.arn + ssm_config_path = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" + kms_key_arn = local.kms_key_arn + }), + local.provider.scale_up.iam_policy_json, + ] +} + +resource "aws_iam_role_policy" "scale_up_logging" { + name = "logging-policy" + role = aws_iam_role.scale_up.name + policy = templatefile("${path.module}/policies/lambda-cloudwatch.json", { + log_group_arn = aws_cloudwatch_log_group.scale_up.arn + }) +} + +resource "aws_iam_role_policy" "service_linked_role" { + count = local.provider.scale_up.additional_iam_policy_json != null ? 1 : 0 + name = "service_linked_role" + role = aws_iam_role.scale_up.name + policy = local.provider.scale_up.additional_iam_policy_json +} + +resource "aws_iam_role_policy_attachment" "scale_up_vpc_execution_role" { + count = length(var.lambda_subnet_ids) > 0 ? 1 : 0 + role = aws_iam_role.scale_up.name + policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy_attachment" "ami_id_ssm_parameter_read" { + count = local.provider.scale_up.managed_policy_enabled ? 1 : 0 + role = aws_iam_role.scale_up.name + policy_arn = local.provider.scale_up.managed_policy_arn +} + +resource "aws_iam_role_policy" "scale_up_xray" { + count = var.tracing_config.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.scale_up.name +} + +resource "aws_iam_role_policy" "job_retry_sqs_publish" { + count = local.job_retry_enabled ? 1 : 0 + name = "publish-retry-check-sqs-policy" + role = aws_iam_role.scale_up.name + + policy = templatefile("${path.module}/policies/lambda-publish-sqs-policy.json", { + sqs_resource_arns = jsonencode([module.job_retry[0].job_retry_check_queue.arn]) + kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" + }) +} diff --git a/modules/runner-stack/ssm-housekeeper.tf b/modules/runner-stack/ssm-housekeeper.tf new file mode 100644 index 0000000000..ab2e014232 --- /dev/null +++ b/modules/runner-stack/ssm-housekeeper.tf @@ -0,0 +1,122 @@ +locals { + ssm_housekeeper = { + schedule_expression = var.ssm_housekeeper.schedule_expression + state = var.ssm_housekeeper.state + lambda_timeout = var.ssm_housekeeper.lambda_timeout + lambda_memory_size = var.ssm_housekeeper.lambda_memory_size + config = { + tokenPath = var.ssm_housekeeper.config.tokenPath == null ? local.token_path : var.ssm_housekeeper.config.tokenPath + minimumDaysOld = var.ssm_housekeeper.config.minimumDaysOld + dryRun = var.ssm_housekeeper.config.dryRun + } + } +} + +resource "aws_lambda_function" "ssm_housekeeper" { + s3_bucket = var.lambda_s3_bucket != null ? var.lambda_s3_bucket : null + s3_key = var.runners_lambda_s3_key != null ? var.runners_lambda_s3_key : null + s3_object_version = var.runners_lambda_s3_object_version != null ? var.runners_lambda_s3_object_version : null + filename = var.lambda_s3_bucket == null ? local.lambda_zip : null + source_code_hash = var.lambda_s3_bucket == null ? filebase64sha256(local.lambda_zip) : null + function_name = "${var.prefix}-ssm-housekeeper" + role = aws_iam_role.ssm_housekeeper.arn + handler = "index.ssmHousekeeper" + runtime = var.lambda_runtime + timeout = local.ssm_housekeeper.lambda_timeout + tags = merge(local.tags, var.lambda_tags) + memory_size = local.ssm_housekeeper.lambda_memory_size + architectures = [var.lambda_architecture] + + environment { + variables = { + ENVIRONMENT = var.prefix + LOG_LEVEL = upper(var.log_level) + SSM_CLEANUP_CONFIG = jsonencode(local.ssm_housekeeper.config) + POWERTOOLS_SERVICE_NAME = "${var.prefix}-ssm-housekeeper" + POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + } + } + + dynamic "vpc_config" { + for_each = var.lambda_subnet_ids != null && var.lambda_security_group_ids != null ? [true] : [] + content { + security_group_ids = var.lambda_security_group_ids + subnet_ids = var.lambda_subnet_ids + } + } + + dynamic "tracing_config" { + for_each = var.tracing_config.mode != null ? [true] : [] + content { + mode = var.tracing_config.mode + } + } +} + +resource "aws_cloudwatch_log_group" "ssm_housekeeper" { + name = "/aws/lambda/${aws_lambda_function.ssm_housekeeper.function_name}" + retention_in_days = var.logging_retention_in_days + kms_key_id = var.logging_kms_key_id + log_group_class = var.log_class + tags = var.tags +} + +resource "aws_cloudwatch_event_rule" "ssm_housekeeper" { + name = "${var.prefix}-ssm-housekeeper" + schedule_expression = local.ssm_housekeeper.schedule_expression + tags = var.tags + state = local.ssm_housekeeper.state +} + +resource "aws_cloudwatch_event_target" "ssm_housekeeper" { + rule = aws_cloudwatch_event_rule.ssm_housekeeper.name + arn = aws_lambda_function.ssm_housekeeper.arn +} + +resource "aws_lambda_permission" "ssm_housekeeper" { + statement_id = "AllowExecutionFromCloudWatch" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.ssm_housekeeper.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.ssm_housekeeper.arn +} + +resource "aws_iam_role" "ssm_housekeeper" { + name = "${substr("${var.prefix}-ssm-hk-lambda", 0, 54)}-${substr(md5("${var.prefix}-ssm-hk-lambda"), 0, 8)}" + description = "Lambda role for SSM Housekeeper (${var.prefix})" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json + path = local.role_path + permissions_boundary = var.role_permissions_boundary + tags = local.tags +} + +resource "aws_iam_role_policy" "ssm_housekeeper" { + name = "ssm-policy" + role = aws_iam_role.ssm_housekeeper.name + policy = templatefile("${path.module}/policies/lambda-ssm-housekeeper.json", { + ssm_token_path = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${local.token_path}" + }) +} + +resource "aws_iam_role_policy" "ssm_housekeeper_logging" { + name = "logging-policy" + role = aws_iam_role.ssm_housekeeper.name + policy = templatefile("${path.module}/policies/lambda-cloudwatch.json", { + log_group_arn = aws_cloudwatch_log_group.ssm_housekeeper.arn + }) +} + +resource "aws_iam_role_policy_attachment" "ssm_housekeeper_vpc_execution_role" { + count = length(var.lambda_subnet_ids) > 0 ? 1 : 0 + role = aws_iam_role.ssm_housekeeper.name + policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy" "ssm_housekeeper_xray" { + count = var.tracing_config.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.ssm_housekeeper.name +} diff --git a/modules/runner-stack/tests/README.md b/modules/runner-stack/tests/README.md new file mode 100644 index 0000000000..fa55dfecd9 --- /dev/null +++ b/modules/runner-stack/tests/README.md @@ -0,0 +1,72 @@ +# Terraform Tests + +This directory contains [Terraform test files](https://developer.hashicorp.com/terraform/language/tests) (`.tftest.hcl`) for the runners module. + +## Why `terraform test` instead of `terraform validate`? + +`terraform validate` only checks syntax and basic type correctness of the configuration. It **cannot** detect: + +- Conditional expressions with inconsistent result types (e.g., one branch returns an object with 1 attribute, the other returns 16) +- Runtime type mismatches that only surface during `plan` +- Invalid cross-module references that depend on resource attribute shapes + +`terraform test` with `mock_provider` runs a full plan without needing real cloud credentials, catching these classes of bugs in CI. + +## Requirements + +- Terraform >= 1.7 (for `mock_provider` and `mock_data` support) +- No AWS credentials required — all providers are mocked + +## Running locally + +```bash +cd modules/runners +terraform test -test-directory=tests +``` + +Expected output: + +``` +tests/pool.tftest.hcl... in progress + run "plan_with_pool_enabled"... pass +tests/pool.tftest.hcl... pass + +Success! 1 passed, 0 failed. +``` + +## Writing new tests + +1. Create a `.tftest.hcl` file in this directory +2. Use `mock_provider "aws" {}` to avoid needing credentials +3. Use `mock_data` blocks to provide realistic values for data sources that perform validation (e.g., `aws_iam_policy_document` validates JSON) +4. Set all required variables in a `variables {}` block +5. Use `run` blocks with `command = plan` and `assert` conditions + +### Example template + +```hcl +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + } + } +} + +variables { + # ... required variables ... +} + +run "descriptive_test_name" { + command = plan + + assert { + condition = + error_message = "Explanation of what failed" + } +} +``` + +## CI integration + +These tests run automatically in the `terraform_test` job of `.github/workflows/terraform.yml` on every PR that touches `*.tf` or `*.hcl` files. diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl new file mode 100644 index 0000000000..b6b98945d7 --- /dev/null +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -0,0 +1,136 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/runner-test" + } + } + + mock_resource "aws_ssm_parameter" { + defaults = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id" + } + } +} + +variables { + aws_region = "eu-west-1" + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + + instance_types = ["m5.large"] + + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id" + kms_key_arn = null + } + + s3_runner_binaries = { + arn = "arn:aws:s3:::my-bucket" + id = "my-bucket" + key = "runners/linux/actions-runner.tar.gz" + } + + sqs_build_queue = { + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + } + + enable_organization_runners = true + enable_ssm_on_runners = true + runner_labels = ["self-hosted", "linux", "x64"] + + # Use S3 bucket to avoid filebase64sha256 needing local zip files + lambda_s3_bucket = "my-lambda-bucket" + runners_lambda_s3_key = "runners.zip" + + github_app_parameters = { + key_base64 = { name = "/github-runner/key-base64", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" } + id = { name = "/github-runner/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" } + } + + ssm_paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + + # Enable pool to exercise the pool module and its role type + pool_config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] +} + +run "plan_with_pool_enabled" { + command = plan + + assert { + condition = length(module.pool) == 1 + error_message = "Pool module should be enabled when pool_config is non-empty" + } + + assert { + condition = output.provider.type == "ec2" + error_message = "The runner stack must expose the selected compute provider type." + } + + assert { + condition = contains(keys(output.provider.ec2), "launch_template") + error_message = "The runner stack must expose EC2 resources only under provider.ec2." + } + + assert { + condition = aws_lambda_function.scale_up.environment[0].variables["RUNNER_PROVIDER_TYPE"] == "ec2" + error_message = "Scale-up must receive the provider type from the selected provider." + } + + assert { + condition = aws_lambda_function.scale_up.environment[0].variables["INSTANCE_TYPES"] == "m5.large" + error_message = "Scale-up must merge the EC2 environment fragment." + } + + assert { + condition = aws_lambda_function.scale_down.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" + error_message = "Scale-down must merge the EC2 environment fragment." + } + + assert { + condition = length(aws_iam_role_policy_attachment.ami_id_ssm_parameter_read) == 1 + error_message = "An external AMI SSM parameter must plan the scale-up policy attachment even when its policy ARN is not known yet." + } + +} + +run "rejects_unimplemented_compute_provider" { + command = plan + + variables { + provider_type = "microvm" + } + + expect_failures = [var.provider_type] +} + +run "job_retry_uses_common_lane_identity" { + command = plan + + variables { + runner_name_prefix = "provider-neutral-" + job_retry = { + enable = true + } + } + + assert { + condition = module.job_retry[0].lambda.function.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "provider-neutral-" + error_message = "Job retry must receive the common lane runner-name prefix." + } +} diff --git a/modules/runner-stack/variables.tf b/modules/runner-stack/variables.tf new file mode 100644 index 0000000000..0822782967 --- /dev/null +++ b/modules/runner-stack/variables.tf @@ -0,0 +1,889 @@ +variable "provider_type" { + description = "Compute provider used by the common runner control plane." + type = string + default = "ec2" + + validation { + condition = contains(["ec2"], lower(trimspace(var.provider_type))) + error_message = "Supported runner providers: ec2." + } +} + +variable "ami" { + description = <= 1 && var.lambda_event_source_mapping_batch_size <= 1000 + error_message = "The batch size for the lambda event source mapping must be between 1 and 1000." + } +} + +variable "lambda_event_source_mapping_maximum_batching_window_in_seconds" { + description = "Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch_size is greater than 10. Defaults to 0." + type = number + default = 0 + validation { + condition = var.lambda_event_source_mapping_maximum_batching_window_in_seconds >= 0 && var.lambda_event_source_mapping_maximum_batching_window_in_seconds <= 300 + error_message = "Maximum batching window must be between 0 and 300 seconds." + } +} + +variable "parameter_store_tags" { + description = "Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function." + type = map(string) + default = {} +} + +variable "use_dedicated_host" { + description = "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly." + type = bool + default = false +} diff --git a/modules/runners/tests-upgrade/fixtures/pre-provider-split/versions.tf b/modules/runner-stack/versions.tf similarity index 78% rename from modules/runners/tests-upgrade/fixtures/pre-provider-split/versions.tf rename to modules/runner-stack/versions.tf index 0494d14462..da9769f550 100644 --- a/modules/runners/tests-upgrade/fixtures/pre-provider-split/versions.tf +++ b/modules/runner-stack/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.14.0" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/runners/README.md b/modules/runners/README.md index eb5364e958..0a1c0c53cd 100644 --- a/modules/runners/README.md +++ b/modules/runners/README.md @@ -2,9 +2,7 @@ > This module is treated as internal module, breaking changes will not trigger a major release bump. -This module creates a provider-neutral runner control plane and selects its compute implementation with `provider_type`. EC2 is the only active provider today and remains the default, so existing callers continue to deploy without changing their configuration. - -The common layer owns scale-up, scale-down, pool, job retry, Lambda execution roles, shared SSM configuration, and the SSM housekeeper. `providers/ec2` owns the launch template, runner instance role and policies, EC2 bootstrap parameters, runner log groups, and the EC2 environment and IAM fragments merged into the common Lambda resources. Future providers can implement the same output contract without copying the control plane. +This module creates resources required to run the GitHub action runner on AWS EC2 spot instances. The lifecycle of the runners on AWS is managed by two lambda functions. One function will handle scaling up, the other scaling down. ## Overview @@ -67,7 +65,6 @@ yarn run dist | Name | Source | Version | |------|--------|---------| -| [ec2](#module\_ec2) | ./providers/ec2 | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | @@ -79,13 +76,23 @@ yarn run dist | [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | | [aws_cloudwatch_event_target.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_log_group.gh_runners](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_cloudwatch_log_group.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_cloudwatch_log_group.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_cloudwatch_log_group.ssm_housekeeper](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_iam_role.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.create_tag](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.describe_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.dist_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ec2](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.job_retry_sqs_publish](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.runner_session_manager_aws_managed](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.scale_down_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.scale_down_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -96,10 +103,13 @@ yarn run dist | [aws_iam_role_policy.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.ssm_housekeeper_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.ssm_housekeeper_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ssm_parameters](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy_attachment.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.managed_policies](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.scale_down_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.scale_up_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.xray_tracing](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_lambda_event_source_mapping.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | | [aws_lambda_function.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | | [aws_lambda_function.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | @@ -107,15 +117,20 @@ yarn run dist | [aws_lambda_permission.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | | [aws_lambda_permission.scale_runners_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | | [aws_lambda_permission.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | 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.disable_default_labels](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.jit_config_enabled](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.runner_agent_mode](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 | | [aws_ssm_parameter.token_path](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | 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.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.lambda_xray](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 | ## Inputs @@ -190,7 +205,6 @@ yarn run dist | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | | [pool\_runner\_owner](#input\_pool\_runner\_owner) | The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported. | `string` | `null` | no | | [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no | -| [provider\_type](#input\_provider\_type) | Compute provider used by the common runner control plane. | `string` | `"ec2"` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the prefix will be used. | `string` | `null` | no | | [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | | [runner\_additional\_security\_group\_ids](#input\_runner\_additional\_security\_group\_ids) | (optional) List of additional security groups IDs to apply to the runner | `list(string)` | `[]` | no | @@ -239,10 +253,10 @@ yarn run dist | [lambda\_scale\_down\_log\_group](#output\_lambda\_scale\_down\_log\_group) | n/a | | [lambda\_scale\_up](#output\_lambda\_scale\_up) | n/a | | [lambda\_scale\_up\_log\_group](#output\_lambda\_scale\_up\_log\_group) | n/a | -| [launch\_template](#output\_launch\_template) | EC2 launch template when the selected compute provider exposes one. | +| [launch\_template](#output\_launch\_template) | n/a | | [logfiles](#output\_logfiles) | List of logfiles to send to CloudWatch. Object description: `log_group_name`: Name of the log group, `file_path`: path to the log file, `log_stream_name`: name of the log stream. | | [role\_pool](#output\_role\_pool) | n/a | -| [role\_runner](#output\_role\_runner) | EC2 runner roles when the selected compute provider exposes them. | +| [role\_runner](#output\_role\_runner) | n/a | | [role\_scale\_down](#output\_role\_scale\_down) | n/a | | [role\_scale\_up](#output\_role\_scale\_up) | n/a | | [runners\_log\_groups](#output\_runners\_log\_groups) | List of log groups from different log files of runner machine. | diff --git a/modules/runners/job-retry.tf b/modules/runners/job-retry.tf index 4adb27ad1a..00ed54d8e1 100644 --- a/modules/runners/job-retry.tf +++ b/modules/runners/job-retry.tf @@ -26,7 +26,6 @@ locals { tracing_config = var.tracing_config github_app_parameters = var.github_app_parameters enable_organization_runners = var.enable_organization_runners - runner_name_prefix = var.runner_name_prefix sqs_build_queue = var.sqs_build_queue ghes_url = var.ghes_url lambda_event_source_mapping_batch_size = var.lambda_event_source_mapping_batch_size diff --git a/modules/runners/job-retry/README.md b/modules/runners/job-retry/README.md index 9f462a6f9e..7ecd69deeb 100644 --- a/modules/runners/job-retry/README.md +++ b/modules/runners/job-retry/README.md @@ -42,7 +42,7 @@ The module is an inner module and used by the runner module when the opt-in feat | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runner_name_prefix`: Prefix used to identify runners belonging to this lane.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | +| [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs diff --git a/modules/runners/job-retry/main.tf b/modules/runners/job-retry/main.tf index 9c3377f59d..eba478b214 100644 --- a/modules/runners/job-retry/main.tf +++ b/modules/runners/job-retry/main.tf @@ -11,7 +11,6 @@ locals { JOB_QUEUE_SCALE_UP_URL = var.config.sqs_build_queue.url PARAMETER_GITHUB_APP_ID_NAME = var.config.github_app_parameters.id.name PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github_app_parameters.key_base64.name - RUNNER_NAME_PREFIX = var.config.runner_name_prefix } config = merge(var.config, { diff --git a/modules/runners/job-retry/variables.tf b/modules/runners/job-retry/variables.tf index e71712d7a2..7ccfdf63b3 100644 --- a/modules/runners/job-retry/variables.tf +++ b/modules/runners/job-retry/variables.tf @@ -23,7 +23,6 @@ variable "config" { `prefix`: The prefix used for naming resources. `role_path`: The path that will be added to the role, if not set the environment name will be used. `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda. - `runner_name_prefix`: Prefix used to identify runners belonging to this lane. `runtime`: AWS Lambda runtime. `s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly. `s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. @@ -76,7 +75,6 @@ variable "config" { }), {}) role_path = optional(string, null) role_permissions_boundary = optional(string, null) - runner_name_prefix = optional(string, "") runtime = optional(string, null) security_group_ids = optional(list(string), []) subnet_ids = optional(list(string), []) diff --git a/modules/runners/logging.tf b/modules/runners/logging.tf new file mode 100644 index 0000000000..71fd3f274e --- /dev/null +++ b/modules/runners/logging.tf @@ -0,0 +1,85 @@ +locals { + runner_log_files = ( + var.runner_log_files != null + ? var.runner_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.enable_cloudwatch_agent ? [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.enable_cloudwatch_agent ? 1 : 0 + name = "${var.ssm_paths.root}/${var.ssm_paths.config}/cloudwatch_agent_config_runner" + type = "String" + value = var.cloudwatch_config != null ? var.cloudwatch_config : templatefile("${path.module}/templates/cloudwatch_config.json", { + logfiles = jsonencode(local.logfiles) + }) + tags = local.tags +} + +resource "aws_cloudwatch_log_group" "gh_runners" { + count = length(local.loggroups_names) + name = local.loggroups_names[count.index] + retention_in_days = var.logging_retention_in_days + kms_key_id = var.logging_kms_key_id + log_group_class = local.loggroups_classes[count.index] + tags = local.tags +} + +resource "aws_iam_role_policy" "cloudwatch" { + count = var.iam_overrides["override_runner_role"] ? 0 : (var.enable_cloudwatch_agent ? 1 : 0) + name = "CloudWatchLogginAndMetrics" + role = aws_iam_role.runner[0].name + policy = templatefile("${path.module}/policies/instance-cloudwatch-policy.json", + { + ssm_parameter_arn = aws_ssm_parameter.cloudwatch_agent_config_runner[0].arn + } + ) +} diff --git a/modules/runners/main.tf b/modules/runners/main.tf index ed40f2c1dc..547f320bda 100644 --- a/modules/runners/main.tf +++ b/modules/runners/main.tf @@ -9,83 +9,334 @@ locals { var.tags, ) - role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path - lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/control-plane/runners.zip" : var.lambda_zip - kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" - enable_job_queued_check = var.enable_job_queued_check == null ? !var.enable_ephemeral_runners : var.enable_job_queued_check - token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" + name_sg = var.overrides["name_sg"] == "" ? local.tags["Name"] : var.overrides["name_sg"] + name_runner = var.overrides["name_runner"] == "" ? local.tags["Name"] : var.overrides["name_runner"] + role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path + instance_profile_path = var.instance_profile_path == null ? "/${var.prefix}/" : var.instance_profile_path + lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/control-plane/runners.zip" : var.lambda_zip + userdata_template = var.userdata_template == null ? local.default_userdata_template[var.runner_os] : var.userdata_template + kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" + s3_location_runner_distribution = var.enable_runner_binaries_syncer ? "s3://${var.s3_runner_binaries.id}/${var.s3_runner_binaries.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.ami != null ? var.ami : { + filter = local.default_ami[var.runner_os] + owners = ["amazon"] + id_ssm_parameter_arn = null + kms_key_arn = null + } + ami_kms_key_arn = local.ami_config.kms_key_arn != null ? local.ami_config.kms_key_arn : "" + ami_filter = merge(local.default_ami[var.runner_os], local.ami_config.filter) + ami_id_ssm_module_managed = 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_module_managed ? null : try(regex("parameter(/.+)$", local.ami_config.id_ssm_parameter_arn)[0], null) + + enable_job_queued_check = var.enable_job_queued_check == null ? !var.enable_ephemeral_runners : var.enable_job_queued_check + arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" - provider_type = lower(trimspace(var.provider_type)) - provider = one(module.ec2[*].provider) + token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" + + user_data = var.enable_userdata ? (var.userdata_content == null ? templatefile(local.userdata_template, { + enable_debug_logging = var.enable_user_data_debug_logging + s3_location_runner_distribution = local.s3_location_runner_distribution + pre_install = var.userdata_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.userdata_post_install + hook_job_started = var.runner_hook_job_started + hook_job_completed = var.runner_hook_job_completed + start_runner = templatefile(local.userdata_start_runner[var.runner_os], { + metadata_tags = var.metadata_options != null ? var.metadata_options.instance_metadata_tags : "enabled" + }) + ghes_url = var.ghes_url + ghes_ssl_verify = var.ghes_ssl_verify + + ## retain these for backwards compatibility + environment = var.prefix + enable_cloudwatch_agent = var.enable_cloudwatch_agent + ssm_key_cloudwatch_agent_config = var.enable_cloudwatch_agent ? aws_ssm_parameter.cloudwatch_agent_config_runner[0].name : "" + }) : var.userdata_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_caller_identity" "current" {} - -module "ec2" { - count = local.provider_type == "ec2" ? 1 : 0 - source = "./providers/ec2" - - ami = var.ami - aws_region = var.aws_region - vpc_id = var.vpc_id - subnet_ids = var.subnet_ids - overrides = var.overrides - iam_overrides = var.iam_overrides - tags = var.tags - prefix = var.prefix - s3_runner_binaries = var.s3_runner_binaries - block_device_mappings = var.block_device_mappings - ebs_optimized = var.ebs_optimized - instance_target_capacity_type = var.instance_target_capacity_type - instance_allocation_strategy = var.instance_allocation_strategy - instance_type_priorities = var.instance_type_priorities - instance_max_spot_price = var.instance_max_spot_price - runner_os = var.runner_os - instance_types = var.instance_types - enable_userdata = var.enable_userdata - userdata_template = var.userdata_template - userdata_content = var.userdata_content - userdata_pre_install = var.userdata_pre_install - userdata_post_install = var.userdata_post_install - runner_hook_job_started = var.runner_hook_job_started - runner_hook_job_completed = var.runner_hook_job_completed - runner_boot_time_in_minutes = var.runner_boot_time_in_minutes - role_permissions_boundary = var.role_permissions_boundary - role_path = var.role_path - instance_profile_path = var.instance_profile_path - runner_as_root = var.runner_as_root - runner_run_as = var.runner_run_as - runner_architecture = var.runner_architecture - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id - enable_ssm_on_runners = var.enable_ssm_on_runners - create_service_linked_role_spot = var.create_service_linked_role_spot - aws_partition = var.aws_partition - runner_iam_role_managed_policy_arns = var.runner_iam_role_managed_policy_arns - enable_cloudwatch_agent = var.enable_cloudwatch_agent - enable_managed_runner_security_group = var.enable_managed_runner_security_group - cloudwatch_config = var.cloudwatch_config - runner_log_files = var.runner_log_files - ghes_url = var.ghes_url - ghes_ssl_verify = var.ghes_ssl_verify +data "aws_ami" "runner" { + 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.id + + tags = merge( + local.tags, + { + # Remove parentheses from AMI name to comply with AWS tag constraints + "ghr:ami_name" = replace(data.aws_ami.runner.name, "/[()]/", "") + }, + { + "ghr:ami_creation_date" = data.aws_ami.runner.creation_date + }, + { + "ghr:ami_deprecation_time" = data.aws_ami.runner.deprecation_time + } + ) +} + +resource "aws_launch_template" "runner" { + name = "${var.prefix}-action-runner" + + dynamic "block_device_mappings" { + for_each = var.block_device_mappings != null ? var.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.metadata_options != null ? [var.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.metadata_options != null ? [] : [0] + + content { + instance_metadata_tags = "enabled" + } + } + + dynamic "credit_specification" { + for_each = var.credit_specification != null ? [var.credit_specification] : [] + content { + cpu_credits = credit_specification.value + } + } + + dynamic "cpu_options" { + for_each = var.cpu_options != null ? [var.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.placement != null ? [var.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.license_specifications + content { + license_configuration_arn = license_specification.value.license_configuration_arn + } + } + + monitoring { + enabled = var.enable_runner_detailed_monitoring + } + + iam_instance_profile { + name = var.iam_overrides["override_instance_profile"] ? var.iam_overrides["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 : var.ami.id_ssm_parameter_arn}" key_name = var.key_name - runner_additional_security_group_ids = var.runner_additional_security_group_ids - enable_runner_detailed_monitoring = var.enable_runner_detailed_monitoring - egress_rules = var.egress_rules - runner_ec2_tags = var.runner_ec2_tags - metadata_options = var.metadata_options - enable_runner_binaries_syncer = var.enable_runner_binaries_syncer - enable_user_data_debug_logging = var.enable_user_data_debug_logging - ssm_paths = var.ssm_paths - runner_name_prefix = var.runner_name_prefix - tracing_config = var.tracing_config - credit_specification = var.credit_specification - cpu_options = var.cpu_options - placement = var.placement - license_specifications = var.license_specifications - associate_public_ipv4_address = var.associate_public_ipv4_address - enable_on_demand_failover_for_errors = var.enable_on_demand_failover_for_errors - scale_errors = var.scale_errors - use_dedicated_host = var.use_dedicated_host + ebs_optimized = var.ebs_optimized + + vpc_security_group_ids = !var.associate_public_ipv4_address ? compact(concat( + var.enable_managed_runner_security_group ? [aws_security_group.runner_sg[0].id] : [], + var.runner_additional_security_group_ids, + )) : [] + + tag_specifications { + resource_type = "instance" + tags = merge( + local.tags, + { + "Name" = format("%s", local.name_runner) + }, + { + "ghr:runner_name_prefix" = var.runner_name_prefix + }, + var.runner_ec2_tags + ) + } + + tag_specifications { + resource_type = "volume" + tags = merge( + local.tags, + { + "Name" = format("%s", local.name_runner) + }, + { + "ghr:runner_name_prefix" = var.runner_name_prefix + }, + var.runner_ec2_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.instance_target_capacity_type == "spot" && length(var.enable_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 = merge( + local.tags, + { + "Name" = format("%s", local.name_runner) + }, + { + "ghr:runner_name_prefix" = var.runner_name_prefix + }, + var.runner_ec2_tags + ) + } + } + + tag_specifications { + resource_type = "network-interface" + tags = merge( + local.tags, + { + "Name" = format("%s", local.name_runner) + }, + { + "ghr:runner_name_prefix" = var.runner_name_prefix + }, + var.runner_ec2_tags + ) + } + + user_data = local.encoded_user_data + + tags = local.tags + + update_default_version = true + + dynamic "network_interfaces" { + for_each = var.associate_public_ipv4_address ? [var.associate_public_ipv4_address] : [] + iterator = associate_public_ipv4_address + content { + associate_public_ip_address = associate_public_ipv4_address.value + security_groups = compact(concat( + var.enable_managed_runner_security_group ? [aws_security_group.runner_sg[0].id] : [], + var.runner_additional_security_group_ids, + )) + } + } +} + +resource "aws_security_group" "runner_sg" { + count = var.enable_managed_runner_security_group ? 1 : 0 + name_prefix = "${var.prefix}-github-actions-runner-sg" + description = "Github Actions Runner security group" + + vpc_id = var.vpc_id + + ingress = [] + + dynamic "egress" { + for_each = var.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.tags, + { + "Name" = format("%s", local.name_sg) + }, + ) } diff --git a/modules/runners/moved.tf b/modules/runners/moved.tf deleted file mode 100644 index 4352f30fb1..0000000000 --- a/modules/runners/moved.tf +++ /dev/null @@ -1,94 +0,0 @@ -moved { - from = aws_ssm_parameter.runner_ami_id - to = module.ec2[0].aws_ssm_parameter.runner_ami_id -} - -moved { - from = aws_launch_template.runner - to = module.ec2[0].aws_launch_template.runner -} - -moved { - from = aws_security_group.runner_sg - to = module.ec2[0].aws_security_group.runner_sg -} - -moved { - from = aws_ssm_parameter.runner_config_run_as - to = module.ec2[0].aws_ssm_parameter.runner_config_run_as -} - -moved { - from = aws_ssm_parameter.runner_enable_cloudwatch - to = module.ec2[0].aws_ssm_parameter.runner_enable_cloudwatch -} - -moved { - from = aws_ssm_parameter.cloudwatch_agent_config_runner - to = module.ec2[0].aws_ssm_parameter.cloudwatch_agent_config_runner -} - -moved { - from = aws_cloudwatch_log_group.gh_runners - to = module.ec2[0].aws_cloudwatch_log_group.gh_runners -} - -moved { - from = aws_iam_role_policy.cloudwatch - to = module.ec2[0].aws_iam_role_policy.cloudwatch -} - -moved { - from = aws_iam_role.runner - to = module.ec2[0].aws_iam_role.runner -} - -moved { - from = aws_iam_instance_profile.runner - to = module.ec2[0].aws_iam_instance_profile.runner -} - -moved { - from = aws_iam_role_policy.runner_session_manager_aws_managed - to = module.ec2[0].aws_iam_role_policy.runner_session_manager_aws_managed -} - -moved { - from = aws_iam_role_policy.ssm_parameters - to = module.ec2[0].aws_iam_role_policy.ssm_parameters -} - -moved { - from = aws_iam_role_policy.dist_bucket - to = module.ec2[0].aws_iam_role_policy.dist_bucket -} - -moved { - from = aws_iam_role_policy_attachment.xray_tracing - to = module.ec2[0].aws_iam_role_policy_attachment.xray_tracing -} - -moved { - from = aws_iam_role_policy.describe_tags - to = module.ec2[0].aws_iam_role_policy.describe_tags -} - -moved { - from = aws_iam_role_policy.create_tag - to = module.ec2[0].aws_iam_role_policy.create_tag -} - -moved { - from = aws_iam_role_policy_attachment.managed_policies - to = module.ec2[0].aws_iam_role_policy_attachment.managed_policies -} - -moved { - from = aws_iam_role_policy.ec2 - to = module.ec2[0].aws_iam_role_policy.ec2 -} - -moved { - from = aws_iam_policy.ami_id_ssm_parameter_read - to = module.ec2[0].aws_iam_policy.ami_id_ssm_parameter_read -} diff --git a/modules/runners/outputs.tf b/modules/runners/outputs.tf index 973538ebce..8f366dce90 100644 --- a/modules/runners/outputs.tf +++ b/modules/runners/outputs.tf @@ -1,11 +1,9 @@ output "launch_template" { - description = "EC2 launch template when the selected compute provider exposes one." - value = try(local.provider.launch_template, null) + value = aws_launch_template.runner } output "role_runner" { - description = "EC2 runner roles when the selected compute provider exposes them." - value = try(local.provider.role_runner, []) + value = aws_iam_role.runner } output "lambda_scale_up" { @@ -46,10 +44,10 @@ output "role_pool" { output "runners_log_groups" { description = "List of log groups from different log files of runner machine." - value = try(local.provider.runners_log_groups, []) + value = try(aws_cloudwatch_log_group.gh_runners, []) } output "logfiles" { - value = try(local.provider.logfiles, []) + value = local.logfiles description = "List of logfiles to send to CloudWatch. Object description: `log_group_name`: Name of the log group, `file_path`: path to the log file, `log_stream_name`: name of the log stream." } diff --git a/modules/runners/policies-lambda-common.tf b/modules/runners/policies-lambda-common.tf index e3ce5c427f..0e9b2eace9 100644 --- a/modules/runners/policies-lambda-common.tf +++ b/modules/runners/policies-lambda-common.tf @@ -9,6 +9,30 @@ data "aws_iam_policy_document" "lambda_assume_role_policy" { } } +resource "aws_iam_policy" "ami_id_ssm_parameter_read" { + count = local.ami_id_ssm_parameter_name != null ? 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.tags + policy = <<-JSON + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameter" + ], + "Resource": [ + "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${trimprefix(local.ami_id_ssm_parameter_name, "/")}" + ] + } + ] + } + JSON +} + data "aws_iam_policy_document" "lambda_xray" { count = var.tracing_config.mode != null ? 1 : 0 statement { diff --git a/modules/runners/policies-runner.tf b/modules/runners/policies-runner.tf new file mode 100644 index 0000000000..0c08487a0f --- /dev/null +++ b/modules/runners/policies-runner.tf @@ -0,0 +1,84 @@ +data "aws_caller_identity" "current" {} + +resource "aws_iam_role" "runner" { + count = var.iam_overrides["override_runner_role"] ? 0 : 1 + name = "${substr("${var.prefix}-runner", 0, 54)}-${substr(md5("${var.prefix}-runner"), 0, 8)}" + assume_role_policy = templatefile("${path.module}/policies/instance-role-trust-policy.json", {}) + path = local.role_path + permissions_boundary = var.role_permissions_boundary + tags = local.tags +} + +resource "aws_iam_instance_profile" "runner" { + count = (var.iam_overrides["override_instance_profile"] || var.iam_overrides["override_runner_role"]) ? 0 : 1 + name = "${var.prefix}-runner-profile" + role = aws_iam_role.runner[0].name + path = local.instance_profile_path + tags = local.tags +} + +resource "aws_iam_role_policy" "runner_session_manager_aws_managed" { + count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : (var.enable_ssm_on_runners ? 1 : 0) + name = "runner-ssm-session" + role = aws_iam_role.runner[0].name + policy = templatefile("${path.module}/policies/instance-ssm-policy.json", {}) +} + +resource "aws_iam_role_policy" "ssm_parameters" { + count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : 1 + name = "runner-ssm-parameters" + role = aws_iam_role.runner[0].name + policy = templatefile("${path.module}/policies/instance-ssm-parameters-policy.json", + { + arn_ssm_parameters_path_tokens = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.tokens}" + arn_ssm_parameters_path_config = local.arn_ssm_parameters_path_config + } + ) +} + +resource "aws_iam_role_policy" "dist_bucket" { + count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : (var.enable_runner_binaries_syncer ? 1 : 0) + + name = "distribution-bucket" + role = aws_iam_role.runner[0].name + policy = templatefile("${path.module}/policies/instance-s3-policy.json", + { + s3_arn = "${var.s3_runner_binaries.arn}/${var.s3_runner_binaries.key}" + } + ) +} + +resource "aws_iam_role_policy_attachment" "xray_tracing" { + count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : (var.tracing_config.mode != null ? 1 : 0) + role = aws_iam_role.runner[0].name + policy_arn = "arn:${var.aws_partition}:iam::aws:policy/AWSXRayDaemonWriteAccess" +} + +resource "aws_iam_role_policy" "describe_tags" { + count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : 1 + name = "runner-describe-tags" + role = aws_iam_role.runner[0].name + policy = file("${path.module}/policies/instance-describe-tags-policy.json") +} + +resource "aws_iam_role_policy" "create_tag" { + count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : 1 + name = "runner-create-tags" + role = aws_iam_role.runner[0].name + policy = templatefile("${path.module}/policies/instance-create-tags-policy.json", {}) +} + +resource "aws_iam_role_policy_attachment" "managed_policies" { + count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : length(var.runner_iam_role_managed_policy_arns) + role = aws_iam_role.runner[0].name + policy_arn = element(var.runner_iam_role_managed_policy_arns, count.index) +} + +resource "aws_iam_role_policy" "ec2" { + count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : 1 + name = "ec2" + role = aws_iam_role.runner[0].name + policy = templatefile("${path.module}/policies/instance-ec2.json", {}) +} + +# see also logging.tf for logging and metrics policies diff --git a/modules/runners/providers/ec2/policies/instance-cloudwatch-policy.json b/modules/runners/policies/instance-cloudwatch-policy.json similarity index 100% rename from modules/runners/providers/ec2/policies/instance-cloudwatch-policy.json rename to modules/runners/policies/instance-cloudwatch-policy.json diff --git a/modules/runners/policies/instance-create-tags-policy.json b/modules/runners/policies/instance-create-tags-policy.json new file mode 100644 index 0000000000..9da09fcb70 --- /dev/null +++ b/modules/runners/policies/instance-create-tags-policy.json @@ -0,0 +1,20 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": "ec2:CreateTags", + "Condition": { + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "ghr:github_runner_id" + ] + }, + "StringEquals": { + "aws:ARN": "$${ec2:SourceInstanceARN}" + } + }, + "Effect": "Allow", + "Resource": "arn:*:ec2:*:*:instance/*" + } + ] +} diff --git a/modules/runners/providers/ec2/policies/instance-describe-tags-policy.json b/modules/runners/policies/instance-describe-tags-policy.json similarity index 100% rename from modules/runners/providers/ec2/policies/instance-describe-tags-policy.json rename to modules/runners/policies/instance-describe-tags-policy.json diff --git a/modules/runners/policies/instance-ec2.json b/modules/runners/policies/instance-ec2.json new file mode 100644 index 0000000000..4a5bc578f5 --- /dev/null +++ b/modules/runners/policies/instance-ec2.json @@ -0,0 +1,15 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "ec2:TerminateInstances", + "Resource": "*", + "Condition": { + "StringEquals": { + "aws:ARN": "$${ec2:SourceInstanceARN}" + } + } + } + ] +} diff --git a/modules/runners/policies/instance-role-trust-policy.json b/modules/runners/policies/instance-role-trust-policy.json new file mode 100644 index 0000000000..45bf82b081 --- /dev/null +++ b/modules/runners/policies/instance-role-trust-policy.json @@ -0,0 +1,13 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] +} diff --git a/modules/runners/policies/instance-s3-policy.json b/modules/runners/policies/instance-s3-policy.json new file mode 100644 index 0000000000..65d8f84b22 --- /dev/null +++ b/modules/runners/policies/instance-s3-policy.json @@ -0,0 +1,11 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "githubActionDist", + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:GetObjectAcl"], + "Resource": ["${s3_arn}"] + } + ] +} diff --git a/modules/runners/policies/instance-ssm-parameters-policy.json b/modules/runners/policies/instance-ssm-parameters-policy.json new file mode 100644 index 0000000000..fcd82304e1 --- /dev/null +++ b/modules/runners/policies/instance-ssm-parameters-policy.json @@ -0,0 +1,31 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ssm:DeleteParameter", + "ssm:GetParameters", + "ssm:GetParameter" + ], + "Resource": "${arn_ssm_parameters_path_tokens}/*", + "Condition": { + "StringLike": { + "ec2:SourceInstanceARN": "*/$${aws:ResourceTag/InstanceId}" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": [ + "${arn_ssm_parameters_path_config}", + "${arn_ssm_parameters_path_config}/*" + ] + } + ] +} diff --git a/modules/runners/providers/ec2/policies/instance-ssm-policy.json b/modules/runners/policies/instance-ssm-policy.json similarity index 100% rename from modules/runners/providers/ec2/policies/instance-ssm-policy.json rename to modules/runners/policies/instance-ssm-policy.json diff --git a/modules/runners/policies/lambda-scale-down.json b/modules/runners/policies/lambda-scale-down.json index dbd77b2383..067a747c81 100644 --- a/modules/runners/policies/lambda-scale-down.json +++ b/modules/runners/policies/lambda-scale-down.json @@ -3,15 +3,65 @@ "Statement": [ { "Effect": "Allow", - "Action": ["ssm:GetParameter", "ssm:GetParameters"], - "Resource": ["${github_app_key_base64_arn}", "${github_app_id_arn}"] - } + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeTags" + ], + "Resource": [ + "*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "ec2:TerminateInstances", + "ec2:CreateTags", + "ec2:DeleteTags" + ], + "Resource": [ + "*" + ], + "Condition": { + "StringEquals": { + "ec2:ResourceTag/ghr:Application": "github-action-runner" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:TerminateInstances", + "ec2:CreateTags", + "ec2:DeleteTags" + ], + "Resource": [ + "*" + ], + "Condition": { + "StringEquals": { + "ec2:ResourceTag/gh:environment": "${environment}" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters" + ], + "Resource": [ + "${github_app_key_base64_arn}", + "${github_app_id_arn}" + ] %{ if kms_key_arn != "" ~} - ,{ + }, + { "Effect": "Allow", - "Action": ["kms:Decrypt"], + "Action": [ + "kms:Decrypt" + ], "Resource": "${kms_key_arn}" - } %{ endif ~} + } ] } diff --git a/modules/runners/policies/lambda-scale-up.json b/modules/runners/policies/lambda-scale-up.json index c7a98ffa88..4a38d23f6a 100644 --- a/modules/runners/policies/lambda-scale-up.json +++ b/modules/runners/policies/lambda-scale-up.json @@ -1,31 +1,123 @@ { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["ssm:PutParameter", "ssm:AddTagsToResource"], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": ["ssm:GetParameter", "ssm:GetParameters"], - "Resource": [ - "${github_app_key_base64_arn}", - "${github_app_id_arn}", - "${ssm_config_path}/*" - ] - }, - { - "Effect": "Allow", - "Action": ["sqs:ReceiveMessage", "sqs:GetQueueAttributes", "sqs:DeleteMessage"], - "Resource": "${sqs_arn}" - } + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeLaunchTemplateVersions", + "ec2:DescribeTags", + "ec2:RunInstances", + "ec2:CreateFleet", + "ec2:CreateTags" + ], + "Resource": [ + "*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "ec2:TerminateInstances" + ], + "Resource": [ + "*" + ], + "Condition": { + "StringEquals": { + "ec2:ResourceTag/ghr:Application": "github-action-runner" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:TerminateInstances" + ], + "Resource": [ + "*" + ], + "Condition": { + "StringEquals": { + "ec2:ResourceTag/gh:environment": "${environment}" + } + } + }, + { + "Effect": "Allow", + "Action": "iam:PassRole", + "Resource": "${arn_runner_instance_role}" + }, + { + "Effect": "Allow", + "Action": [ + "ssm:PutParameter", + "ssm:AddTagsToResource" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters" + ], + "Resource": [ + "${github_app_key_base64_arn}", + "${github_app_id_arn}", + "${ssm_config_path}/*", + "${ssm_ami_id_parameter_arn}" + ] + }, + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameters" + ], + "Resource": [ + "${ssm_ami_id_parameter_arn}" + ] + }, + { + "Effect": "Allow", + "Action": [ + "sqs:ReceiveMessage", + "sqs:GetQueueAttributes", + "sqs:DeleteMessage" + ], + "Resource": "${sqs_arn}" %{ if kms_key_arn != "" ~} - ,{ - "Effect": "Allow", - "Action": ["kms:Decrypt"], - "Resource": "${kms_key_arn}" - } + }, + { + "Effect": "Allow", + "Action": [ + "kms:Decrypt" + ], + "Resource": "${kms_key_arn}" %{ endif ~} - ] +%{ if ami_kms_key_arn != "" ~} + }, + { + "Effect": "Allow", + "Action": [ + "kms:DescribeKey", + "kms:ReEncrypt*", + "kms:Decrypt" + ], + "Resource": "${ami_kms_key_arn}" + }, + { + "Effect": "Allow", + "Action": [ + "kms:CreateGrant" + ], + "Resource": "${ami_kms_key_arn}", + "Condition": { + "Bool": { + "aws:ViaAWSService": "true" + } + } +%{ endif ~} + } + ] } diff --git a/modules/runners/policies/service-linked-role-create-policy.json b/modules/runners/policies/service-linked-role-create-policy.json new file mode 100644 index 0000000000..18a47d5104 --- /dev/null +++ b/modules/runners/policies/service-linked-role-create-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "iam:CreateServiceLinkedRole", + "Resource": "arn:${aws_partition}:iam::*:role/aws-service-role/*" + } + ] +} diff --git a/modules/runners/pool.tf b/modules/runners/pool.tf index 79c56aeae6..11840a4638 100644 --- a/modules/runners/pool.tf +++ b/modules/runners/pool.tf @@ -9,10 +9,17 @@ module "pool" { ssl_verify = var.ghes_ssl_verify url = var.ghes_url } - user_agent = var.user_agent - github_app_parameters = var.github_app_parameters - runners_maximum_count = var.runners_maximum_count - kms_key_arn = local.kms_key_arn + user_agent = var.user_agent + github_app_parameters = var.github_app_parameters + instance_allocation_strategy = var.instance_allocation_strategy + instance_type_priorities = var.instance_type_priorities + instance_max_spot_price = var.instance_max_spot_price + instance_target_capacity_type = var.instance_target_capacity_type + instance_types = var.instance_types + runners_maximum_count = var.runners_maximum_count + kms_key_arn = local.kms_key_arn + ami_kms_key_arn = local.ami_kms_key_arn + ami_id_ssm_parameter_arn = local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn lambda = { log_level = var.log_level logging_retention_in_days = var.logging_retention_in_days @@ -36,28 +43,30 @@ module "pool" { role_path = local.role_path role_permissions_boundary = var.role_permissions_boundary runner = { - disable_runner_autoupdate = var.disable_runner_autoupdate - ephemeral = var.enable_ephemeral_runners - enable_jit_config = var.enable_jit_config - labels = var.runner_labels - group_name = var.runner_group_name - name_prefix = var.runner_name_prefix - pool_owner = var.pool_runner_owner + disable_runner_autoupdate = var.disable_runner_autoupdate + ephemeral = var.enable_ephemeral_runners + enable_jit_config = var.enable_jit_config + enable_on_demand_failover_for_errors = var.enable_on_demand_failover_for_errors + scale_errors = var.scale_errors + boot_time_in_minutes = var.runner_boot_time_in_minutes + labels = var.runner_labels + launch_template = aws_launch_template.runner + group_name = var.runner_group_name + name_prefix = var.runner_name_prefix + pool_owner = var.pool_runner_owner + role = { arn = var.iam_overrides["override_runner_role"] ? var.iam_overrides["runner_role_arn"] : aws_iam_role.runner[0].arn } + use_dedicated_host = var.use_dedicated_host } - ssm_token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" - ssm_config_path = "${var.ssm_paths.root}/${var.ssm_paths.config}" - tags = local.tags - lambda_tags = var.lambda_tags - arn_ssm_parameters_path_config = local.arn_ssm_parameters_path_config + subnet_ids = var.subnet_ids + ssm_token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" + ssm_config_path = "${var.ssm_paths.root}/${var.ssm_paths.config}" + ami_id_ssm_parameter_name = local.ami_id_ssm_parameter_name + ami_id_ssm_parameter_read_policy_arn = local.ami_id_ssm_parameter_name != null ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null + tags = local.tags + lambda_tags = var.lambda_tags + arn_ssm_parameters_path_config = local.arn_ssm_parameters_path_config } aws_partition = var.aws_partition tracing_config = var.tracing_config - runner_provider = { - type = local.provider.type - environment_variables = local.provider.pool.environment_variables - iam_policy_json = local.provider.pool.iam_policy_json - managed_policy_enabled = local.provider.pool.managed_policy_enabled - managed_policy_arn = local.provider.pool.managed_policy_arn - } } diff --git a/modules/runners/pool/README.md b/modules/runners/pool/README.md index a6962c832b..24f9de5811 100644 --- a/modules/runners/pool/README.md +++ b/modules/runners/pool/README.md @@ -10,7 +10,7 @@ The pool is an opt-in feature. To be able to use the count on a module level to | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [terraform](#requirement\_terraform) | >= 0.14.1 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers @@ -34,14 +34,13 @@ No modules. | [aws_iam_role_policy.pool_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.pool_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.pool_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_iam_role_policy_attachment.provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_lambda_function.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | | [aws_scheduler_schedule.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/scheduler_schedule) | resource | | [aws_scheduler_schedule_group.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/scheduler_schedule_group) | resource | | [aws_iam_policy_document.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.lambda_xray](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.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.scheduler_assume](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | @@ -50,8 +49,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no | -| [config](#input\_config) | Lookup details in parent module. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_arn = string
role_path = string
ssm_token_path = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
})
| n/a | yes | -| [runner\_provider](#input\_runner\_provider) | Compute provider configuration for the pool Lambda. |
object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
| n/a | yes | +| [config](#input\_config) | Lookup details in parent module. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
subnet_ids = list(string)
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
enable_on_demand_failover_for_errors = list(string)
scale_errors = list(string)
boot_time_in_minutes = number
labels = list(string)
launch_template = object({
name = string
})
group_name = string
name_prefix = string
pool_owner = string
role = object({
arn = string
})
use_dedicated_host = bool
})
runners_maximum_count = number
instance_types = list(string)
instance_type_priorities = optional(map(number))
instance_target_capacity_type = string
instance_allocation_strategy = string
instance_max_spot_price = string
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_arn = string
ami_kms_key_arn = string
ami_id_ssm_parameter_arn = string
role_path = string
ssm_token_path = string
ssm_config_path = string
ami_id_ssm_parameter_name = string
ami_id_ssm_parameter_read_policy_arn = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
})
| n/a | yes | | [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 | ## Outputs diff --git a/modules/runners/pool/main.tf b/modules/runners/pool/main.tf index c34df8ea64..e4f2485ccd 100644 --- a/modules/runners/pool/main.tf +++ b/modules/runners/pool/main.tf @@ -4,33 +4,6 @@ locals { ? "${var.config.prefix}-pool" : "${substr("${var.config.prefix}-pool", 0, 29)}-${substr(md5("${var.config.prefix}-pool"), 0, 8)}" ) - - common_environment_variables = { - DISABLE_RUNNER_AUTOUPDATE = var.config.runner.disable_runner_autoupdate - ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral - ENABLE_JIT_CONFIG = var.config.runner.enable_jit_config - ENVIRONMENT = var.config.prefix - GHES_URL = var.config.ghes.url - USER_AGENT = var.config.user_agent - LOG_LEVEL = upper(var.config.lambda.log_level) - NODE_TLS_REJECT_UNAUTHORIZED = var.config.ghes.url != null && !var.config.ghes.ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = var.config.github_app_parameters.id.name - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github_app_parameters.key_base64.name - POWERTOOLS_LOGGER_LOG_EVENT = var.config.lambda.log_level == "debug" ? "true" : "false" - RUNNER_LABELS = lower(join(",", var.config.runner.labels)) - RUNNER_GROUP_NAME = var.config.runner.group_name - RUNNER_NAME_PREFIX = var.config.runner.name_prefix - RUNNER_OWNER = var.config.runner.pool_owner - RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count - SSM_TOKEN_PATH = var.config.ssm_token_path - SSM_CONFIG_PATH = var.config.ssm_config_path - POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-pool" - POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error - SSM_PARAMETER_STORE_TAGS = var.config.lambda.parameter_store_tags - INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners - } } resource "aws_lambda_function" "pool" { @@ -51,7 +24,44 @@ resource "aws_lambda_function" "pool" { tags = merge(var.config.tags, var.config.lambda_tags) environment { - variables = merge(var.runner_provider.environment_variables, local.common_environment_variables) + variables = { + AMI_ID_SSM_PARAMETER_NAME = var.config.ami_id_ssm_parameter_name + DISABLE_RUNNER_AUTOUPDATE = var.config.runner.disable_runner_autoupdate + ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral + ENABLE_JIT_CONFIG = var.config.runner.enable_jit_config + ENVIRONMENT = var.config.prefix + GHES_URL = var.config.ghes.url + USER_AGENT = var.config.user_agent + 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 = var.config.runner.launch_template.name + LOG_LEVEL = upper(var.config.lambda.log_level) + NODE_TLS_REJECT_UNAUTHORIZED = var.config.ghes.url != null && !var.config.ghes.ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = var.config.github_app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github_app_parameters.key_base64.name + POWERTOOLS_LOGGER_LOG_EVENT = var.config.lambda.log_level == "debug" ? "true" : "false" + RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes + RUNNER_LABELS = lower(join(",", var.config.runner.labels)) + RUNNER_GROUP_NAME = var.config.runner.group_name + RUNNER_NAME_PREFIX = var.config.runner.name_prefix + RUNNER_OWNER = var.config.runner.pool_owner + RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count + SSM_TOKEN_PATH = var.config.ssm_token_path + SSM_CONFIG_PATH = var.config.ssm_config_path + SUBNET_IDS = join(",", var.config.subnet_ids) + POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-pool" + POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.config.runner.enable_on_demand_failover_for_errors) + SSM_PARAMETER_STORE_TAGS = var.config.lambda.parameter_store_tags + SCALE_ERRORS = jsonencode(var.config.runner.scale_errors) + USE_DEDICATED_HOST = var.config.runner.use_dedicated_host + INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners + } } dynamic "vpc_config" { @@ -87,21 +97,17 @@ resource "aws_iam_role" "pool" { } resource "aws_iam_role_policy" "pool" { - name = "pool-policy" - role = aws_iam_role.pool.name - policy = data.aws_iam_policy_document.pool.json -} - -data "aws_iam_policy_document" "pool" { - source_policy_documents = [ - templatefile("${path.module}/policies/lambda-pool.json", { - arn_ssm_parameters_path_config = var.config.arn_ssm_parameters_path_config - github_app_id_arn = var.config.github_app_parameters.id.arn - github_app_key_base64_arn = var.config.github_app_parameters.key_base64.arn - kms_key_arn = var.config.kms_key_arn - }), - var.runner_provider.iam_policy_json, - ] + name = "pool-policy" + role = aws_iam_role.pool.name + policy = templatefile("${path.module}/policies/lambda-pool.json", { + arn_ssm_parameters_path_config = var.config.arn_ssm_parameters_path_config + arn_runner_instance_role = var.config.runner.role.arn + github_app_id_arn = var.config.github_app_parameters.id.arn + github_app_key_base64_arn = var.config.github_app_parameters.key_base64.arn + kms_key_arn = var.config.kms_key_arn + ami_kms_key_arn = var.config.ami_kms_key_arn + ssm_ami_id_parameter_arn = var.config.ami_id_ssm_parameter_arn + }) } resource "aws_iam_role_policy" "pool_logging" { @@ -129,15 +135,10 @@ data "aws_iam_policy_document" "lambda_assume_role_policy" { } } -resource "aws_iam_role_policy_attachment" "provider" { - count = var.runner_provider.managed_policy_enabled ? 1 : 0 +resource "aws_iam_role_policy_attachment" "ami_id_ssm_parameter_read" { + count = var.config.ami_id_ssm_parameter_name != null ? 1 : 0 role = aws_iam_role.pool.name - policy_arn = var.runner_provider.managed_policy_arn -} - -moved { - from = aws_iam_role_policy_attachment.ami_id_ssm_parameter_read - to = aws_iam_role_policy_attachment.provider + policy_arn = var.config.ami_id_ssm_parameter_read_policy_arn } # lambda xray policy @@ -230,7 +231,7 @@ resource "aws_scheduler_schedule" "pool" { role_arn = aws_iam_role.scheduler.arn input = jsonencode({ poolSize = each.value.size - type = var.runner_provider.type + type = "ec2" }) } } diff --git a/modules/runners/pool/policies/lambda-pool.json b/modules/runners/pool/policies/lambda-pool.json index b44f135a64..91c9997ce4 100644 --- a/modules/runners/pool/policies/lambda-pool.json +++ b/modules/runners/pool/policies/lambda-pool.json @@ -1,6 +1,24 @@ { "Version": "2012-10-17", "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeTags", + "ec2:RunInstances", + "ec2:CreateFleet", + "ec2:CreateTags" + ], + "Resource": [ + "*" + ] + }, + { + "Effect": "Allow", + "Action": "iam:PassRole", + "Resource": "${arn_runner_instance_role}" + }, { "Effect": "Allow", "Action": [ @@ -16,9 +34,18 @@ "ssm:GetParameters", "ssm:GetParametersByPath" ], + "Resource": [ + "${arn_ssm_parameters_path_config}", + "${arn_ssm_parameters_path_config}/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameters" + ], "Resource": [ - "${arn_ssm_parameters_path_config}", - "${arn_ssm_parameters_path_config}/*" + "${ssm_ami_id_parameter_arn}" ] }, { @@ -39,6 +66,29 @@ "kms:Decrypt" ], "Resource": "${kms_key_arn}" +%{ endif ~} +%{ if ami_kms_key_arn != "" ~} + }, + { + "Effect": "Allow", + "Action": [ + "kms:DescribeKey", + "kms:ReEncrypt*", + "kms:Decrypt" + ], + "Resource": "${ami_kms_key_arn}" + }, + { + "Effect": "Allow", + "Action": [ + "kms:CreateGrant" + ], + "Resource": "${ami_kms_key_arn}", + "Condition": { + "Bool": { + "aws:ViaAWSService": "true" + } + } %{ endif ~} } ] diff --git a/modules/runners/pool/variables.tf b/modules/runners/pool/variables.tf index 833fdb4956..adf5ad571c 100644 --- a/modules/runners/pool/variables.tf +++ b/modules/runners/pool/variables.tf @@ -28,60 +28,54 @@ variable "config" { key_base64 = map(string) id = map(string) }) + subnet_ids = list(string) runner = object({ - disable_runner_autoupdate = bool - ephemeral = bool - enable_jit_config = bool - labels = list(string) - group_name = string - name_prefix = string - pool_owner = string + disable_runner_autoupdate = bool + ephemeral = bool + enable_jit_config = bool + enable_on_demand_failover_for_errors = list(string) + scale_errors = list(string) + boot_time_in_minutes = number + labels = list(string) + launch_template = object({ + name = string + }) + group_name = string + name_prefix = string + pool_owner = string + role = object({ + arn = string + }) + use_dedicated_host = bool }) - runners_maximum_count = number - prefix = string + runners_maximum_count = number + instance_types = list(string) + instance_type_priorities = optional(map(number)) + instance_target_capacity_type = string + instance_allocation_strategy = string + instance_max_spot_price = string + prefix = string pool = list(object({ schedule_expression = string schedule_expression_timezone = string size = number })) - include_busy_runners = bool - role_permissions_boundary = string - kms_key_arn = string - role_path = string - ssm_token_path = string - ssm_config_path = string - arn_ssm_parameters_path_config = string - lambda_tags = map(string) - user_agent = string + include_busy_runners = bool + role_permissions_boundary = string + kms_key_arn = string + ami_kms_key_arn = string + ami_id_ssm_parameter_arn = string + role_path = string + ssm_token_path = string + ssm_config_path = string + ami_id_ssm_parameter_name = string + ami_id_ssm_parameter_read_policy_arn = string + arn_ssm_parameters_path_config = string + lambda_tags = map(string) + user_agent = string }) } -variable "runner_provider" { - description = "Compute provider configuration for the pool Lambda." - type = object({ - type = string - environment_variables = map(string) - iam_policy_json = string - managed_policy_enabled = bool - managed_policy_arn = optional(string, null) - }) - - validation { - condition = trimspace(var.runner_provider.type) != "" - error_message = "The compute provider type must not be empty." - } - - validation { - condition = can(jsondecode(var.runner_provider.iam_policy_json)) - error_message = "The compute provider IAM policy must be valid JSON." - } - - validation { - condition = !var.runner_provider.managed_policy_enabled || var.runner_provider.managed_policy_arn != null - error_message = "The compute provider managed policy ARN must be set when its attachment is enabled." - } -} - variable "aws_partition" { description = "(optional) partition for the arn if not 'aws'" type = string @@ -97,3 +91,5 @@ variable "tracing_config" { }) default = {} } + + diff --git a/modules/runners/pool/versions.tf b/modules/runners/pool/versions.tf index 42a40b33fd..bceee0424e 100644 --- a/modules/runners/pool/versions.tf +++ b/modules/runners/pool/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.3.0" + required_version = ">= 0.14.1" required_providers { aws = { diff --git a/modules/runners/runner-config.tf b/modules/runners/runner-config.tf index 2c2de2c203..d381b7679c 100644 --- a/modules/runners/runner-config.tf +++ b/modules/runners/runner-config.tf @@ -1,3 +1,10 @@ +resource "aws_ssm_parameter" "runner_config_run_as" { + name = "${var.ssm_paths.root}/${var.ssm_paths.config}/run_as" + type = "String" + value = var.runner_as_root ? "root" : var.runner_run_as + tags = local.tags +} + resource "aws_ssm_parameter" "runner_agent_mode" { name = "${var.ssm_paths.root}/${var.ssm_paths.config}/agent_mode" type = "String" @@ -19,6 +26,13 @@ resource "aws_ssm_parameter" "jit_config_enabled" { tags = local.tags } +resource "aws_ssm_parameter" "runner_enable_cloudwatch" { + name = "${var.ssm_paths.root}/${var.ssm_paths.config}/enable_cloudwatch" + type = "String" + value = var.enable_cloudwatch_agent + tags = local.tags +} + resource "aws_ssm_parameter" "token_path" { name = "${var.ssm_paths.root}/${var.ssm_paths.config}/token_path" type = "String" diff --git a/modules/runners/scale-down.tf b/modules/runners/scale-down.tf index 874e285a60..0775893d71 100644 --- a/modules/runners/scale-down.tf +++ b/modules/runners/scale-down.tf @@ -24,7 +24,7 @@ resource "aws_lambda_function" "scale_down" { architectures = [var.lambda_architecture] environment { - variables = merge(local.provider.scale_down.environment_variables, { + variables = { ENVIRONMENT = var.prefix ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.metrics.enable && var.metrics.metric.enable_github_app_rate_limit GHES_URL = var.ghes_url @@ -35,14 +35,15 @@ resource "aws_lambda_function" "scale_down" { PARAMETER_GITHUB_APP_ID_NAME = var.github_app_parameters.id.name PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github_app_parameters.key_base64.name POWERTOOLS_LOGGER_LOG_EVENT = var.log_level == "debug" ? "true" : "false" + RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes SCALE_DOWN_CONFIG = jsonencode(var.idle_config) POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-down" POWERTOOLS_METRICS_NAMESPACE = var.metrics.namespace POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error - RUNNER_PROVIDER_TYPE = local.provider.type - }) + RUNNER_PROVIDER_TYPE = "ec2" + } } dynamic "vpc_config" { @@ -97,20 +98,14 @@ resource "aws_iam_role" "scale_down" { } resource "aws_iam_role_policy" "scale_down" { - name = "scale-down-policy" - role = aws_iam_role.scale_down.name - policy = data.aws_iam_policy_document.scale_down.json -} - -data "aws_iam_policy_document" "scale_down" { - source_policy_documents = [ - templatefile("${path.module}/policies/lambda-scale-down.json", { - github_app_id_arn = var.github_app_parameters.id.arn - github_app_key_base64_arn = var.github_app_parameters.key_base64.arn - kms_key_arn = local.kms_key_arn - }), - local.provider.scale_down.iam_policy_json, - ] + name = "scale-down-policy" + role = aws_iam_role.scale_down.name + policy = templatefile("${path.module}/policies/lambda-scale-down.json", { + environment = var.prefix + github_app_id_arn = var.github_app_parameters.id.arn + github_app_key_base64_arn = var.github_app_parameters.key_base64.arn + kms_key_arn = local.kms_key_arn + }) } resource "aws_iam_role_policy" "scale_down_logging" { diff --git a/modules/runners/scale-up.tf b/modules/runners/scale-up.tf index 1d2b4970b8..bdda3c070f 100644 --- a/modules/runners/scale-up.tf +++ b/modules/runners/scale-up.tf @@ -24,7 +24,8 @@ resource "aws_lambda_function" "scale_up" { tags = merge(local.tags, var.lambda_tags) architectures = [var.lambda_architecture] environment { - variables = merge(local.provider.scale_up.environment_variables, { + variables = { + AMI_ID_SSM_PARAMETER_NAME = local.ami_id_ssm_parameter_name DISABLE_RUNNER_AUTOUPDATE = var.disable_runner_autoupdate ENABLE_EPHEMERAL_RUNNERS = var.enable_ephemeral_runners ENABLE_JIT_CONFIG = var.enable_jit_config @@ -34,6 +35,12 @@ resource "aws_lambda_function" "scale_up" { ENVIRONMENT = var.prefix GHES_URL = var.ghes_url USER_AGENT = var.user_agent + INSTANCE_ALLOCATION_STRATEGY = var.instance_allocation_strategy + INSTANCE_MAX_SPOT_PRICE = var.instance_max_spot_price + INSTANCE_TARGET_CAPACITY_TYPE = var.instance_target_capacity_type + INSTANCE_TYPE_PRIORITIES = var.instance_type_priorities != null ? jsonencode(var.instance_type_priorities) : "" + INSTANCE_TYPES = join(",", var.instance_types) + LAUNCH_TEMPLATE_NAME = aws_launch_template.runner.name LOG_LEVEL = upper(var.log_level) MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner_os]) NODE_TLS_REJECT_UNAUTHORIZED = var.ghes_url != null && !var.ghes_ssl_verify ? 0 : 1 @@ -47,14 +54,18 @@ resource "aws_lambda_function" "scale_up" { RUNNER_LABELS = lower(join(",", var.runner_labels)) RUNNER_GROUP_NAME = var.runner_group_name RUNNER_NAME_PREFIX = var.runner_name_prefix - RUNNER_PROVIDER_TYPE = local.provider.type + RUNNER_PROVIDER_TYPE = "ec2" RUNNERS_MAXIMUM_COUNT = var.runners_maximum_count POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-up" SSM_TOKEN_PATH = local.token_path SSM_CONFIG_PATH = "${var.ssm_paths.root}/${var.ssm_paths.config}" SSM_PARAMETER_STORE_TAGS = local.parameter_store_tags + SUBNET_IDS = join(",", var.subnet_ids) + ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.enable_on_demand_failover_for_errors) + SCALE_ERRORS = jsonencode(var.scale_errors) JOB_RETRY_CONFIG = jsonencode(local.job_retry_config) - }) + USE_DEDICATED_HOST = var.use_dedicated_host + } } dynamic "vpc_config" { @@ -107,22 +118,19 @@ resource "aws_iam_role" "scale_up" { } resource "aws_iam_role_policy" "scale_up" { - name = "scale-up-policy" - role = aws_iam_role.scale_up.name - policy = data.aws_iam_policy_document.scale_up.json -} - -data "aws_iam_policy_document" "scale_up" { - source_policy_documents = [ - templatefile("${path.module}/policies/lambda-scale-up.json", { - sqs_arn = var.sqs_build_queue.arn - github_app_id_arn = var.github_app_parameters.id.arn - github_app_key_base64_arn = var.github_app_parameters.key_base64.arn - ssm_config_path = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" - kms_key_arn = local.kms_key_arn - }), - local.provider.scale_up.iam_policy_json, - ] + name = "scale-up-policy" + role = aws_iam_role.scale_up.name + policy = templatefile("${path.module}/policies/lambda-scale-up.json", { + arn_runner_instance_role = var.iam_overrides["override_runner_role"] ? var.iam_overrides["runner_role_arn"] : aws_iam_role.runner[0].arn + environment = var.prefix + sqs_arn = var.sqs_build_queue.arn + github_app_id_arn = var.github_app_parameters.id.arn + github_app_key_base64_arn = var.github_app_parameters.key_base64.arn + ssm_config_path = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" + kms_key_arn = local.kms_key_arn + ami_kms_key_arn = local.ami_kms_key_arn + ssm_ami_id_parameter_arn = local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn + }) } resource "aws_iam_role_policy" "scale_up_logging" { @@ -134,10 +142,10 @@ resource "aws_iam_role_policy" "scale_up_logging" { } resource "aws_iam_role_policy" "service_linked_role" { - count = local.provider.scale_up.additional_iam_policy_json != null ? 1 : 0 + count = var.create_service_linked_role_spot ? 1 : 0 name = "service_linked_role" role = aws_iam_role.scale_up.name - policy = local.provider.scale_up.additional_iam_policy_json + policy = templatefile("${path.module}/policies/service-linked-role-create-policy.json", { aws_partition = var.aws_partition }) } resource "aws_iam_role_policy_attachment" "scale_up_vpc_execution_role" { @@ -147,9 +155,9 @@ resource "aws_iam_role_policy_attachment" "scale_up_vpc_execution_role" { } resource "aws_iam_role_policy_attachment" "ami_id_ssm_parameter_read" { - count = local.provider.scale_up.managed_policy_enabled ? 1 : 0 + count = local.ami_id_ssm_parameter_name != null ? 1 : 0 role = aws_iam_role.scale_up.name - policy_arn = local.provider.scale_up.managed_policy_arn + policy_arn = aws_iam_policy.ami_id_ssm_parameter_read[0].arn } resource "aws_iam_role_policy" "scale_up_xray" { diff --git a/modules/runners/providers/ec2/templates/cloudwatch_config.json b/modules/runners/templates/cloudwatch_config.json similarity index 100% rename from modules/runners/providers/ec2/templates/cloudwatch_config.json rename to modules/runners/templates/cloudwatch_config.json diff --git a/modules/runners/templates/install-runner-osx.sh b/modules/runners/templates/install-runner-osx.sh new file mode 100644 index 0000000000..ed848dad27 --- /dev/null +++ b/modules/runners/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/runners/templates/install-runner.ps1 b/modules/runners/templates/install-runner.ps1 new file mode 100644 index 0000000000..a13f91a65b --- /dev/null +++ b/modules/runners/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/runners/templates/install-runner.sh b/modules/runners/templates/install-runner.sh new file mode 100644 index 0000000000..5ed5897e7c --- /dev/null +++ b/modules/runners/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/runners/templates/start-runner-osx.sh b/modules/runners/templates/start-runner-osx.sh new file mode 100644 index 0000000000..a6da66116d --- /dev/null +++ b/modules/runners/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 <&1 + + if ($LASTEXITCODE -eq 0) { + Write-Host "Successfully tagged instance with agent ID: $agentId" + return $true + } else { + Write-Host "Warning: Failed to tag instance with agent ID - $tagResult" + return $true + } + } + catch { + Write-Host "Warning: Error processing .runner file - $($_.Exception.Message)" + return $true + } +} + +## Retrieve instance metadata + +Write-Host "Retrieving TOKEN from AWS API" +$token=Invoke-RestMethod -Method PUT -Uri "http://169.254.169.254/latest/api/token" -Headers @{"X-aws-ec2-metadata-token-ttl-seconds" = "180"} +if ( ! $token ) { + $retrycount=0 + do { + echo "Failed to retrieve token. Retrying in 5 seconds." + Start-Sleep 5 + $token=Invoke-RestMethod -Method PUT -Uri "http://169.254.169.254/latest/api/token" -Headers @{"X-aws-ec2-metadata-token-ttl-seconds" = "180"} + $retrycount=$retrycount + 1 + if ( $retrycount -gt 40 ) + { + break + } + } until ($token) +} + +$ami_id=Invoke-RestMethod -Uri "http://169.254.169.254/latest/meta-data/ami-id" -Headers @{"X-aws-ec2-metadata-token" = $token} + +$metadata=Invoke-RestMethod -Uri "http://169.254.169.254/latest/dynamic/instance-identity/document" -Headers @{"X-aws-ec2-metadata-token" = $token} + +$Region = $metadata.region +Write-Host "Retrieved REGION from AWS API ($Region)" + +$InstanceId = $metadata.instanceId +Write-Host "Retrieved InstanceId from AWS API ($InstanceId)" + +$tags=aws ec2 describe-tags --region "$Region" --filters "Name=resource-id,Values=$InstanceId" | ConvertFrom-Json +Write-Host "Retrieved tags from AWS API" + +$environment=$tags.Tags.where( {$_.Key -eq 'ghr:environment'}).value +Write-Host "Retrieved ghr:environment tag - ($environment)" + +$runner_name_prefix=$tags.Tags.where( {$_.Key -eq 'ghr:runner_name_prefix'}).value +Write-Host "Retrieved ghr:runner_name_prefix tag - ($runner_name_prefix)" + +$ssm_config_path=$tags.Tags.where( {$_.Key -eq 'ghr:ssm_config_path'}).value +Write-Host "Retrieved ghr:ssm_config_path tag - ($ssm_config_path)" + +$parameters=$(aws ssm get-parameters-by-path --path "$ssm_config_path" --region "$Region" --query "Parameters[*].{Name:Name,Value:Value}") | ConvertFrom-Json +Write-Host "Retrieved parameters from AWS SSM" + +$run_as=$parameters.where( {$_.Name -eq "$ssm_config_path/run_as"}).value +Write-Host "Retrieved $ssm_config_path/run_as parameter - ($run_as)" + +$enable_cloudwatch_agent=$parameters.where( {$_.Name -eq "$ssm_config_path/enable_cloudwatch"}).value +Write-Host "Retrieved $ssm_config_path/enable_cloudwatch parameter - ($enable_cloudwatch_agent)" + +$agent_mode=$parameters.where( {$_.Name -eq "$ssm_config_path/agent_mode"}).value +Write-Host "Retrieved $ssm_config_path/agent_mode parameter - ($agent_mode)" + +$disable_default_labels=$parameters.where( {$_.Name -eq "$ssm_config_path/disable_default_labels"}).value +Write-Host "Retrieved $ssm_config_path/disable_default_labels parameter - ($disable_default_labels)" + +$enable_jit_config=$parameters.where( {$_.Name -eq "$ssm_config_path/enable_jit_config"}).value +Write-Host "Retrieved $ssm_config_path/enable_jit_config parameter - ($enable_jit_config)" + +$token_path=$parameters.where( {$_.Name -eq "$ssm_config_path/token_path"}).value +Write-Host "Retrieved $ssm_config_path/token_path parameter - ($token_path)" + + +if ($enable_cloudwatch_agent -eq "true") +{ + Write-Host "Enabling CloudWatch Agent" + & 'C:\Program Files\Amazon\AmazonCloudWatchAgent\amazon-cloudwatch-agent-ctl.ps1' -a fetch-config -m ec2 -s -c "ssm:$ssm_config_path/cloudwatch_agent_config_runner" +} + +## Configure the runner + +Write-Host "Get GH Runner config from AWS SSM" +$config = $null +$i = 0 +do { + $config = (aws ssm get-parameters --names "$token_path/$InstanceId" --with-decryption --region $Region --query "Parameters[*].{Name:Name,Value:Value}" | ConvertFrom-Json)[0].value + Write-Host "Waiting for GH Runner config to become available in AWS SSM ($i/30)" + Start-Sleep 1 + $i++ +} while (($null -eq $config) -and ($i -lt 30)) + +Write-Host "Delete GH Runner token from AWS SSM" +aws ssm delete-parameter --name "$token_path/$InstanceId" --region $Region + +# Create or update user +if (-not($run_as)) { + Write-Host "No user specified, using default ec2-user account" + $run_as="ec2-user" +} +Add-Type -AssemblyName "System.Web" +$password = [System.Web.Security.Membership]::GeneratePassword(24, 4) +$securePassword = ConvertTo-SecureString $password -AsPlainText -Force +$username = $run_as +if (!(Get-LocalUser -Name $username -ErrorAction Ignore)) { + New-LocalUser -Name $username -Password $securePassword + Write-Host "Created new user ($username)" +} +else { + Set-LocalUser -Name $username -Password $securePassword + Write-Host "Changed password for user ($username)" +} +# Add user to groups +foreach ($group in @("Administrators", "docker-users")) { + if ((Get-LocalGroup -Name "$group" -ErrorAction Ignore) -and + !(Get-LocalGroupMember -Group "$group" -Member $username -ErrorAction Ignore)) { + Add-LocalGroupMember -Group "$group" -Member $username + Write-Host "Added $username to $group group" + } +} + +# Disable User Access Control (UAC) +# TODO investigate if this is needed or if its overkill - https://github.com/github-aws-runners/terraform-aws-github-runner/issues/1505 +Set-ItemProperty HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System -Name ConsentPromptBehaviorAdmin -Value 0 -Force +Write-Host "Disabled User Access Control (UAC)" + +$runnerExtraOptions = "" +if ($disable_default_labels -eq "true") { + $runnerExtraOptions += "--no-default-labels" +} + +if ($enable_jit_config -eq "false" -or $agent_mode -ne "ephemeral") { + $configCmd = ".\config.cmd --unattended --name $runner_name_prefix$InstanceId --work `"_work`" $runnerExtraOptions $config" + Write-Host "Configure GH Runner (non ephmeral / no JIT) as user $run_as" + Invoke-Expression $configCmd + + # Tag instance with GitHub runner agent ID for non-JIT runners + Tag-InstanceWithRunnerId +} + +$jsonBody = @( + @{ + group='Runner Image' + detail="AMI id: $ami_id" + } +) +ConvertTo-Json -InputObject $jsonBody | Set-Content -Path "$pwd\.setup_info" + + +Write-Host "Starting the runner in $agent_mode mode" +Write-Host "Starting runner after $(((get-date) - (gcim Win32_OperatingSystem).LastBootUpTime).tostring("hh':'mm':'ss''"))" + +if ($agent_mode -eq "ephemeral") { + if ($enable_jit_config -eq "true") { + Write-Host "Starting with jit config" + Invoke-Expression ".\run.cmd --jitconfig $${config}" + } + else { + Write-Host "Starting without jit config" + Invoke-Expression ".\run.cmd" + } + Write-Host "Runner has finished" + + if ($enable_cloudwatch_agent) + { + Write-Host "Stopping CloudWatch Agent" + & 'C:\Program Files\Amazon\AmazonCloudWatchAgent\amazon-cloudwatch-agent-ctl.ps1' -a stop + } + + Write-Host "Terminating instance" + aws ec2 terminate-instances --instance-ids "$InstanceId" --region "$Region" +} else { + Write-Host "Installing the runner as a service" + + $action = New-ScheduledTaskAction -WorkingDirectory "$pwd" -Execute "run.cmd" + $trigger = Get-CimClass "MSFT_TaskRegistrationTrigger" -Namespace "Root/Microsoft/Windows/TaskScheduler" + Register-ScheduledTask -TaskName "runnertask" -Action $action -Trigger $trigger -User $username -Password $password -RunLevel Highest -Force + Write-Host "Starting runner after $(((get-date) - (gcim Win32_OperatingSystem).LastBootUpTime).tostring("hh':'mm':'ss''"))" +} diff --git a/modules/runners/templates/start-runner.sh b/modules/runners/templates/start-runner.sh new file mode 100644 index 0000000000..7f2c0f82c5 --- /dev/null +++ b/modules/runners/templates/start-runner.sh @@ -0,0 +1,280 @@ +#!/bin/bash + +# https://docs.aws.amazon.com/xray/latest/devguide/xray-api-sendingdata.html +# https://docs.aws.amazon.com/xray/latest/devguide/scorekeep-scripts.html +create_xray_start_segment() { + START_TIME=$(date -d "$(uptime -s)" +%s) + TRACE_ID=$1 + INSTANCE_ID=$2 + SEGMENT_ID=$(dd if=/dev/random bs=8 count=1 2>/dev/null | od -An -tx1 | tr -d ' \t\n') + SEGMENT_DOC="{\"trace_id\": \"$TRACE_ID\", \"id\": \"$SEGMENT_ID\", \"start_time\": $START_TIME, \"in_progress\": true, \"name\": \"Runner\",\"origin\": \"AWS::EC2::Instance\", \"aws\": {\"ec2\":{\"instance_id\":\"$INSTANCE_ID\"}}}" + HEADER='{"format": "json", "version": 1}' + TRACE_DATA="$HEADER\n$SEGMENT_DOC" + echo "$HEADER" > document.txt + echo "$SEGMENT_DOC" >> document.txt + UDP_IP="127.0.0.1" + UDP_PORT=2000 + cat document.txt > /dev/udp/$UDP_IP/$UDP_PORT + echo "$SEGMENT_DOC" +} + +create_xray_success_segment() { + local SEGMENT_DOC=$1 + if [ -z "$SEGMENT_DOC" ]; then + echo "No segment doc provided" + return + fi + SEGMENT_DOC=$(echo "$SEGMENT_DOC" | jq '. | del(.in_progress)') + END_TIME=$(date +%s) + SEGMENT_DOC=$(echo "$SEGMENT_DOC" | jq -c ". + {\"end_time\": $END_TIME}") + HEADER="{\"format\": \"json\", \"version\": 1}" + TRACE_DATA="$HEADER\n$SEGMENT_DOC" + echo "$HEADER" > document.txt + echo "$SEGMENT_DOC" >> document.txt + UDP_IP="127.0.0.1" + UDP_PORT=2000 + cat document.txt > /dev/udp/$UDP_IP/$UDP_PORT + echo "$SEGMENT_DOC" +} + +create_xray_error_segment() { + local SEGMENT_DOC="$1" + if [ -z "$SEGMENT_DOC" ]; then + echo "No segment doc provided" + return + fi + MESSAGE="$2" + ERROR="{\"exceptions\": [{\"message\": \"$MESSAGE\"}]}" + SEGMENT_DOC=$(echo "$SEGMENT_DOC" | jq '. | del(.in_progress)') + END_TIME=$(date +%s) + SEGMENT_DOC=$(echo "$SEGMENT_DOC" | jq -c ". + {\"end_time\": $END_TIME, \"error\": true, \"cause\": $ERROR }") + HEADER="{\"format\": \"json\", \"version\": 1}" + TRACE_DATA="$HEADER\n$SEGMENT_DOC" + echo "$HEADER" > document.txt + echo "$SEGMENT_DOC" >> document.txt + UDP_IP="127.0.0.1" + UDP_PORT=2000 + cat document.txt > /dev/udp/$UDP_IP/$UDP_PORT + echo "$SEGMENT_DOC" +} + +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" + local error_location="$2" + local error_lineno="$3" + + if [ "$exit_code" -ne 0 ]; then + echo "ERROR: runner-start-failed with exit code $exit_code occurred on $error_location" + create_xray_error_segment "$SEGMENT" "runner-start-failed with exit code $exit_code occurred on $error_location - $error_lineno" + fi + # allows to flush the cloud watch logs and traces + sleep 10 + if [ "$agent_mode" = "ephemeral" ] || [ "$exit_code" -ne 0 ]; then + echo "Stopping CloudWatch service" + systemctl stop amazon-cloudwatch-agent.service || true + echo "Terminating instance" + aws ec2 terminate-instances \ + --instance-ids "$instance_id" \ + --region "$region" \ + || true + fi +} + +trap 'cleanup $? $LINENO $BASH_LINENO' 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 + +ami_id=$(curl -f -H "X-aws-ec2-metadata-token: $token" -v http://169.254.169.254/latest/meta-data/ami-id) + +region=$(curl -f -H "X-aws-ec2-metadata-token: $token" -v 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" -v http://169.254.169.254/latest/meta-data/instance-id) +echo "Retrieved INSTANCE_ID from AWS API ($instance_id)" + +instance_type=$(curl -f -H "X-aws-ec2-metadata-token: $token" -v http://169.254.169.254/latest/meta-data/instance-type) +availability_zone=$(curl -f -H "X-aws-ec2-metadata-token: $token" -v http://169.254.169.254/latest/meta-data/placement/availability-zone) + +%{ if metadata_tags == "enabled" } +environment=$(curl -f -H "X-aws-ec2-metadata-token: $token" -v http://169.254.169.254/latest/meta-data/tags/instance/ghr:environment) +ssm_config_path=$(curl -f -H "X-aws-ec2-metadata-token: $token" -v http://169.254.169.254/latest/meta-data/tags/instance/ghr:ssm_config_path) +runner_name_prefix=$(curl -f -H "X-aws-ec2-metadata-token: $token" -v http://169.254.169.254/latest/meta-data/tags/instance/ghr:runner_name_prefix || echo "") +xray_trace_id=$(curl -f -H "X-aws-ec2-metadata-token: $token" -v http://169.254.169.254/latest/meta-data/tags/instance/ghr:trace_id || echo "") + +%{ else } +tags=$(aws ec2 describe-tags --region "$region" --filters "Name=resource-id,Values=$instance_id") +echo "Retrieved tags from AWS API ($tags)" + +environment=$(echo "$tags" | jq -r '.Tags[] | select(.Key == "ghr:environment") | .Value') +ssm_config_path=$(echo "$tags" | jq -r '.Tags[] | select(.Key == "ghr:ssm_config_path") | .Value') +runner_name_prefix=$(echo "$tags" | jq -r '.Tags[] | select(.Key == "ghr:runner_name_prefix") | .Value' || echo "") +xray_trace_id=$(echo "$tags" | jq -r '.Tags[] | select(.Key == "ghr:trace_id") | .Value' || echo "") + +%{ endif } + +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)" + +enable_cloudwatch_agent=$(echo "$parameters" | jq --arg ssm_config_path "$ssm_config_path" -r '.[] | select(.Name == "'$ssm_config_path'/enable_cloudwatch") | .Value') +echo "Retrieved /$ssm_config_path/enable_cloudwatch parameter - ($enable_cloudwatch_agent)" + +agent_mode=$(echo "$parameters" | jq --arg ssm_config_path "$ssm_config_path" -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 --arg ssm_config_path "$ssm_config_path" -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 --arg ssm_config_path "$ssm_config_path" -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 --arg ssm_config_path "$ssm_config_path" -r '.[] | select(.Name == "'$ssm_config_path'/token_path") | .Value') +echo "Retrieved /$ssm_config_path/token_path parameter - ($token_path)" + +if [[ "$xray_trace_id" != "" ]]; then + # run xray service + curl https://s3.us-east-2.amazonaws.com/aws-xray-assets.us-east-2/xray-daemon/aws-xray-daemon-linux-3.x.zip -o aws-xray-daemon-linux-3.x.zip + unzip aws-xray-daemon-linux-3.x.zip -d aws-xray-daemon-linux-3.x + chmod +x ./aws-xray-daemon-linux-3.x/xray + ./aws-xray-daemon-linux-3.x/xray -o -n "$region" & + + + SEGMENT=$(create_xray_start_segment "$xray_trace_id" "$instance_id") + echo "$SEGMENT" +fi + +if [[ "$enable_cloudwatch_agent" == "true" ]]; then + echo "Cloudwatch is enabled" + amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -s -c "ssm:$ssm_config_path/cloudwatch_agent_config_runner" +fi + +## Configure the runner + +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 + +chown -R $run_as /opt/actions-runner + +info_arch=$(uname -p) +info_os=$( ( lsb_release -ds || cat /etc/*release || uname -om ) 2>/dev/null | head -n1 | cut -d "=" -f2- | tr -d '"') + +tee /opt/actions-runner/.setup_info </dev/null 2>&1; then + echo "Homebrew detected; you can install extra dependencies via brew if needed" +fi + +user_name=ec2-user + +${install_runner} + +${post_install} + +# Register runner job hooks +# Ref: https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/running-scripts-before-or-after-a-job +%{ if hook_job_started != "" } +cat > /opt/actions-runner/hook_job_started.sh <<'EOF' +${hook_job_started} +EOF +echo ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/actions-runner/hook_job_started.sh | tee -a /opt/actions-runner/.env +%{ endif } + +%{ if hook_job_completed != "" } +cat > /opt/actions-runner/hook_job_completed.sh <<'EOF' +${hook_job_completed} +EOF +echo ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/opt/actions-runner/hook_job_completed.sh | tee -a /opt/actions-runner/.env +%{ endif } + +${start_runner} diff --git a/modules/runners/providers/ec2/templates/user-data.ps1 b/modules/runners/templates/user-data.ps1 similarity index 100% rename from modules/runners/providers/ec2/templates/user-data.ps1 rename to modules/runners/templates/user-data.ps1 diff --git a/modules/runners/templates/user-data.sh b/modules/runners/templates/user-data.sh new file mode 100644 index 0000000000..ca69f26d34 --- /dev/null +++ b/modules/runners/templates/user-data.sh @@ -0,0 +1,81 @@ +#!/bin/bash -e + +install_with_retry() { + max_attempts=5 + attempt_count=0 + success=false + while [ $success = false ] && [ $attempt_count -le $max_attempts ]; do + echo "Attempting $attempt_count/$max_attempts: Installing $*" + dnf install -y $* + if [ $? -eq 0 ]; then + success=true + else + echo "Failed to install $1 - retrying" + attempt_count=$(( attempt_count + 1 )) + sleep 5 + fi + done +} + +exec > >(tee /var/log/user-data.log | logger -t user-data -s 2>/dev/console) 2>&1 + +# AWS suggest to create a log for debug purpose based on https://aws.amazon.com/premiumsupport/knowledge-center/ec2-linux-log-user-data/ +# As side effect all command, set +x disable debugging explicitly. +# +# An alternative for masking tokens could be: exec > >(sed 's/--token\ [^ ]* /--token\ *** /g' > /var/log/user-data.log) 2>&1 + +set +x + +%{ if enable_debug_logging } +set -x +%{ endif } + +${pre_install} + +max_attempts=5 +attempt_count=0 +success=false +while [ $success = false ] && [ $attempt_count -le $max_attempts ]; do + echo "Attempting $attempt_count/$max_attempts: upgrade-minimal" + dnf upgrade-minimal -y +if [ $? -eq 0 ]; then + success=true + else + echo "Failed to run `dnf upgrad-minimal -y` - retrying" + attempt_count=$(( attempt_count + 1 )) + sleep 5 + fi +done + +# Install docker +install_with_retry docker + +service docker start +usermod -a -G docker ec2-user + +install_with_retry amazon-cloudwatch-agent jq git +install_with_retry --allowerasing curl + +user_name=ec2-user + +${install_runner} + +${post_install} + +# Register runner job hooks +# Ref: https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/running-scripts-before-or-after-a-job +%{ if hook_job_started != "" } +cat > /opt/actions-runner/hook_job_started.sh <<'EOF' +${hook_job_started} +EOF +echo ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/actions-runner/hook_job_started.sh | tee -a /opt/actions-runner/.env +%{ endif } + +%{ if hook_job_completed != "" } +cat > /opt/actions-runner/hook_job_completed.sh <<'EOF' +${hook_job_completed} +EOF +echo ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/opt/actions-runner/hook_job_completed.sh | tee -a /opt/actions-runner/.env +%{ endif } + +${start_runner} diff --git a/modules/runners/tests-upgrade/README.md b/modules/runners/tests-upgrade/README.md deleted file mode 100644 index c18fa7ca82..0000000000 --- a/modules/runners/tests-upgrade/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Runner provider-split state upgrade test - -The test first applies a frozen copy of the EC2 resources at their historical -root addresses. It then shares that in-memory state with a plan of the current -`modules/runners` configuration. - -Two state paths are tested because the module-managed AMI parameter and the -external AMI read policy are mutually exclusive. The ordinary Terraform test -assertions retain representative launch-template, runner-role, and runner-log -group IDs. CI also reads Terraform's verbose JSON plans with -`verify-moves.jq`; it requires every historical resource address to appear as -`previous_address` and rejects create or delete actions for every moved -resource. diff --git a/modules/runners/tests-upgrade/fixtures/pre-provider-split/README.md b/modules/runners/tests-upgrade/fixtures/pre-provider-split/README.md deleted file mode 100644 index 9e748e53c7..0000000000 --- a/modules/runners/tests-upgrade/fixtures/pre-provider-split/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Frozen pre-provider-split fixture - -This fixture intentionally declares the EC2 resources at their historical -`modules/runners` addresses. Do not replace it with a call to the current -module: the upgrade test needs old state addresses so the current `moved` -blocks are exercised. - -The external-AMI and module-managed-AMI runs cover the two mutually exclusive -count paths for `runner_ami_id` and `ami_id_ssm_parameter_read`. - - -## Requirements - -| Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.14.0 | -| [aws](#requirement\_aws) | >= 6.33 | - -## Providers - -| Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | - -## 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_iam_role.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | -| [aws_iam_role_policy.cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.create_tag](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.describe_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.dist_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.ec2](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.runner_session_manager_aws_managed](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.ssm_parameters](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy_attachment.managed_policies](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_iam_role_policy_attachment.xray_tracing](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | 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 | -| [aws_ami.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [prefix](#input\_prefix) | Prefix used to reproduce the historical runner resource addresses. | `string` | n/a | yes | -| [use\_external\_ami](#input\_use\_external\_ami) | Selects the historical external-AMI policy path instead of the module-managed AMI parameter path. | `bool` | n/a | yes | - -## Outputs - -| Name | Description | -|------|-------------| -| [moved\_resource\_ids](#output\_moved\_resource\_ids) | n/a | - \ No newline at end of file diff --git a/modules/runners/tests-upgrade/fixtures/pre-provider-split/main.tf b/modules/runners/tests-upgrade/fixtures/pre-provider-split/main.tf deleted file mode 100644 index 033e179448..0000000000 --- a/modules/runners/tests-upgrade/fixtures/pre-provider-split/main.tf +++ /dev/null @@ -1,287 +0,0 @@ -variable "prefix" { - description = "Prefix used to reproduce the historical runner resource addresses." - type = string -} - -variable "use_external_ami" { - description = "Selects the historical external-AMI policy path instead of the module-managed AMI parameter path." - type = bool -} - -locals { - external_ami_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/shared/runner-ami" - ssm_root = "/github-action-runners/upgrade" - ssm_config = "runners/config" - role_path = "/${var.prefix}/" - instance_profile_path = "/${var.prefix}/" - - tags = { - Name = "${var.prefix}-action-runner" - "ghr:ssm_config_path" = "${local.ssm_root}/${local.ssm_config}" - } - - runner_log_groups = [ - "/github-self-hosted-runners/${var.prefix}/messages", - "/github-self-hosted-runners/${var.prefix}/user_data", - "/github-self-hosted-runners/${var.prefix}/runner", - "/github-self-hosted-runners/${var.prefix}/runner-startup", - ] - - common_policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Effect = "Allow" - Action = ["logs:CreateLogStream"] - Resource = "*" - }] - }) -} - -data "aws_ami" "runner" { - most_recent = true - - filter { - name = "name" - values = ["al2023-ami-2023.*-kernel-6.*-x86_64"] - } - - filter { - name = "state" - values = ["available"] - } - - owners = ["amazon"] -} - -resource "aws_ssm_parameter" "runner_ami_id" { - count = var.use_external_ami ? 0 : 1 - name = "${local.ssm_root}/${local.ssm_config}/ami_id" - type = "String" - data_type = "aws:ec2:image" - value = data.aws_ami.runner.id - - tags = merge(local.tags, { - "ghr:ami_name" = replace(data.aws_ami.runner.name, "/[()]/", "") - "ghr:ami_creation_date" = data.aws_ami.runner.creation_date - "ghr:ami_deprecation_time" = data.aws_ami.runner.deprecation_time - }) -} - -resource "aws_security_group" "runner_sg" { - count = 1 - name_prefix = "${var.prefix}-github-actions-runner-sg" - description = "Github Actions Runner security group" - vpc_id = "vpc-12345678" - ingress = [] - - egress { - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = ["::/0"] - from_port = 0 - protocol = "-1" - to_port = 0 - } - - tags = merge(local.tags, { Name = local.tags.Name }) -} - -resource "aws_iam_role" "runner" { - count = 1 - name = "${substr("${var.prefix}-runner", 0, 54)}-${substr(md5("${var.prefix}-runner"), 0, 8)}" - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Effect = "Allow" - Principal = { - Service = "ec2.amazonaws.com" - } - Action = "sts:AssumeRole" - }] - }) - path = local.role_path - tags = local.tags -} - -resource "aws_iam_instance_profile" "runner" { - count = 1 - name = "${var.prefix}-runner-profile" - role = aws_iam_role.runner[0].name - path = local.instance_profile_path - tags = local.tags -} - -resource "aws_launch_template" "runner" { - name = "${var.prefix}-action-runner" - - block_device_mappings { - device_name = "/dev/xvda" - - ebs { - delete_on_termination = true - encrypted = true - volume_size = 30 - volume_type = "gp3" - } - } - - metadata_options { - http_endpoint = "enabled" - http_tokens = "required" - http_put_response_hop_limit = 1 - instance_metadata_tags = "enabled" - } - - monitoring { - enabled = false - } - - iam_instance_profile { - name = aws_iam_instance_profile.runner[0].name - } - - instance_initiated_shutdown_behavior = "terminate" - image_id = "resolve:ssm:${ - var.use_external_ami - ? local.external_ami_parameter_arn - : aws_ssm_parameter.runner_ami_id[0].arn - }" - ebs_optimized = false - - vpc_security_group_ids = [aws_security_group.runner_sg[0].id] - - tag_specifications { - resource_type = "instance" - tags = merge(local.tags, { - Name = local.tags.Name - "ghr:runner_name_prefix" = "" - }) - } - - tag_specifications { - resource_type = "volume" - tags = merge(local.tags, { - Name = local.tags.Name - "ghr:runner_name_prefix" = "" - }) - } - - tag_specifications { - resource_type = "spot-instances-request" - tags = merge(local.tags, { - Name = local.tags.Name - "ghr:runner_name_prefix" = "" - }) - } - - tag_specifications { - resource_type = "network-interface" - tags = merge(local.tags, { - Name = local.tags.Name - "ghr:runner_name_prefix" = "" - }) - } - - user_data = base64gzip("") - tags = local.tags - update_default_version = true -} - -resource "aws_ssm_parameter" "runner_config_run_as" { - name = "${local.ssm_root}/${local.ssm_config}/run_as" - type = "String" - value = "ec2-user" - tags = local.tags -} - -resource "aws_ssm_parameter" "runner_enable_cloudwatch" { - name = "${local.ssm_root}/${local.ssm_config}/enable_cloudwatch" - type = "String" - value = true - tags = local.tags -} - -resource "aws_ssm_parameter" "cloudwatch_agent_config_runner" { - count = 1 - name = "${local.ssm_root}/${local.ssm_config}/cloudwatch_agent_config_runner" - type = "String" - value = jsonencode({ logs = { logs_collected = { files = { collect_list = [] } } } }) - tags = local.tags -} - -resource "aws_cloudwatch_log_group" "gh_runners" { - count = length(local.runner_log_groups) - name = local.runner_log_groups[count.index] - retention_in_days = 180 - log_group_class = "STANDARD" - tags = local.tags -} - -resource "aws_iam_role_policy" "cloudwatch" { - count = 1 - name = "CloudWatchLogginAndMetrics" - role = aws_iam_role.runner[0].name - policy = local.common_policy -} - -resource "aws_iam_role_policy" "runner_session_manager_aws_managed" { - count = 1 - name = "runner-ssm-session" - role = aws_iam_role.runner[0].name - policy = local.common_policy -} - -resource "aws_iam_role_policy" "ssm_parameters" { - count = 1 - name = "runner-ssm-parameters" - role = aws_iam_role.runner[0].name - policy = local.common_policy -} - -resource "aws_iam_role_policy" "dist_bucket" { - count = 1 - name = "distribution-bucket" - role = aws_iam_role.runner[0].name - policy = local.common_policy -} - -resource "aws_iam_role_policy_attachment" "xray_tracing" { - count = 1 - role = aws_iam_role.runner[0].name - policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess" -} - -resource "aws_iam_role_policy" "describe_tags" { - count = 1 - name = "runner-describe-tags" - role = aws_iam_role.runner[0].name - policy = local.common_policy -} - -resource "aws_iam_role_policy" "create_tag" { - count = 1 - name = "runner-create-tags" - role = aws_iam_role.runner[0].name - policy = local.common_policy -} - -resource "aws_iam_role_policy_attachment" "managed_policies" { - count = 1 - role = aws_iam_role.runner[0].name - policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" -} - -resource "aws_iam_role_policy" "ec2" { - count = 1 - name = "ec2" - role = aws_iam_role.runner[0].name - policy = local.common_policy -} - -resource "aws_iam_policy" "ami_id_ssm_parameter_read" { - count = var.use_external_ami ? 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.tags - policy = local.common_policy -} diff --git a/modules/runners/tests-upgrade/fixtures/pre-provider-split/outputs.tf b/modules/runners/tests-upgrade/fixtures/pre-provider-split/outputs.tf deleted file mode 100644 index 63faf53a31..0000000000 --- a/modules/runners/tests-upgrade/fixtures/pre-provider-split/outputs.tf +++ /dev/null @@ -1,23 +0,0 @@ -output "moved_resource_ids" { - value = { - runner_ami_id = try(aws_ssm_parameter.runner_ami_id[0].id, null) - launch_template = aws_launch_template.runner.id - security_group = aws_security_group.runner_sg[0].id - runner_config_run_as = aws_ssm_parameter.runner_config_run_as.id - runner_enable_cloudwatch = aws_ssm_parameter.runner_enable_cloudwatch.id - cloudwatch_agent_config_runner = aws_ssm_parameter.cloudwatch_agent_config_runner[0].id - runner_log_groups = aws_cloudwatch_log_group.gh_runners[*].id - cloudwatch_policy = aws_iam_role_policy.cloudwatch[0].id - runner_role = aws_iam_role.runner[0].id - runner_instance_profile = aws_iam_instance_profile.runner[0].id - runner_session_manager_policy = aws_iam_role_policy.runner_session_manager_aws_managed[0].id - runner_ssm_parameters_policy = aws_iam_role_policy.ssm_parameters[0].id - runner_distribution_bucket_policy = aws_iam_role_policy.dist_bucket[0].id - runner_xray_attachment = aws_iam_role_policy_attachment.xray_tracing[0].id - runner_describe_tags_policy = aws_iam_role_policy.describe_tags[0].id - runner_create_tag_policy = aws_iam_role_policy.create_tag[0].id - runner_managed_policy_attachment = aws_iam_role_policy_attachment.managed_policies[0].id - runner_ec2_policy = aws_iam_role_policy.ec2[0].id - ami_parameter_read_policy = try(aws_iam_policy.ami_id_ssm_parameter_read[0].id, null) - } -} diff --git a/modules/runners/tests-upgrade/state-migration.tftest.hcl b/modules/runners/tests-upgrade/state-migration.tftest.hcl deleted file mode 100644 index 1450a12b4d..0000000000 --- a/modules/runners/tests-upgrade/state-migration.tftest.hcl +++ /dev/null @@ -1,249 +0,0 @@ -mock_provider "aws" { - mock_data "aws_ami" { - defaults = { - id = "ami-1234567890abcdef0" - name = "al2023-ami-2023.9.20260101.0-kernel-6.1-x86_64" - creation_date = "2026-01-01T00:00:00.000Z" - deprecation_time = "" - } - } - - mock_data "aws_caller_identity" { - defaults = { - account_id = "123456789012" - arn = "arn:aws:iam::123456789012:user/terraform-test" - id = "123456789012" - } - } - - mock_data "aws_iam_policy_document" { - defaults = { - json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"logs:CreateLogStream\",\"Resource\":\"*\"}]}" - } - } - - mock_resource "aws_iam_role" { - defaults = { - arn = "arn:aws:iam::123456789012:role/upgrade-test" - } - } - - mock_resource "aws_iam_policy" { - defaults = { - arn = "arn:aws:iam::123456789012:policy/upgrade-test" - } - } - - mock_resource "aws_ssm_parameter" { - defaults = { - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/upgrade/value" - } - } - - mock_resource "aws_cloudwatch_log_group" { - defaults = { - arn = "arn:aws:logs:eu-west-1:123456789012:log-group:upgrade-test" - } - } - - mock_resource "aws_launch_template" { - defaults = { - arn = "arn:aws:ec2:eu-west-1:123456789012:launch-template/lt-upgrade-test" - latest_version = 1 - } - } - - mock_resource "aws_lambda_function" { - defaults = { - arn = "arn:aws:lambda:eu-west-1:123456789012:function:upgrade-test" - } - } - - mock_resource "aws_cloudwatch_event_rule" { - defaults = { - arn = "arn:aws:events:eu-west-1:123456789012:rule/upgrade-test" - } - } -} - -run "apply_pre_provider_split_external_ami" { - command = apply - state_key = "provider-split-external-ami" - - module { - source = "./tests-upgrade/fixtures/pre-provider-split" - } - - variables { - prefix = "upgrade-test-external" - use_external_ami = true - } -} - -run "plan_provider_split_external_ami" { - command = plan - state_key = "provider-split-external-ami" - - plan_options { - refresh = false - } - - variables { - aws_region = "eu-west-1" - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - prefix = "upgrade-test-external" - - ami = { - filter = { state = ["available"] } - owners = ["amazon"] - id_ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/shared/runner-ami" - kms_key_arn = null - } - - instance_types = ["m5.large"] - enable_userdata = false - enable_ssm_on_runners = true - runner_labels = ["self-hosted", "linux", "x64"] - enable_organization_runners = true - - s3_runner_binaries = { - arn = "arn:aws:s3:::runner-binaries" - id = "runner-binaries" - key = "runners/linux/actions-runner.tar.gz" - } - - sqs_build_queue = { - arn = "arn:aws:sqs:eu-west-1:123456789012:upgrade-test-external" - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/upgrade-test-external" - } - - github_app_parameters = { - key_base64 = { - name = "/github-action-runners/upgrade/app/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/upgrade/app/key-base64" - } - id = { - name = "/github-action-runners/upgrade/app/id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/upgrade/app/id" - } - } - - ssm_paths = { - root = "/github-action-runners/upgrade" - tokens = "runners/tokens" - config = "runners/config" - } - - lambda_s3_bucket = "lambda-artifacts" - runners_lambda_s3_key = "runners.zip" - - runner_iam_role_managed_policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"] - tracing_config = { - mode = "Active" - } - } - - assert { - condition = output.launch_template.id == run.apply_pre_provider_split_external_ami.moved_resource_ids.launch_template - error_message = "The external-AMI upgrade must retain the launch template state." - } - - assert { - condition = output.role_runner[0].id == run.apply_pre_provider_split_external_ami.moved_resource_ids.runner_role - error_message = "The external-AMI upgrade must retain the runner role state." - } - - assert { - condition = output.runners_log_groups[*].id == run.apply_pre_provider_split_external_ami.moved_resource_ids.runner_log_groups - error_message = "The external-AMI upgrade must retain the optional runner log-group state." - } -} - -run "apply_pre_provider_split_managed_ami" { - command = apply - state_key = "provider-split-managed-ami" - - module { - source = "./tests-upgrade/fixtures/pre-provider-split" - } - - variables { - prefix = "upgrade-test-managed" - use_external_ami = false - } -} - -run "plan_provider_split_managed_ami" { - command = plan - state_key = "provider-split-managed-ami" - - plan_options { - refresh = false - } - - variables { - aws_region = "eu-west-1" - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - prefix = "upgrade-test-managed" - - ami = null - instance_types = ["m5.large"] - enable_userdata = false - enable_ssm_on_runners = true - runner_labels = ["self-hosted", "linux", "x64"] - enable_organization_runners = true - - s3_runner_binaries = { - arn = "arn:aws:s3:::runner-binaries" - id = "runner-binaries" - key = "runners/linux/actions-runner.tar.gz" - } - - sqs_build_queue = { - arn = "arn:aws:sqs:eu-west-1:123456789012:upgrade-test-managed" - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/upgrade-test-managed" - } - - github_app_parameters = { - key_base64 = { - name = "/github-action-runners/upgrade/app/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/upgrade/app/key-base64" - } - id = { - name = "/github-action-runners/upgrade/app/id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/upgrade/app/id" - } - } - - ssm_paths = { - root = "/github-action-runners/upgrade" - tokens = "runners/tokens" - config = "runners/config" - } - - lambda_s3_bucket = "lambda-artifacts" - runners_lambda_s3_key = "runners.zip" - - runner_iam_role_managed_policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"] - tracing_config = { - mode = "Active" - } - } - - assert { - condition = output.launch_template.id == run.apply_pre_provider_split_managed_ami.moved_resource_ids.launch_template - error_message = "The module-managed-AMI upgrade must retain the launch template state." - } - - assert { - condition = output.role_runner[0].id == run.apply_pre_provider_split_managed_ami.moved_resource_ids.runner_role - error_message = "The module-managed-AMI upgrade must retain the runner role state." - } - - assert { - condition = output.runners_log_groups[*].id == run.apply_pre_provider_split_managed_ami.moved_resource_ids.runner_log_groups - error_message = "The module-managed-AMI upgrade must retain the optional runner log-group state." - } -} diff --git a/modules/runners/tests-upgrade/verify-moves.jq b/modules/runners/tests-upgrade/verify-moves.jq deleted file mode 100644 index 2f6c32535e..0000000000 --- a/modules/runners/tests-upgrade/verify-moves.jq +++ /dev/null @@ -1,61 +0,0 @@ -def expected_previous_addresses: - [ - "aws_ssm_parameter.runner_ami_id[0]", - "aws_launch_template.runner", - "aws_security_group.runner_sg[0]", - "aws_ssm_parameter.runner_config_run_as", - "aws_ssm_parameter.runner_enable_cloudwatch", - "aws_ssm_parameter.cloudwatch_agent_config_runner[0]", - "aws_cloudwatch_log_group.gh_runners[0]", - "aws_cloudwatch_log_group.gh_runners[1]", - "aws_cloudwatch_log_group.gh_runners[2]", - "aws_cloudwatch_log_group.gh_runners[3]", - "aws_iam_role_policy.cloudwatch[0]", - "aws_iam_role.runner[0]", - "aws_iam_instance_profile.runner[0]", - "aws_iam_role_policy.runner_session_manager_aws_managed[0]", - "aws_iam_role_policy.ssm_parameters[0]", - "aws_iam_role_policy.dist_bucket[0]", - "aws_iam_role_policy_attachment.xray_tracing[0]", - "aws_iam_role_policy.describe_tags[0]", - "aws_iam_role_policy.create_tag[0]", - "aws_iam_role_policy_attachment.managed_policies[0]", - "aws_iam_role_policy.ec2[0]", - "aws_iam_policy.ami_id_ssm_parameter_read[0]" - ]; - -[ - .[] - | select(.type == "test_plan") - | select(."@testrun" | startswith("plan_provider_split_")) -] as $plans -| if ($plans | length) != 2 then - error("expected two provider-split upgrade plans, found \($plans | length)") - else - [ - $plans[] - | .test_plan.resource_changes[]? - | select(.previous_address != null) - ] as $moved - | ($moved | map(.previous_address) | unique) as $actual_previous_addresses - | (expected_previous_addresses - $actual_previous_addresses) as $missing - | [ - $moved[] - | select( - (.change.actions | index("create")) != null - or (.change.actions | index("delete")) != null - ) - | { - address, - previous_address, - actions: .change.actions - } - ] as $destructive - | if ($missing | length) != 0 then - error("upgrade plans did not exercise moved state for: \($missing | join(", "))") - elif ($destructive | length) != 0 then - error("moved resources contain create/delete actions: \($destructive | tojson)") - else - true - end - end diff --git a/modules/runners/tests/pool.tftest.hcl b/modules/runners/tests/pool.tftest.hcl index 2dd548ea33..2c557f9392 100644 --- a/modules/runners/tests/pool.tftest.hcl +++ b/modules/runners/tests/pool.tftest.hcl @@ -4,18 +4,6 @@ mock_provider "aws" { json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" } } - - mock_resource "aws_iam_role" { - defaults = { - arn = "arn:aws:iam::123456789012:role/runner-test" - } - } - - mock_resource "aws_ssm_parameter" { - defaults = { - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id" - } - } } variables { @@ -25,13 +13,6 @@ variables { instance_types = ["m5.large"] - ami = { - filter = { state = ["available"] } - owners = ["amazon"] - id_ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id" - kms_key_arn = null - } - s3_runner_binaries = { arn = "arn:aws:s3:::my-bucket" id = "my-bucket" @@ -48,7 +29,7 @@ variables { runner_labels = ["self-hosted", "linux", "x64"] # Use S3 bucket to avoid filebase64sha256 needing local zip files - lambda_s3_bucket = "my-lambda-bucket" + lambda_s3_bucket = "my-lambda-bucket" runners_lambda_s3_key = "runners.zip" github_app_parameters = { @@ -76,56 +57,4 @@ run "plan_with_pool_enabled" { condition = length(module.pool) == 1 error_message = "Pool module should be enabled when pool_config is non-empty" } - - assert { - condition = module.ec2[0].provider.type == "ec2" - error_message = "The runners facade must resolve the EC2 compute provider." - } - - assert { - condition = aws_lambda_function.scale_up.environment[0].variables["RUNNER_PROVIDER_TYPE"] == "ec2" - error_message = "Scale-up must receive the provider type from the selected provider." - } - - assert { - condition = aws_lambda_function.scale_up.environment[0].variables["INSTANCE_TYPES"] == "m5.large" - error_message = "Scale-up must merge the EC2 environment fragment." - } - - assert { - condition = aws_lambda_function.scale_down.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" - error_message = "Scale-down must merge the EC2 environment fragment." - } - - assert { - condition = length(aws_iam_role_policy_attachment.ami_id_ssm_parameter_read) == 1 - error_message = "An external AMI SSM parameter must plan the scale-up policy attachment even when its policy ARN is not known yet." - } - -} - -run "rejects_unimplemented_compute_provider" { - command = plan - - variables { - provider_type = "microvm" - } - - expect_failures = [var.provider_type] -} - -run "job_retry_uses_common_lane_identity" { - command = plan - - variables { - runner_name_prefix = "provider-neutral-" - job_retry = { - enable = true - } - } - - assert { - condition = module.job_retry[0].lambda.function.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "provider-neutral-" - error_message = "Job retry must receive the common lane runner-name prefix." - } } diff --git a/modules/runners/variables.tf b/modules/runners/variables.tf index 0822782967..08283ce65c 100644 --- a/modules/runners/variables.tf +++ b/modules/runners/variables.tf @@ -1,14 +1,3 @@ -variable "provider_type" { - description = "Compute provider used by the common runner control plane." - type = string - default = "ec2" - - validation { - condition = contains(["ec2"], lower(trimspace(var.provider_type))) - error_message = "Supported runner providers: ec2." - } -} - variable "ami" { description = < Date: Thu, 6 Aug 2026 23:52:47 +0000 Subject: [PATCH 06/27] docs: auto update terraform docs --- modules/compute-providers/ec2/README.md | 12 ++++++------ modules/runner-stack/README.md | 14 +++++++------- modules/runner-stack/job-retry/README.md | 12 ++++++------ modules/runner-stack/pool/README.md | 10 +++++----- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index db07fd612f..e25422339c 100644 --- a/modules/compute-providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -10,15 +10,15 @@ EC2 is the only active compute provider. A future provider must implement the sa ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -27,7 +27,7 @@ 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 | @@ -53,7 +53,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | @@ -120,7 +120,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [control\_plane](#output\_control\_plane) | Provider-neutral control-plane contract containing the EC2 environment and IAM fragments. | | [resources](#output\_resources) | EC2-specific resources and bootstrap logging details. | diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index 89d409e8d6..3b79ee7c72 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -53,20 +53,20 @@ yarn run dist ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | @@ -74,7 +74,7 @@ yarn run dist ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | @@ -120,7 +120,7 @@ yarn run dist ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | @@ -232,7 +232,7 @@ yarn run dist ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [lambda\_pool](#output\_lambda\_pool) | n/a | | [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a | | [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a | diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md index f57cc1dbfd..0ba065e092 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/runner-stack/job-retry/README.md @@ -11,26 +11,26 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [job\_retry](#module\_job\_retry) | ../../lambda | n/a | ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_iam_role_policy.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_lambda_event_source_mapping.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | | [aws_lambda_permission.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | @@ -41,13 +41,13 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runner_name_prefix`: Prefix used to identify runners belonging to this lane.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | n/a | | [lambda](#output\_lambda) | n/a | diff --git a/modules/runner-stack/pool/README.md b/modules/runner-stack/pool/README.md index 2f83465bf1..a6962c832b 100644 --- a/modules/runner-stack/pool/README.md +++ b/modules/runner-stack/pool/README.md @@ -9,14 +9,14 @@ The pool is an opt-in feature. To be able to use the count on a module level to ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | ## Modules @@ -26,7 +26,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_log_group.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | @@ -48,7 +48,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no | | [config](#input\_config) | Lookup details in parent module. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_arn = string
role_path = string
ssm_token_path = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
})
| n/a | yes | | [runner\_provider](#input\_runner\_provider) | Compute provider configuration for the pool Lambda. |
object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
| n/a | yes | @@ -57,7 +57,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [lambda](#output\_lambda) | n/a | | [lambda\_log\_group](#output\_lambda\_log\_group) | n/a | | [role\_pool](#output\_role\_pool) | n/a | From a126c615c6548abd4ea2906077c96503f2e7e19f Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 03:26:20 +0200 Subject: [PATCH 07/27] refactor(multi-runner): isolate compute provider contracts --- .github/workflows/terraform.yml | 2 + docs/index.md | 2 +- modules/compute-providers/ec2/README.md | 44 +- modules/compute-providers/ec2/contracts.tf | 70 --- .../compute-providers/ec2/control-plane.tf | 222 ++++++++++ .../compute-providers/ec2/instance-profile.tf | 9 + modules/compute-providers/ec2/logging.tf | 11 - modules/compute-providers/ec2/outputs.tf | 1 - .../compute-providers/ec2/policies-runner.tf | 85 ---- .../policies/instance-cloudwatch-policy.json | 25 -- .../policies/instance-create-tags-policy.json | 20 - .../instance-describe-tags-policy.json | 10 - .../ec2/policies/instance-ec2.json | 15 - .../policies/instance-role-trust-policy.json | 13 - .../ec2/policies/instance-s3-policy.json | 11 - .../instance-ssm-parameters-policy.json | 31 -- .../ec2/policies/instance-ssm-policy.json | 46 -- .../ec2/policies/lambda-pool.json | 43 -- .../ec2/policies/lambda-scale-down.json | 30 -- .../ec2/policies/lambda-scale-up.json | 64 --- .../service-linked-role-create-policy.json | 10 - .../ec2/{compute.tf => runner-instances.tf} | 20 +- .../ec2/runner-role/README.md | 56 +++ .../ec2/runner-role/outputs.tf | 14 + .../ec2/runner-role/policies.tf | 224 ++++++++++ .../ec2/runner-role/tests/contract.tftest.hcl | 97 ++++ .../ec2/runner-role/variables.tf | 43 ++ .../ec2/runner-role/versions.tf | 10 + .../ec2/tests/provider.tftest.hcl | 84 +++- modules/compute-providers/ec2/variables.tf | 71 +-- modules/multi-runner/README.md | 54 ++- modules/multi-runner/main.tf | 7 + modules/multi-runner/multi-runner-config.tf | 53 ++- modules/multi-runner/outputs.tf | 1 + modules/multi-runner/runners.tf | 136 +++--- .../tests/provider-routing.tftest.hcl | 171 ++++++- .../multi-runner/variables.experimental.tf | 53 ++- modules/runner-stack/README.md | 78 +--- modules/runner-stack/ec2.tf | 73 +++ .../job-retry/{main.tf => job-retry.tf} | 1 + modules/runner-stack/local.tf | 8 - modules/runner-stack/main.tf | 91 ---- modules/runner-stack/outputs.tf | 5 + .../runner-stack/pool/{main.tf => pool.tf} | 1 + modules/runner-stack/runner-role.tf | 69 +++ modules/runner-stack/stack-config.tf | 28 ++ .../tests/computed-iam-inputs.tftest.hcl | 25 ++ .../computed-iam-inputs.tf | 123 +++++ .../fixtures/computed-iam-inputs/versions.tf | 13 + modules/runner-stack/tests/pool.tftest.hcl | 196 +++++++- .../variables-compute-provider.tf | 211 +++++++++ modules/runner-stack/variables.tf | 419 +----------------- 52 files changed, 1903 insertions(+), 1296 deletions(-) delete mode 100644 modules/compute-providers/ec2/contracts.tf create mode 100644 modules/compute-providers/ec2/control-plane.tf create mode 100644 modules/compute-providers/ec2/instance-profile.tf delete mode 100644 modules/compute-providers/ec2/policies-runner.tf delete mode 100644 modules/compute-providers/ec2/policies/instance-cloudwatch-policy.json delete mode 100644 modules/compute-providers/ec2/policies/instance-create-tags-policy.json delete mode 100644 modules/compute-providers/ec2/policies/instance-describe-tags-policy.json delete mode 100644 modules/compute-providers/ec2/policies/instance-ec2.json delete mode 100644 modules/compute-providers/ec2/policies/instance-role-trust-policy.json delete mode 100644 modules/compute-providers/ec2/policies/instance-s3-policy.json delete mode 100644 modules/compute-providers/ec2/policies/instance-ssm-parameters-policy.json delete mode 100644 modules/compute-providers/ec2/policies/instance-ssm-policy.json delete mode 100644 modules/compute-providers/ec2/policies/lambda-pool.json delete mode 100644 modules/compute-providers/ec2/policies/lambda-scale-down.json delete mode 100644 modules/compute-providers/ec2/policies/lambda-scale-up.json delete mode 100644 modules/compute-providers/ec2/policies/service-linked-role-create-policy.json rename modules/compute-providers/ec2/{compute.tf => runner-instances.tf} (93%) create mode 100644 modules/compute-providers/ec2/runner-role/README.md create mode 100644 modules/compute-providers/ec2/runner-role/outputs.tf create mode 100644 modules/compute-providers/ec2/runner-role/policies.tf create mode 100644 modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl create mode 100644 modules/compute-providers/ec2/runner-role/variables.tf create mode 100644 modules/compute-providers/ec2/runner-role/versions.tf create mode 100644 modules/runner-stack/ec2.tf rename modules/runner-stack/job-retry/{main.tf => job-retry.tf} (98%) delete mode 100644 modules/runner-stack/local.tf delete mode 100644 modules/runner-stack/main.tf rename modules/runner-stack/pool/{main.tf => pool.tf} (99%) create mode 100644 modules/runner-stack/runner-role.tf create mode 100644 modules/runner-stack/stack-config.tf create mode 100644 modules/runner-stack/tests/computed-iam-inputs.tftest.hcl create mode 100644 modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf create mode 100644 modules/runner-stack/tests/fixtures/computed-iam-inputs/versions.tf create mode 100644 modules/runner-stack/variables-compute-provider.tf diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index dbf2885633..0d497e8a97 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -86,6 +86,7 @@ jobs: "lambda", "multi-runner", "compute-providers/ec2", + "compute-providers/ec2/runner-role", "runner-binaries-syncer", "runner-stack", "runners", @@ -220,6 +221,7 @@ jobs: - modules/runner-stack - modules/runner-stack/pool - modules/compute-providers/ec2 + - modules/compute-providers/ec2/runner-role defaults: run: working-directory: ${{ matrix.module }} diff --git a/docs/index.md b/docs/index.md index 5b6da2fc9e..d1d0772985 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,7 +101,7 @@ Besides these permissions, the lambdas also need permission to CloudWatch (for l ## Terraform main modules -Currently we support two main modules. The existing `runners` module remains the stable EC2 implementation, and the `multi-runner` module creates multiple runner lanes in one deployment. Stable `multi_runner_config` lanes continue to use the unchanged `runners` module. Experimental `multi_runner_config_v2` lanes use the new provider-oriented `runner-stack`, where the common control plane owns scale-up, scale-down, pool, retry, housekeeper, and Lambda roles while `compute-providers/ec2` owns EC2 resources and runner-instance permissions. EC2 is the only active Terraform-managed provider; microVM, CodeBuild, and other provider modules are future work. +Currently we support two main modules. The existing `runners` module remains the stable EC2 implementation, and the `multi-runner` module creates multiple runner lanes in one deployment. Stable `multi_runner_config` lanes continue to use the unchanged `runners` module. Experimental `multi_runner_config_v2` lanes use the new provider-oriented `runner-stack`, where the common control plane owns scale-up, scale-down, pool, retry, housekeeper, Lambda roles, and the runner role and attachments. The EC2 provider supplies EC2-specific policy requirements and owns the instance profile, launch template, bootstrap resources, and runner log groups. Phase 1 supports non-overlapping v1 and v2 lanes together without moving legacy state; later releases will translate v1, ship state migration, and only then remove the v1 interface. See the [multi-runner v2 migration roadmap](modules/public/multi-runner.md#multi-runner-v2-migration-roadmap). EC2 is the only active Terraform-managed provider; microVM, CodeBuild, and other provider modules are future work. Both modules are built on top of the same base modules. When using the multi-runner module you can deploy different runners with only one deployment. diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index e25422339c..f52ad02dfb 100644 --- a/modules/compute-providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -1,24 +1,24 @@ # EC2 runner provider -This internal module owns the EC2 compute implementation used by the common `modules/runners` control plane. It creates the runner launch template, security group, instance role and profile, EC2 bootstrap parameters, and runner log groups. +This internal module owns the EC2 compute implementation used by the common runner stack. It creates the runner launch template, security group, instance profile, EC2 bootstrap parameters, and runner log groups. -The module returns two deliberately separate outputs. `control_plane` is the common provider contract and contains only the EC2-specific environment variables and IAM policy fragments required by scale-up, scale-down, and pool. `resources` contains the EC2 launch template, runner role, runner log groups, and log-file configuration. The parent module owns the Lambda functions, execution roles, schedules, queues, retry flow, and SSM housekeeper. +The module returns two deliberately separate outputs. `control_plane` contains the EC2-specific environment variables and IAM policy fragments required by scale-up, scale-down, and pool. `resources` contains only the EC2 launch template, runner log groups, and log-file configuration. The sibling `runner-role` module supplies the EC2 trust and runner-policy contract before the parent creates or selects the shared role. The parent module owns the shared runner role, policy attachments, Lambda functions, execution roles, schedules, queues, retry flow, and SSM housekeeper. -EC2 is the only active compute provider. A future provider must implement the same contract before it can be selected by `provider_type`. +EC2 is the only active compute provider. A future provider must implement the same contracts before it can be selected by `compute_provider.type`. ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | ## Modules @@ -27,20 +27,10 @@ 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_iam_role.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | -| [aws_iam_role_policy.cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.create_tag](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.describe_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.dist_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.ec2](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.runner_session_manager_aws_managed](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.ssm_parameters](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy_attachment.managed_policies](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_iam_role_policy_attachment.xray_tracing](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | 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 | @@ -49,11 +39,16 @@ No modules. | [aws_ssm_parameter.runner_enable_cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | 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.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 | ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | @@ -70,12 +65,11 @@ No modules. | [enable\_on\_demand\_failover\_for\_errors](#input\_enable\_on\_demand\_failover\_for\_errors) | Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later. | `list(string)` | `[]` | no | | [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI. | `bool` | `true` | no | | [enable\_runner\_detailed\_monitoring](#input\_enable\_runner\_detailed\_monitoring) | Enable detailed monitoring for runners | `bool` | `false` | no | -| [enable\_ssm\_on\_runners](#input\_enable\_ssm\_on\_runners) | Enable to allow access to the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances. | `bool` | n/a | yes | | [enable\_user\_data\_debug\_logging](#input\_enable\_user\_data\_debug\_logging) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no | | [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI | `bool` | `true` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | -| [iam\_overrides](#input\_iam\_overrides) | This map provides the possibility to override some IAM defaults. The following attributes are supported: `instance_profile_name` overrides the instance profile name used in the launch template. `runner_role_arn` overrides the IAM role ARN used for the runner instances. |
object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
})
|
{
"instance_profile_name": null,
"override_instance_profile": false,
"override_runner_role": false,
"runner_role_arn": null
}
| no | +| [iam\_overrides](#input\_iam\_overrides) | Overrides for the EC2 instance profile used by the launch template. |
object({
override_instance_profile = optional(bool, false)
instance_profile_name = optional(string, null)
})
|
{
"instance_profile_name": null,
"override_instance_profile": false
}
| no | | [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | | [instance\_max\_spot\_price](#input\_instance\_max\_spot\_price) | Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet. | `string` | `null` | no | | [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the prefix will be used. | `string` | `null` | no | @@ -86,12 +80,11 @@ No modules. | [license\_specifications](#input\_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 | | [logging\_kms\_key\_id](#input\_logging\_kms\_key\_id) | Specifies the kms key id to encrypt the logs with | `string` | `null` | no | | [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 | -| [metadata\_options](#input\_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 | -| [overrides](#input\_overrides) | This map provides the possibility to override some defaults. The following attributes are supported: `name_sg` overrides the `Name` tag for all security groups created by this module. `name_runner_agent_instance` overrides the `Name` tag for the ec2 instance defined in the auto launch configuration. `name_docker_machine_runners` overrides the `Name` tag spot instances created by the runner agent. | `map(string)` |
{
"name_runner": "",
"name_sg": ""
}
| no | +| [metadata\_options](#input\_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. |
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)
})
| `{}` | no | +| [overrides](#input\_overrides) | This map provides the possibility to override some defaults. The following attributes are supported: `name_sg` overrides the `Name` tag for all security groups created by this module. `name_runner_agent_instance` overrides the `Name` tag for the ec2 instance defined in the auto launch configuration. `name_docker_machine_runners` overrides the `Name` tag spot instances created by the runner agent. |
object({
name_runner = optional(string, "")
name_sg = optional(string, "")
})
| `{}` | no | | [placement](#input\_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 | | [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the prefix will be used. | `string` | `null` | no | -| [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | | [runner\_additional\_security\_group\_ids](#input\_runner\_additional\_security\_group\_ids) | (optional) List of additional security groups IDs to apply to the runner | `list(string)` | `[]` | no | | [runner\_architecture](#input\_runner\_architecture) | The platform architecture of the runner instance\_type. | `string` | `"x64"` | no | | [runner\_as\_root](#input\_runner\_as\_root) | Run the action runner under the root user. Variable `runner_run_as` will be ignored. | `bool` | `false` | no | @@ -99,17 +92,16 @@ No modules. | [runner\_ec2\_tags](#input\_runner\_ec2\_tags) | Map of tags that will be added to the launch template instance tag specifications. | `map(string)` | `{}` | no | | [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\_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\_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, osx, windows). | `string` | `"linux"` | no | +| [runner\_role](#input\_runner\_role) | Runner IAM role created or selected by the common runner stack. |
object({
arn = string
name = string
})
| n/a | yes | | [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | | [s3\_runner\_binaries](#input\_s3\_runner\_binaries) | Bucket details for cached GitHub binary. |
object({
arn = string
id = string
key = string
})
| n/a | yes | | [scale\_errors](#input\_scale\_errors) | List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors | `list(string)` |
[
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]
| no | | [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
tokens = string
config = string
})
| n/a | yes | | [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes | | [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name. | `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) | Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly. | `bool` | `false` | 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) | User-data script snippet to insert after GitHub action runner install | `string` | `""` | no | @@ -120,7 +112,7 @@ No modules. ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [control\_plane](#output\_control\_plane) | Provider-neutral control-plane contract containing the EC2 environment and IAM fragments. | | [resources](#output\_resources) | EC2-specific resources and bootstrap logging details. | diff --git a/modules/compute-providers/ec2/contracts.tf b/modules/compute-providers/ec2/contracts.tf deleted file mode 100644 index 5779e721a4..0000000000 --- a/modules/compute-providers/ec2/contracts.tf +++ /dev/null @@ -1,70 +0,0 @@ -resource "aws_iam_policy" "ami_id_ssm_parameter_read" { - count = local.ami_id_ssm_parameter_name != null ? 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.tags - policy = <<-JSON - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameter" - ], - "Resource": [ - "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${trimprefix(local.ami_id_ssm_parameter_name, "/")}" - ] - } - ] - } - JSON -} - -locals { - scale_up_environment_variables = { - AMI_ID_SSM_PARAMETER_NAME = local.ami_id_ssm_parameter_name - INSTANCE_ALLOCATION_STRATEGY = var.instance_allocation_strategy - INSTANCE_MAX_SPOT_PRICE = var.instance_max_spot_price - INSTANCE_TARGET_CAPACITY_TYPE = var.instance_target_capacity_type - INSTANCE_TYPE_PRIORITIES = var.instance_type_priorities != null ? jsonencode(var.instance_type_priorities) : "" - INSTANCE_TYPES = join(",", var.instance_types) - LAUNCH_TEMPLATE_NAME = aws_launch_template.runner.name - SUBNET_IDS = join(",", var.subnet_ids) - ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.enable_on_demand_failover_for_errors) - SCALE_ERRORS = jsonencode(var.scale_errors) - USE_DEDICATED_HOST = var.use_dedicated_host - } - - scale_down_environment_variables = { - RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes - } - - pool_environment_variables = merge(local.scale_up_environment_variables, { - RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes - }) - - runner_role_arn = var.iam_overrides["override_runner_role"] ? var.iam_overrides["runner_role_arn"] : aws_iam_role.runner[0].arn - - scale_up_iam_policy_json = templatefile("${path.module}/policies/lambda-scale-up.json", { - arn_runner_instance_role = local.runner_role_arn - environment = var.prefix - ami_kms_key_arn = local.ami_kms_key_arn - ssm_ami_id_parameter_arn = local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn - }) - - scale_down_iam_policy_json = templatefile("${path.module}/policies/lambda-scale-down.json", { - environment = var.prefix - }) - - pool_iam_policy_json = templatefile("${path.module}/policies/lambda-pool.json", { - arn_runner_instance_role = local.runner_role_arn - ami_kms_key_arn = local.ami_kms_key_arn - ssm_ami_id_parameter_arn = local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn - }) - - service_linked_role_policy_json = var.create_service_linked_role_spot ? templatefile("${path.module}/policies/service-linked-role-create-policy.json", { - aws_partition = var.aws_partition - }) : null -} diff --git a/modules/compute-providers/ec2/control-plane.tf b/modules/compute-providers/ec2/control-plane.tf new file mode 100644 index 0000000000..9b2bdd4d09 --- /dev/null +++ b/modules/compute-providers/ec2/control-plane.tf @@ -0,0 +1,222 @@ +# EC2-specific IAM and environment fragments consumed by the common control +# plane in runner-stack. +data "aws_caller_identity" "current" {} + +data "aws_iam_policy_document" "ami_id_ssm_parameter_read" { + count = local.ami_id_ssm_parameter_name != null ? 1 : 0 + + statement { + effect = "Allow" + actions = ["ssm:GetParameter"] + resources = ["arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${trimprefix(local.ami_id_ssm_parameter_name, "/")}"] + } +} + +resource "aws_iam_policy" "ami_id_ssm_parameter_read" { + count = local.ami_id_ssm_parameter_name != null ? 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.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/gh:environment" + values = [var.prefix] + } + } + + statement { + effect = "Allow" + actions = ["iam:PassRole"] + resources = [var.runner_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 : var.ami.id_ssm_parameter_arn] + } + + dynamic "statement" { + for_each = local.ami_kms_key_arn != "" ? [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_arn != "" ? [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/gh: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_role.arn] + } + + statement { + effect = "Allow" + actions = ["ssm:GetParameters"] + resources = [local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn] + } + + dynamic "statement" { + for_each = local.ami_kms_key_arn != "" ? [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_arn != "" ? [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.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.instance_allocation_strategy + INSTANCE_MAX_SPOT_PRICE = var.instance_max_spot_price + INSTANCE_TARGET_CAPACITY_TYPE = var.instance_target_capacity_type + INSTANCE_TYPE_PRIORITIES = var.instance_type_priorities != null ? jsonencode(var.instance_type_priorities) : "" + INSTANCE_TYPES = join(",", var.instance_types) + LAUNCH_TEMPLATE_NAME = aws_launch_template.runner.name + SUBNET_IDS = join(",", var.subnet_ids) + ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.enable_on_demand_failover_for_errors) + SCALE_ERRORS = jsonencode(var.scale_errors) + USE_DEDICATED_HOST = var.use_dedicated_host + } + + scale_down_environment_variables = { + RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes + } + + pool_environment_variables = merge(local.scale_up_environment_variables, { + RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes + }) + + 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.create_service_linked_role_spot ? data.aws_iam_policy_document.service_linked_role[0].json : null +} diff --git a/modules/compute-providers/ec2/instance-profile.tf b/modules/compute-providers/ec2/instance-profile.tf new file mode 100644 index 0000000000..fcf270cf50 --- /dev/null +++ b/modules/compute-providers/ec2/instance-profile.tf @@ -0,0 +1,9 @@ +# The common runner stack owns the role; EC2 owns the profile consumed by its +# launch template. +resource "aws_iam_instance_profile" "runner" { + count = var.iam_overrides.override_instance_profile ? 0 : 1 + name = "${var.prefix}-runner-profile" + role = var.runner_role.name + path = local.instance_profile_path + tags = local.tags +} diff --git a/modules/compute-providers/ec2/logging.tf b/modules/compute-providers/ec2/logging.tf index e7ad772187..9cedfedbce 100644 --- a/modules/compute-providers/ec2/logging.tf +++ b/modules/compute-providers/ec2/logging.tf @@ -73,14 +73,3 @@ resource "aws_cloudwatch_log_group" "gh_runners" { log_group_class = local.loggroups_classes[count.index] tags = local.tags } - -resource "aws_iam_role_policy" "cloudwatch" { - count = var.iam_overrides["override_runner_role"] ? 0 : (var.enable_cloudwatch_agent ? 1 : 0) - name = "CloudWatchLogginAndMetrics" - role = aws_iam_role.runner[0].name - policy = templatefile("${path.module}/policies/instance-cloudwatch-policy.json", - { - ssm_parameter_arn = aws_ssm_parameter.cloudwatch_agent_config_runner[0].arn - } - ) -} diff --git a/modules/compute-providers/ec2/outputs.tf b/modules/compute-providers/ec2/outputs.tf index de051f8af5..22a9350b5f 100644 --- a/modules/compute-providers/ec2/outputs.tf +++ b/modules/compute-providers/ec2/outputs.tf @@ -26,7 +26,6 @@ output "resources" { description = "EC2-specific resources and bootstrap logging details." value = { launch_template = aws_launch_template.runner - role_runner = aws_iam_role.runner runners_log_groups = try(aws_cloudwatch_log_group.gh_runners, []) logfiles = local.logfiles } diff --git a/modules/compute-providers/ec2/policies-runner.tf b/modules/compute-providers/ec2/policies-runner.tf deleted file mode 100644 index 11b7012520..0000000000 --- a/modules/compute-providers/ec2/policies-runner.tf +++ /dev/null @@ -1,85 +0,0 @@ -# IAM used by EC2 runner instances. -data "aws_caller_identity" "current" {} - -resource "aws_iam_role" "runner" { - count = var.iam_overrides["override_runner_role"] ? 0 : 1 - name = "${substr("${var.prefix}-runner", 0, 54)}-${substr(md5("${var.prefix}-runner"), 0, 8)}" - assume_role_policy = templatefile("${path.module}/policies/instance-role-trust-policy.json", {}) - path = local.role_path - permissions_boundary = var.role_permissions_boundary - tags = local.tags -} - -resource "aws_iam_instance_profile" "runner" { - count = (var.iam_overrides["override_instance_profile"] || var.iam_overrides["override_runner_role"]) ? 0 : 1 - name = "${var.prefix}-runner-profile" - role = aws_iam_role.runner[0].name - path = local.instance_profile_path - tags = local.tags -} - -resource "aws_iam_role_policy" "runner_session_manager_aws_managed" { - count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : (var.enable_ssm_on_runners ? 1 : 0) - name = "runner-ssm-session" - role = aws_iam_role.runner[0].name - policy = templatefile("${path.module}/policies/instance-ssm-policy.json", {}) -} - -resource "aws_iam_role_policy" "ssm_parameters" { - count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : 1 - name = "runner-ssm-parameters" - role = aws_iam_role.runner[0].name - policy = templatefile("${path.module}/policies/instance-ssm-parameters-policy.json", - { - arn_ssm_parameters_path_tokens = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.tokens}" - arn_ssm_parameters_path_config = local.arn_ssm_parameters_path_config - } - ) -} - -resource "aws_iam_role_policy" "dist_bucket" { - count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : (var.enable_runner_binaries_syncer ? 1 : 0) - - name = "distribution-bucket" - role = aws_iam_role.runner[0].name - policy = templatefile("${path.module}/policies/instance-s3-policy.json", - { - s3_arn = "${var.s3_runner_binaries.arn}/${var.s3_runner_binaries.key}" - } - ) -} - -resource "aws_iam_role_policy_attachment" "xray_tracing" { - count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : (var.tracing_config.mode != null ? 1 : 0) - role = aws_iam_role.runner[0].name - policy_arn = "arn:${var.aws_partition}:iam::aws:policy/AWSXRayDaemonWriteAccess" -} - -resource "aws_iam_role_policy" "describe_tags" { - count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : 1 - name = "runner-describe-tags" - role = aws_iam_role.runner[0].name - policy = file("${path.module}/policies/instance-describe-tags-policy.json") -} - -resource "aws_iam_role_policy" "create_tag" { - count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : 1 - name = "runner-create-tags" - role = aws_iam_role.runner[0].name - policy = templatefile("${path.module}/policies/instance-create-tags-policy.json", {}) -} - -resource "aws_iam_role_policy_attachment" "managed_policies" { - count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : length(var.runner_iam_role_managed_policy_arns) - role = aws_iam_role.runner[0].name - policy_arn = element(var.runner_iam_role_managed_policy_arns, count.index) -} - -resource "aws_iam_role_policy" "ec2" { - count = (var.iam_overrides["override_runner_role"] || var.iam_overrides["override_instance_profile"]) ? 0 : 1 - name = "ec2" - role = aws_iam_role.runner[0].name - policy = templatefile("${path.module}/policies/instance-ec2.json", {}) -} - -# see also logging.tf for logging and metrics policies diff --git a/modules/compute-providers/ec2/policies/instance-cloudwatch-policy.json b/modules/compute-providers/ec2/policies/instance-cloudwatch-policy.json deleted file mode 100644 index afd3993c00..0000000000 --- a/modules/compute-providers/ec2/policies/instance-cloudwatch-policy.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "cloudwatch:PutMetricData", - "ec2:DescribeVolumes", - "ec2:DescribeTags", - "logs:PutLogEvents", - "logs:DescribeLogStreams", - "logs:DescribeLogGroups", - "logs:CreateLogStream" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameter" - ], - "Resource": "${ssm_parameter_arn}/*" - } - ] -} diff --git a/modules/compute-providers/ec2/policies/instance-create-tags-policy.json b/modules/compute-providers/ec2/policies/instance-create-tags-policy.json deleted file mode 100644 index 9da09fcb70..0000000000 --- a/modules/compute-providers/ec2/policies/instance-create-tags-policy.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Action": "ec2:CreateTags", - "Condition": { - "ForAllValues:StringEquals": { - "aws:TagKeys": [ - "ghr:github_runner_id" - ] - }, - "StringEquals": { - "aws:ARN": "$${ec2:SourceInstanceARN}" - } - }, - "Effect": "Allow", - "Resource": "arn:*:ec2:*:*:instance/*" - } - ] -} diff --git a/modules/compute-providers/ec2/policies/instance-describe-tags-policy.json b/modules/compute-providers/ec2/policies/instance-describe-tags-policy.json deleted file mode 100644 index 474cd65023..0000000000 --- a/modules/compute-providers/ec2/policies/instance-describe-tags-policy.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": "ec2:DescribeTags", - "Resource": "*" - } - ] -} diff --git a/modules/compute-providers/ec2/policies/instance-ec2.json b/modules/compute-providers/ec2/policies/instance-ec2.json deleted file mode 100644 index 4a5bc578f5..0000000000 --- a/modules/compute-providers/ec2/policies/instance-ec2.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": "ec2:TerminateInstances", - "Resource": "*", - "Condition": { - "StringEquals": { - "aws:ARN": "$${ec2:SourceInstanceARN}" - } - } - } - ] -} diff --git a/modules/compute-providers/ec2/policies/instance-role-trust-policy.json b/modules/compute-providers/ec2/policies/instance-role-trust-policy.json deleted file mode 100644 index 45bf82b081..0000000000 --- a/modules/compute-providers/ec2/policies/instance-role-trust-policy.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "", - "Effect": "Allow", - "Principal": { - "Service": "ec2.amazonaws.com" - }, - "Action": "sts:AssumeRole" - } - ] -} diff --git a/modules/compute-providers/ec2/policies/instance-s3-policy.json b/modules/compute-providers/ec2/policies/instance-s3-policy.json deleted file mode 100644 index 65d8f84b22..0000000000 --- a/modules/compute-providers/ec2/policies/instance-s3-policy.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "githubActionDist", - "Effect": "Allow", - "Action": ["s3:GetObject", "s3:GetObjectAcl"], - "Resource": ["${s3_arn}"] - } - ] -} diff --git a/modules/compute-providers/ec2/policies/instance-ssm-parameters-policy.json b/modules/compute-providers/ec2/policies/instance-ssm-parameters-policy.json deleted file mode 100644 index fcd82304e1..0000000000 --- a/modules/compute-providers/ec2/policies/instance-ssm-parameters-policy.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "ssm:DeleteParameter", - "ssm:GetParameters", - "ssm:GetParameter" - ], - "Resource": "${arn_ssm_parameters_path_tokens}/*", - "Condition": { - "StringLike": { - "ec2:SourceInstanceARN": "*/$${aws:ResourceTag/InstanceId}" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameter", - "ssm:GetParameters", - "ssm:GetParametersByPath" - ], - "Resource": [ - "${arn_ssm_parameters_path_config}", - "${arn_ssm_parameters_path_config}/*" - ] - } - ] -} diff --git a/modules/compute-providers/ec2/policies/instance-ssm-policy.json b/modules/compute-providers/ec2/policies/instance-ssm-policy.json deleted file mode 100644 index 2587c0a2e8..0000000000 --- a/modules/compute-providers/ec2/policies/instance-ssm-policy.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "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" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "ssmmessages:CreateControlChannel", - "ssmmessages:CreateDataChannel", - "ssmmessages:OpenControlChannel", - "ssmmessages:OpenDataChannel" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "ec2messages:AcknowledgeMessage", - "ec2messages:DeleteMessage", - "ec2messages:FailMessage", - "ec2messages:GetEndpoint", - "ec2messages:GetMessages", - "ec2messages:SendReply" - ], - "Resource": "*" - } - ] -} diff --git a/modules/compute-providers/ec2/policies/lambda-pool.json b/modules/compute-providers/ec2/policies/lambda-pool.json deleted file mode 100644 index eb9f5202b7..0000000000 --- a/modules/compute-providers/ec2/policies/lambda-pool.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "ec2:DescribeInstances", - "ec2:DescribeTags", - "ec2:RunInstances", - "ec2:CreateFleet", - "ec2:CreateTags" - ], - "Resource": ["*"] - }, - { - "Effect": "Allow", - "Action": "iam:PassRole", - "Resource": "${arn_runner_instance_role}" - }, - { - "Effect": "Allow", - "Action": ["ssm:GetParameter", "ssm:GetParameters"], - "Resource": ["${ssm_ami_id_parameter_arn}"] - } -%{ if ami_kms_key_arn != "" ~} - ,{ - "Effect": "Allow", - "Action": ["kms:DescribeKey", "kms:ReEncrypt*", "kms:Decrypt"], - "Resource": "${ami_kms_key_arn}" - }, - { - "Effect": "Allow", - "Action": ["kms:CreateGrant"], - "Resource": "${ami_kms_key_arn}", - "Condition": { - "Bool": { - "aws:ViaAWSService": "true" - } - } - } -%{ endif ~} - ] -} diff --git a/modules/compute-providers/ec2/policies/lambda-scale-down.json b/modules/compute-providers/ec2/policies/lambda-scale-down.json deleted file mode 100644 index b92d0595b1..0000000000 --- a/modules/compute-providers/ec2/policies/lambda-scale-down.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["ec2:DescribeInstances", "ec2:DescribeTags"], - "Resource": ["*"] - }, - { - "Effect": "Allow", - "Action": ["ec2:TerminateInstances", "ec2:CreateTags", "ec2:DeleteTags"], - "Resource": ["*"], - "Condition": { - "StringEquals": { - "ec2:ResourceTag/ghr:Application": "github-action-runner" - } - } - }, - { - "Effect": "Allow", - "Action": ["ec2:TerminateInstances", "ec2:CreateTags", "ec2:DeleteTags"], - "Resource": ["*"], - "Condition": { - "StringEquals": { - "ec2:ResourceTag/gh:environment": "${environment}" - } - } - } - ] -} diff --git a/modules/compute-providers/ec2/policies/lambda-scale-up.json b/modules/compute-providers/ec2/policies/lambda-scale-up.json deleted file mode 100644 index 8e2c4ac624..0000000000 --- a/modules/compute-providers/ec2/policies/lambda-scale-up.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "ec2:DescribeInstances", - "ec2:DescribeLaunchTemplateVersions", - "ec2:DescribeTags", - "ec2:RunInstances", - "ec2:CreateFleet", - "ec2:CreateTags" - ], - "Resource": ["*"] - }, - { - "Effect": "Allow", - "Action": ["ec2:TerminateInstances"], - "Resource": ["*"], - "Condition": { - "StringEquals": { - "ec2:ResourceTag/ghr:Application": "github-action-runner" - } - } - }, - { - "Effect": "Allow", - "Action": ["ec2:TerminateInstances"], - "Resource": ["*"], - "Condition": { - "StringEquals": { - "ec2:ResourceTag/gh:environment": "${environment}" - } - } - }, - { - "Effect": "Allow", - "Action": "iam:PassRole", - "Resource": "${arn_runner_instance_role}" - }, - { - "Effect": "Allow", - "Action": ["ssm:GetParameter", "ssm:GetParameters"], - "Resource": ["${ssm_ami_id_parameter_arn}"] - } -%{ if ami_kms_key_arn != "" ~} - ,{ - "Effect": "Allow", - "Action": ["kms:DescribeKey", "kms:ReEncrypt*", "kms:Decrypt"], - "Resource": "${ami_kms_key_arn}" - }, - { - "Effect": "Allow", - "Action": ["kms:CreateGrant"], - "Resource": "${ami_kms_key_arn}", - "Condition": { - "Bool": { - "aws:ViaAWSService": "true" - } - } - } -%{ endif ~} - ] -} diff --git a/modules/compute-providers/ec2/policies/service-linked-role-create-policy.json b/modules/compute-providers/ec2/policies/service-linked-role-create-policy.json deleted file mode 100644 index 18a47d5104..0000000000 --- a/modules/compute-providers/ec2/policies/service-linked-role-create-policy.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": "iam:CreateServiceLinkedRole", - "Resource": "arn:${aws_partition}:iam::*:role/aws-service-role/*" - } - ] -} diff --git a/modules/compute-providers/ec2/compute.tf b/modules/compute-providers/ec2/runner-instances.tf similarity index 93% rename from modules/compute-providers/ec2/compute.tf rename to modules/compute-providers/ec2/runner-instances.tf index b7d53c0f71..a8e9386385 100644 --- a/modules/compute-providers/ec2/compute.tf +++ b/modules/compute-providers/ec2/runner-instances.tf @@ -1,4 +1,5 @@ -# EC2 compute implementation selected by the parent runners module. +# AMI selection, bootstrap rendering, launch template, and security group for +# EC2 runner instances. locals { tags = merge( { @@ -10,12 +11,12 @@ locals { var.tags, ) - name_sg = var.overrides["name_sg"] == "" ? local.tags["Name"] : var.overrides["name_sg"] - name_runner = var.overrides["name_runner"] == "" ? local.tags["Name"] : var.overrides["name_runner"] + name_sg = var.overrides.name_sg == "" ? local.tags["Name"] : var.overrides.name_sg + name_runner = var.overrides.name_runner == "" ? local.tags["Name"] : var.overrides.name_runner role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path instance_profile_path = var.instance_profile_path == null ? "/${var.prefix}/" : var.instance_profile_path userdata_template = var.userdata_template == null ? local.default_userdata_template[var.runner_os] : var.userdata_template - s3_location_runner_distribution = var.enable_runner_binaries_syncer ? "s3://${var.s3_runner_binaries.id}/${var.s3_runner_binaries.key}" : "" + s3_location_runner_distribution = var.enable_runner_binaries_syncer ? "s3://${try(var.s3_runner_binaries.id, "")}/${try(var.s3_runner_binaries.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"] } @@ -53,8 +54,6 @@ locals { # Extract parameter name from ARN (format: arn:aws:ssm:region:account:parameter/path/to/param) ami_id_ssm_parameter_name = local.ami_id_ssm_module_managed ? null : try(regex("parameter(/.+)$", local.ami_config.id_ssm_parameter_arn)[0], null) - arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" - user_data = var.enable_userdata ? (var.userdata_content == null ? templatefile(local.userdata_template, { enable_debug_logging = var.enable_user_data_debug_logging s3_location_runner_distribution = local.s3_location_runner_distribution @@ -125,6 +124,13 @@ resource "aws_ssm_parameter" "runner_ami_id" { resource "aws_launch_template" "runner" { name = "${var.prefix}-action-runner" + lifecycle { + precondition { + condition = !var.enable_runner_binaries_syncer || var.s3_runner_binaries != null + error_message = "s3_runner_binaries must be set when enable_runner_binaries_syncer is true." + } + } + dynamic "block_device_mappings" { for_each = var.block_device_mappings != null ? var.block_device_mappings : [] content { @@ -207,7 +213,7 @@ resource "aws_launch_template" "runner" { } iam_instance_profile { - name = var.iam_overrides["override_instance_profile"] ? var.iam_overrides["instance_profile_name"] : aws_iam_instance_profile.runner[0].name + name = var.iam_overrides.override_instance_profile ? var.iam_overrides.instance_profile_name : aws_iam_instance_profile.runner[0].name } instance_initiated_shutdown_behavior = "terminate" diff --git a/modules/compute-providers/ec2/runner-role/README.md b/modules/compute-providers/ec2/runner-role/README.md new file mode 100644 index 0000000000..ae2dbe82e4 --- /dev/null +++ b/modules/compute-providers/ec2/runner-role/README.md @@ -0,0 +1,56 @@ +# EC2 runner-role contract + +This internal module builds only the EC2-specific IAM documents required by a runner role. The common runner stack consumes these outputs before it creates or selects the shared role, then passes that role to the EC2 compute module. Keeping this module independent from the runner role and EC2 resources prevents a Terraform dependency cycle. + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.assume_role](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.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 build IAM and SSM ARNs. | `string` | `"aws"` | no | +| [aws\_region](#input\_aws\_region) | AWS region containing the runner configuration parameters. | `string` | n/a | yes | +| [enable\_cloudwatch\_agent](#input\_enable\_cloudwatch\_agent) | Include the CloudWatch agent policy in the runner role contract. | `bool` | n/a | yes | +| [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Include access to the runner distribution object in the runner role contract. | `bool` | n/a | yes | +| [enable\_ssm\_on\_runners](#input\_enable\_ssm\_on\_runners) | Include Session Manager permissions in the runner role contract. | `bool` | n/a | yes | +| [s3\_runner\_binaries](#input\_s3\_runner\_binaries) | S3 object containing the cached runner distribution; required when runner binary sync is enabled. |
object({
arn = string
key = string
})
| `null` | no | +| [ssm\_paths](#input\_ssm\_paths) | SSM paths used for runner tokens and configuration. |
object({
root = string
tokens = string
config = string
})
| n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [assume\_role\_policy\_json](#output\_assume\_role\_policy\_json) | EC2 runner-role trust policy document. | +| [inline\_policies](#output\_inline\_policies) | EC2 runner-role inline policies keyed by stable provider policy identifiers. | +| [managed\_policy\_arns](#output\_managed\_policy\_arns) | EC2 provider-managed runner-role policy ARNs keyed by stable identifiers. | + diff --git a/modules/compute-providers/ec2/runner-role/outputs.tf b/modules/compute-providers/ec2/runner-role/outputs.tf new file mode 100644 index 0000000000..b516663286 --- /dev/null +++ b/modules/compute-providers/ec2/runner-role/outputs.tf @@ -0,0 +1,14 @@ +output "assume_role_policy_json" { + description = "EC2 runner-role trust policy document." + value = data.aws_iam_policy_document.assume_role.json +} + +output "inline_policies" { + description = "EC2 runner-role inline policies keyed by stable provider policy identifiers." + value = local.inline_policies +} + +output "managed_policy_arns" { + description = "EC2 provider-managed runner-role policy ARNs keyed by stable identifiers." + value = {} +} diff --git a/modules/compute-providers/ec2/runner-role/policies.tf b/modules/compute-providers/ec2/runner-role/policies.tf new file mode 100644 index 0000000000..c61f99a7b9 --- /dev/null +++ b/modules/compute-providers/ec2/runner-role/policies.tf @@ -0,0 +1,224 @@ +# EC2 runner-role trust and permission documents returned to runner-stack. +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" "assume_role" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +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.enable_runner_binaries_syncer ? 1 : 0 + + lifecycle { + precondition { + condition = var.s3_runner_binaries != null + error_message = "s3_runner_binaries must be set when enable_runner_binaries_syncer is true." + } + } + + statement { + sid = "githubActionDist" + effect = "Allow" + actions = ["s3:GetObject", "s3:GetObjectAcl"] + resources = ["${try(var.s3_runner_binaries.arn, "")}/${try(var.s3_runner_binaries.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.enable_cloudwatch_agent ? 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 { + 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.enable_ssm_on_runners ? { + session_manager = { + name = "runner-ssm-session" + policy_json = data.aws_iam_policy_document.session_manager.json + } + } : {}, + var.enable_runner_binaries_syncer ? { + distribution_bucket = { + name = "distribution-bucket" + policy_json = data.aws_iam_policy_document.distribution_bucket[0].json + } + } : {}, + var.enable_cloudwatch_agent ? { + cloudwatch = { + name = "CloudWatchLogginAndMetrics" + policy_json = data.aws_iam_policy_document.cloudwatch[0].json + } + } : {}, + ) +} diff --git a/modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl b/modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl new file mode 100644 index 0000000000..b1f3f8c7af --- /dev/null +++ b/modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl @@ -0,0 +1,97 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } + + mock_data "aws_iam_policy_document" { + defaults = { + json = "{}" + } + } +} + +override_data { + target = data.aws_iam_policy_document.assume_role + values = { + json = "{\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}" + } +} + +variables { + aws_region = "eu-west-1" + enable_cloudwatch_agent = true + enable_runner_binaries_syncer = true + enable_ssm_on_runners = true + + s3_runner_binaries = { + arn = "arn:aws:s3:::runner-distribution" + key = "runner.zip" + } + + ssm_paths = { + root = "/github-runner/provider-test" + tokens = "tokens" + config = "config" + } +} + +run "exports_provider_owned_runner_role_contract" { + command = plan + + assert { + condition = strcontains(output.assume_role_policy_json, "ec2.amazonaws.com") + error_message = "The EC2 runner-role contract must expose its trust policy." + } + + assert { + condition = toset(keys(output.inline_policies)) == toset([ + "ssm_parameters", + "describe_tags", + "create_tags", + "terminate_self", + "session_manager", + "distribution_bucket", + "cloudwatch", + ]) + error_message = "The EC2 runner-role contract must expose stable keys for all enabled policies." + } + + assert { + condition = output.inline_policies.create_tags.name == "runner-create-tags" + error_message = "Policy names must be separate from stable contract keys." + } + + assert { + condition = length(output.managed_policy_arns) == 0 + error_message = "The EC2 role contract must not include common or user-managed policy attachments." + } +} + +run "omits_disabled_optional_policies" { + command = plan + + variables { + enable_cloudwatch_agent = false + enable_runner_binaries_syncer = false + enable_ssm_on_runners = false + s3_runner_binaries = null + } + + assert { + condition = toset(keys(output.inline_policies)) == toset(["ssm_parameters", "describe_tags", "create_tags", "terminate_self"]) + error_message = "Disabled optional EC2 policies must be omitted without changing the stable base keys." + } +} + +run "requires_distribution_object_when_sync_is_enabled" { + command = plan + + variables { + enable_runner_binaries_syncer = true + s3_runner_binaries = null + } + + expect_failures = [data.aws_iam_policy_document.distribution_bucket] +} diff --git a/modules/compute-providers/ec2/runner-role/variables.tf b/modules/compute-providers/ec2/runner-role/variables.tf new file mode 100644 index 0000000000..aced38623e --- /dev/null +++ b/modules/compute-providers/ec2/runner-role/variables.tf @@ -0,0 +1,43 @@ +variable "aws_partition" { + description = "AWS partition used to build IAM and SSM ARNs." + type = string + default = "aws" +} + +variable "aws_region" { + description = "AWS region containing the runner configuration parameters." + type = string +} + +variable "enable_cloudwatch_agent" { + description = "Include the CloudWatch agent policy in the runner role contract." + type = bool +} + +variable "enable_runner_binaries_syncer" { + description = "Include access to the runner distribution object in the runner role contract." + type = bool +} + +variable "enable_ssm_on_runners" { + description = "Include Session Manager permissions in the runner role contract." + type = bool +} + +variable "s3_runner_binaries" { + description = "S3 object containing the cached runner distribution; required when runner binary sync is enabled." + type = object({ + arn = string + key = string + }) + default = null +} + +variable "ssm_paths" { + description = "SSM paths used for runner tokens and configuration." + type = object({ + root = string + tokens = string + config = string + }) +} diff --git a/modules/compute-providers/ec2/runner-role/versions.tf b/modules/compute-providers/ec2/runner-role/versions.tf new file mode 100644 index 0000000000..da9769f550 --- /dev/null +++ b/modules/compute-providers/ec2/runner-role/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/compute-providers/ec2/tests/provider.tftest.hcl b/modules/compute-providers/ec2/tests/provider.tftest.hcl index 3d1af38aa5..ef43508908 100644 --- a/modules/compute-providers/ec2/tests/provider.tftest.hcl +++ b/modules/compute-providers/ec2/tests/provider.tftest.hcl @@ -1,4 +1,10 @@ mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{}" + } + } + mock_data "aws_ami" { defaults = { id = "ami-1234567890abcdef0" @@ -9,6 +15,20 @@ mock_provider "aws" { } } +override_data { + target = data.aws_iam_policy_document.scale_up + values = { + json = "{\"Action\":\"ec2:RunInstances\",\"PassRole\":\"arn:aws:iam::123456789012:role/provider-test-runner\"}" + } +} + +override_data { + target = data.aws_iam_policy_document.pool + values = { + json = "{\"Action\":\"iam:PassRole\"}" + } +} + variables { aws_region = "eu-west-1" vpc_id = "vpc-12345678" @@ -26,15 +46,17 @@ variables { s3_runner_binaries = null enable_runner_binaries_syncer = false - enable_ssm_on_runners = false enable_cloudwatch_agent = false enable_managed_runner_security_group = true iam_overrides = { - override_instance_profile = true - instance_profile_name = "provider-test-runner-profile" - override_runner_role = true - runner_role_arn = "arn:aws:iam::123456789012:role/provider-test-runner" + override_instance_profile = false + instance_profile_name = null + } + + runner_role = { + arn = "arn:aws:iam::123456789012:role/provider-test-runner" + name = "provider-test-runner" } ssm_paths = { @@ -77,6 +99,11 @@ run "separates_control_plane_contract_from_ec2_resources" { error_message = "The EC2 provider must expose pool permissions for its runner role." } + assert { + condition = strcontains(output.control_plane.scale_up.iam_policy_json, "arn:aws:iam::123456789012:role/provider-test-runner") + error_message = "The EC2 provider must use the common runner role ARN for PassRole." + } + assert { condition = output.control_plane.scale_up.managed_policy_enabled error_message = "An external AMI SSM parameter must enable the scale-up managed policy attachment at plan time." @@ -93,7 +120,7 @@ run "separates_control_plane_contract_from_ec2_resources" { } assert { - condition = toset(keys(output.resources)) == toset(["launch_template", "role_runner", "runners_log_groups", "logfiles"]) + condition = toset(keys(output.resources)) == toset(["launch_template", "runners_log_groups", "logfiles"]) error_message = "EC2-specific artifacts must be exposed only through the resources output." } @@ -101,4 +128,49 @@ run "separates_control_plane_contract_from_ec2_resources" { condition = !contains(keys(output.resources), "scale_up") error_message = "The EC2 resources output must not contain the common control-plane contract." } + + assert { + condition = aws_iam_instance_profile.runner[0].role == "provider-test-runner" + error_message = "The EC2 instance profile must use the common runner role name." + } + +} + +run "accepts_partial_typed_compute_options" { + command = plan + + variables { + overrides = { + name_runner = "custom-runner" + } + metadata_options = { + http_tokens = "optional" + } + } + + assert { + condition = local.name_runner == "custom-runner" && local.name_sg == "provider-test-action-runner" + error_message = "Partial name overrides must retain defaults for omitted attributes." + } + + assert { + condition = ( + aws_launch_template.runner.metadata_options[0].http_tokens == "optional" + && aws_launch_template.runner.metadata_options[0].http_endpoint == "enabled" + && aws_launch_template.runner.metadata_options[0].http_put_response_hop_limit == 1 + && aws_launch_template.runner.metadata_options[0].instance_metadata_tags == "enabled" + ) + error_message = "Partial metadata options must retain typed defaults for omitted attributes." + } +} + +run "requires_distribution_object_when_sync_is_enabled" { + command = plan + + variables { + enable_runner_binaries_syncer = true + s3_runner_binaries = null + } + + expect_failures = [aws_launch_template.runner] } diff --git a/modules/compute-providers/ec2/variables.tf b/modules/compute-providers/ec2/variables.tf index c39c8b8451..36189fd8a8 100644 --- a/modules/compute-providers/ec2/variables.tf +++ b/modules/compute-providers/ec2/variables.tf @@ -37,39 +37,38 @@ variable "subnet_ids" { variable "overrides" { description = "This map provides the possibility to override some defaults. The following attributes are supported: `name_sg` overrides the `Name` tag for all security groups created by this module. `name_runner_agent_instance` overrides the `Name` tag for the ec2 instance defined in the auto launch configuration. `name_docker_machine_runners` overrides the `Name` tag spot instances created by the runner agent." - type = map(string) + type = object({ + name_runner = optional(string, "") + name_sg = optional(string, "") + }) - default = { - name_runner = "" - name_sg = "" - } + default = {} } variable "iam_overrides" { - description = "This map provides the possibility to override some IAM defaults. The following attributes are supported: `instance_profile_name` overrides the instance profile name used in the launch template. `runner_role_arn` overrides the IAM role ARN used for the runner instances." + description = "Overrides for the EC2 instance profile used by the launch template." type = object({ - override_instance_profile = optional(bool, null) + override_instance_profile = optional(bool, false) instance_profile_name = optional(string, null) - override_runner_role = optional(bool, null) - runner_role_arn = optional(string, null) }) default = { override_instance_profile = false instance_profile_name = null - override_runner_role = false - runner_role_arn = null } validation { condition = !var.iam_overrides.override_instance_profile || var.iam_overrides.instance_profile_name != null error_message = "instance_profile_name must be provided when override_instance_profile is true." } +} - validation { - condition = !var.iam_overrides.override_runner_role || var.iam_overrides.runner_role_arn != null - error_message = "runner_role_arn must be provided when override_runner_role is true." - } +variable "runner_role" { + description = "Runner IAM role created or selected by the common runner stack." + type = object({ + arn = string + name = string + }) } variable "tags" { @@ -218,12 +217,6 @@ variable "runner_boot_time_in_minutes" { default = 5 } -variable "role_permissions_boundary" { - description = "Permissions boundary that will be added to the created role for the lambda." - type = string - default = null -} - variable "role_path" { description = "The path that will be added to the role; if not set, the prefix will be used." type = string @@ -266,11 +259,6 @@ variable "logging_kms_key_id" { default = null } -variable "enable_ssm_on_runners" { - description = "Enable to allow access to the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances." - type = bool -} - variable "create_service_linked_role_spot" { description = "(optional) create the service linked role for spot instances that is required by the scale-up lambda." type = bool @@ -283,12 +271,6 @@ variable "aws_partition" { default = "aws" } -variable "runner_iam_role_managed_policy_arns" { - description = "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role" - type = list(string) - default = [] -} - variable "enable_cloudwatch_agent" { description = "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`." type = bool @@ -383,13 +365,13 @@ variable "runner_ec2_tags" { variable "metadata_options" { description = "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." - type = map(any) - default = { - instance_metadata_tags = "enabled" - http_endpoint = "enabled" - http_tokens = "required" - http_put_response_hop_limit = 1 - } + type = 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) + }) + default = {} } variable "enable_runner_binaries_syncer" { @@ -423,17 +405,6 @@ variable "runner_name_prefix" { } } -variable "tracing_config" { - description = "Configuration for lambda tracing." - type = object({ - mode = optional(string, null) - capture_http_requests = optional(bool, false) - capture_error = optional(bool, false) - }) - default = {} -} - - variable "credit_specification" { description = "The credit option for CPU usage of a T instance. Can be unset, \"standard\" or \"unlimited\"." type = string diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 4c67cfd476..56aaa9de0f 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -8,15 +8,41 @@ The module takes a configuration as input containing a matcher for the labels. T ## Provider boundary -The multi-runner module owns provider-neutral lane normalization, queues, shared runner-binary discovery, and webhook routing. Stable `multi_runner_config` lanes continue to use the existing `modules/runners` module at their historical `module.runners["lane"]` addresses. The stable module and its state layout are not part of the experimental provider refactor. +The multi-runner module owns provider-neutral lane normalization, queues, shared runner-binary discovery, and webhook routing. Stable `multi_runner_config` lanes continue to use the existing `modules/runners` module at their historical `module.runners["lane"]` addresses. -Setting `multi_runner_config_v2` opts all selected lanes into `modules/runner-stack` at `module.runner_stacks["lane"]`. That stack owns the common scale-up, scale-down, pool, retry, and Lambda-role resources and dispatches provider-specific compute, IAM, bootstrap, and Lambda fragments using `provider.type`. The v1 and v2 runner module maps are mutually exclusive, while queue, webhook, and public output lane keys remain unchanged. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. +Lanes in `multi_runner_config_v2` opt into `modules/runner-stack` at `module.runner_stacks["lane"]`. That stack owns common scale-up, scale-down, pool, retry, Lambda roles, and the runner role and attachments. It dispatches typed provider configuration through `provider.type`; the EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. -Stable v1 entries in `runners_map` retain their existing flat output shape. Experimental v2 entries keep common control-plane Lambda and role outputs at the lane level, while compute-provider resources are available only under `provider`. For EC2 lanes, use `runners_map["lane"].provider.ec2.launch_template`, `.role_runner`, `.runners_log_groups`, and `.logfiles`; the corresponding flat v1 attributes are intentionally not duplicated in v2 entries. +In v2, common runner-role configuration belongs under `runner.iam`; EC2's optional external instance-profile selection belongs under `provider.ec2.instance_profile`. Provider policy documents are generated internally and attached by the common stack when it creates the role. An external role remains unmanaged and must already contain the required policies. -### Experimental v2 migration +The two input maps can be used in the same module instance during phase 1, provided their lane keys do not overlap. Shared queues, webhook routing, and runner-binary discovery use the union of both maps. A duplicate key is rejected so an existing lane cannot silently switch module address or output contract. -`multi_runner_config_v2` is experimental. Deployments that already used an earlier v2 implementation may have runner state at `module.runners["lane"]`; the provider-oriented stack uses `module.runner_stacks["lane"]` instead. Terraform cannot apply a generic automatic move because the original `module.runners` address remains reserved for stable v1 deployments. Review the plan and migrate experimental v2 state explicitly before upgrading, or allow those experimental runner resources to be replaced. Deployments that continue using stable `multi_runner_config` require no address migration. +Stable v1 entries in `runners_map` retain their existing flat output shape. Experimental v2 entries keep common control-plane Lambda and runner-role outputs at the lane level, while compute-provider resources are available only under `provider`. For EC2 lanes, use `runners_map["lane"].provider.ec2.launch_template`, `.runners_log_groups`, and `.logfiles`; use `runners_map["lane"].role_runner` for the common runner role. The corresponding flat EC2 attributes are intentionally not duplicated in v2 entries. + +### Multi-runner v2 migration roadmap + +Here, v1 and v2 refer to the `multi_runner_config` input contracts, not module release versions. The migration is intentionally split across releases so configuration migration, state migration, and interface removal do not happen at the same time. + +#### Phase 1 — Add v2 alongside v1 (current) + +Both input contracts are available in the same module release and can manage different lane keys in one module instance. Existing `multi_runner_config` lanes continue through the unchanged `modules/runners` implementation at `module.runners["lane"]`, retaining their input contract, flat `runners_map` output, and Terraform addresses. Experimental `multi_runner_config_v2` lanes use `module.runner_stacks["lane"]` and the provider-oriented output shape. + +Compatibility guarantee: upgrading without moving a lane from `multi_runner_config` to `multi_runner_config_v2` requires no state migration and must not move or replace legacy runner resources. Moving an existing lane key to v2 is deliberately deferred until phase 2 supplies the state mapping. + +#### Phase 2 — Translate v1 and migrate state + +`multi_runner_config` remains accepted but is deprecated and translated to the v2 contract before dispatching through `runner-stack`. This phase will include tested `moved` blocks wherever Terraform can express the mapping and exact state-migration instructions for remaining addresses. The legacy flat output shape remains available as a compatibility adapter while users update configuration and output references. + +Compatibility guarantee: users can migrate implementation state before rewriting their configuration. With equivalent inputs, the documented migration must produce a plan without unintended runner-resource destruction or replacement. + +#### Phase 3 — Remove v1 from multi-runner + +After the announced migration window, a breaking release removes `multi_runner_config`, its translation, and the legacy flat output adapter from the multi-runner module. Only the v2 provider-oriented contract remains. Phase 3 will not introduce another state-address migration. + +Compatibility guarantee: phase 3 will not be released together with phase 2. Users will have at least one released migration version in which v1 is still accepted before its removal. + +#### Future — Retire the legacy runners module + +Removing `modules/runners` is a separate future change. It requires its own compatibility analysis, migration instructions, and deprecation window for direct and top-level consumers; it is not part of this provider-boundary refactor. For each configuration: @@ -89,7 +115,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -97,14 +123,14 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | -| [random](#provider\_random) | ~> 3.0 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | +| [random](#provider\_random) | 3.9.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -116,7 +142,7 @@ module "multi-runner" { ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -127,7 +153,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | | [ami\_housekeeper\_lambda\_s3\_key](#input\_ami\_housekeeper\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | @@ -166,7 +192,7 @@ module "multi-runner" { | [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, osx, 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\_job\_queued\_check: Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
enable\_on\_demand\_failover\_for\_errors: "Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later."
scale\_errors: "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`."
instance\_type\_priorities: "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
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)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
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."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` disables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_reserved\_concurrent\_executions: "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
enable\_jit\_config: "Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
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."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`."
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."
pool\_config: "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
map(object({
runner_config = object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
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
})), [])
use_dedicated_host = optional(bool, false)
runner_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)
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
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}))
| `{}` | no | -| [multi\_runner\_config\_v2](#input\_multi\_runner\_config\_v2) | Experimental runner lane configuration keyed by lane name. This v2 shape separates common runner routing from provider-specific backend configuration. The schema can change while the provider model is being finalized. When set, this variable takes precedence over stable `multi_runner_config`.

Each lane has:
- `runner`: GitHub runner behavior shared by all providers.
- `provider`: backend discriminator plus typed provider configuration.
- `queue`: queue and event-source settings for the lane.
- `matcherConfig`: webhook routing labels and priority. |
map(object({
runner = object({
runner_os = string
runner_architecture = string
disable_runner_autoupdate = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_jit_config = optional(bool, null)
enable_organization_runners = optional(bool, false)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
})

provider = object({
type = string

ec2 = optional(object({
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), 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
}])
cloudwatch_config = optional(string, null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
enable_runner_binaries_syncer = optional(bool, true)
enable_runner_detailed_monitoring = optional(bool, false)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
runner_additional_security_group_ids = optional(list(string), [])
runner_iam_role_managed_policy_arns = optional(list(string), [])
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = 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
})), [])
use_dedicated_host = optional(bool, false)
runner_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)
runner_ec2_tags = optional(map(string), {})
runner_hook_job_completed = optional(string, "")
runner_hook_job_started = optional(string, "")
userdata_content = optional(string, null)
userdata_post_install = optional(string, "")
userdata_pre_install = optional(string, "")
userdata_template = optional(string, null)
}), null)

# Future provider references only. Do not uncomment until the Terraform
# resources for these lanes are implemented.
#
# microvm = optional(object({
# environment_variables = optional(map(string), {})
# }), null)
})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| `{}` | no | +| [multi\_runner\_config\_v2](#input\_multi\_runner\_config\_v2) | Experimental runner lane configuration keyed by lane name. This v2 shape separates common runner routing from provider-specific backend configuration. The schema can change while the provider model is being finalized. V1 and v2 maps can be used together when their lane keys do not overlap.

Each lane has:
- `runner`: GitHub runner behavior shared by all providers.
- `provider`: backend discriminator plus typed provider configuration.
- `queue`: queue and event-source settings for the lane.
- `matcherConfig`: webhook routing labels and priority. |
map(object({
runner = object({
runner_os = string
runner_architecture = string
disable_runner_autoupdate = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_jit_config = optional(bool, null)
enable_organization_runners = optional(bool, false)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
}), {})
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
})

provider = object({
type = string

ec2 = optional(object({
runner_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)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), 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
}])
cloudwatch_config = optional(string, null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
enable_runner_binaries_syncer = optional(bool, true)
enable_runner_detailed_monitoring = optional(bool, false)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
runner_additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = 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
})), [])
use_dedicated_host = optional(bool, false)
runner_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)
runner_ec2_tags = optional(map(string), {})
runner_hook_job_completed = optional(string, "")
runner_hook_job_started = optional(string, "")
userdata_content = optional(string, null)
userdata_post_install = optional(string, "")
userdata_pre_install = optional(string, "")
userdata_template = optional(string, null)
}), null)

# Future provider references only. Do not uncomment until the Terraform
# resources for these lanes are implemented.
#
# microvm = optional(object({
# environment_variables = optional(map(string), {})
# }), null)
})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| `{}` | no | | [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | | [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | @@ -211,7 +237,7 @@ module "multi-runner" { ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 7465631949..883e063109 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -16,4 +16,11 @@ resource "random_string" "random" { length = 24 special = false upper = false + + lifecycle { + precondition { + condition = length(local.duplicate_runner_config_keys) == 0 + error_message = "Lane keys must be unique across multi_runner_config and multi_runner_config_v2. Duplicate keys: ${join(", ", sort(tolist(local.duplicate_runner_config_keys)))}." + } + } } diff --git a/modules/multi-runner/multi-runner-config.tf b/modules/multi-runner/multi-runner-config.tf index e657999fbe..9e92ac70fc 100644 --- a/modules/multi-runner/multi-runner-config.tf +++ b/modules/multi-runner/multi-runner-config.tf @@ -22,8 +22,17 @@ locals { runners_maximum_count = v.runner_config.runners_maximum_count scale_down_schedule_expression = v.runner_config.scale_down_schedule_expression scale_up_reserved_concurrent_executions = v.runner_config.scale_up_reserved_concurrent_executions - pool_config = v.runner_config.pool_config - job_retry = v.runner_config.job_retry + iam = { + role = v.runner_config.iam_overrides.override_runner_role == true ? { + arn = v.runner_config.iam_overrides.runner_role_arn + } : null + managed_policy_arns = { + for policy_index, policy_arn in v.runner_config.runner_iam_role_managed_policy_arns : + "legacy-${policy_index}" => policy_arn + } + } + pool_config = v.runner_config.pool_config + job_retry = v.runner_config.job_retry } provider = { @@ -47,6 +56,9 @@ locals { instance_type_priorities = v.runner_config.instance_type_priorities instance_types = v.runner_config.instance_types runner_additional_security_group_ids = v.runner_config.runner_additional_security_group_ids + instance_profile = v.runner_config.iam_overrides.override_instance_profile == true ? { + name = v.runner_config.iam_overrides.instance_profile_name + } : null runner_iam_role_managed_policy_arns = v.runner_config.runner_iam_role_managed_policy_arns iam_overrides = v.runner_config.iam_overrides enable_on_demand_failover_for_errors = v.runner_config.enable_on_demand_failover_for_errors @@ -80,8 +92,15 @@ locals { } } - use_multi_runner_config_v2 = length(var.multi_runner_config_v2) > 0 - multi_runner_config = local.use_multi_runner_config_v2 ? var.multi_runner_config_v2 : local.multi_runner_config_v1_as_v2 + duplicate_runner_config_keys = setintersection( + toset(keys(var.multi_runner_config)), + toset(keys(var.multi_runner_config_v2)), + ) + + # Phase 1 keeps stable v1 lanes and experimental v2 lanes side by side. A + # lane key must belong to exactly one input so its module address and output + # contract remain unambiguous. + multi_runner_config = merge(local.multi_runner_config_v1_as_v2, var.multi_runner_config_v2) runner_extra_labels = { for k, v in local.multi_runner_config : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.runner_extra_labels))) @@ -97,16 +116,30 @@ locals { }) } - # Keep stable v1 lanes at their historical module.runners addresses. The - # experimental v2 input opts into the new provider-oriented runner stack. + # Keep stable v1 lanes at their historical module.runners addresses while + # v2 lanes opt into the provider-oriented runner stack. runner_config_v1 = { - for k, v in local.runner_config : k => v - if !local.use_multi_runner_config_v2 + for k, v in local.multi_runner_config_v1_as_v2 : k => merge(v, { + id = aws_sqs_queue.queued_builds[k].id + arn = aws_sqs_queue.queued_builds[k].arn + url = aws_sqs_queue.queued_builds[k].url + runnerProvider = lower(trimspace(v.provider.type)) + runner = merge(v.runner, { + runner_extra_labels = sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.runner_extra_labels))) + }) + }) } runner_config_v2 = { - for k, v in local.runner_config : k => v - if local.use_multi_runner_config_v2 + for k, v in var.multi_runner_config_v2 : k => merge(v, { + id = aws_sqs_queue.queued_builds[k].id + arn = aws_sqs_queue.queued_builds[k].arn + url = aws_sqs_queue.queued_builds[k].url + runnerProvider = lower(trimspace(v.provider.type)) + runner = merge(v.runner, { + runner_extra_labels = sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.runner_extra_labels))) + }) + }) } runner_matcher_config = { diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index a93a1ad34b..5e264d54f9 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -28,6 +28,7 @@ output "runners_map" { lambda_down_log_group = runner.lambda_scale_down_log_group lambda_pool = runner.lambda_pool lambda_pool_log_group = runner.lambda_pool_log_group + role_runner = runner.role_runner role_scale_up = runner.role_scale_up role_scale_down = runner.role_scale_down role_pool = runner.role_pool diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index bcae53c1e5..cfc9ed17e5 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -131,20 +131,67 @@ module "runners" { } module "runner_stacks" { - source = "../runner-stack" - for_each = local.runner_config_v2 - provider_type = each.value.runnerProvider + source = "../runner-stack" + for_each = local.runner_config_v2 aws_region = var.aws_region aws_partition = var.aws_partition - vpc_id = coalesce(each.value.provider.ec2.vpc_id, var.vpc_id) - subnet_ids = coalesce(each.value.provider.ec2.subnet_ids, var.subnet_ids) prefix = "${var.prefix}-${each.key}" tags = merge(local.tags, { "ghr:environment" = "${var.prefix}-${each.key}" }) - s3_runner_binaries = each.value.provider.ec2.enable_runner_binaries_syncer ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.runner_os}_${each.value.runner.runner_architecture}"] : null + compute_provider = { + type = each.value.runnerProvider + ec2 = { + ami = each.value.provider.ec2.ami + vpc_id = coalesce(each.value.provider.ec2.vpc_id, var.vpc_id) + subnet_ids = coalesce(each.value.provider.ec2.subnet_ids, var.subnet_ids) + instance_types = each.value.provider.ec2.instance_types + instance_target_capacity_type = each.value.provider.ec2.instance_target_capacity_type + instance_allocation_strategy = each.value.provider.ec2.instance_allocation_strategy + instance_type_priorities = each.value.provider.ec2.instance_type_priorities + instance_max_spot_price = each.value.provider.ec2.instance_max_spot_price + block_device_mappings = each.value.provider.ec2.block_device_mappings + ebs_optimized = each.value.provider.ec2.ebs_optimized + instance_profile = each.value.provider.ec2.instance_profile + instance_profile_path = var.instance_profile_path + enable_on_demand_failover_for_errors = each.value.provider.ec2.enable_on_demand_failover_for_errors + scale_errors = each.value.provider.ec2.scale_errors + enable_managed_runner_security_group = var.enable_managed_runner_security_group + enable_runner_detailed_monitoring = each.value.provider.ec2.enable_runner_detailed_monitoring + enable_ssm_on_runners = each.value.provider.ec2.enable_ssm_on_runners + egress_rules = var.runner_egress_rules + runner_additional_security_group_ids = try(coalescelist(each.value.provider.ec2.runner_additional_security_group_ids, var.runner_additional_security_group_ids), []) + metadata_options = each.value.provider.ec2.runner_metadata_options + credit_specification = each.value.provider.ec2.credit_specification + cpu_options = each.value.provider.ec2.cpu_options + placement = each.value.provider.ec2.placement + license_specifications = each.value.provider.ec2.license_specifications + use_dedicated_host = each.value.provider.ec2.use_dedicated_host + enable_runner_binaries_syncer = each.value.provider.ec2.enable_runner_binaries_syncer + s3_runner_binaries = each.value.provider.ec2.enable_runner_binaries_syncer ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.runner_os}_${each.value.runner.runner_architecture}"] : null + enable_cloudwatch_agent = each.value.provider.ec2.enable_cloudwatch_agent + cloudwatch_config = try(coalesce(each.value.provider.ec2.cloudwatch_config, var.cloudwatch_config), null) + runner_log_files = each.value.provider.ec2.runner_log_files + enable_userdata = each.value.provider.ec2.enable_userdata + userdata_template = each.value.provider.ec2.userdata_template + userdata_content = each.value.provider.ec2.userdata_content + userdata_pre_install = each.value.provider.ec2.userdata_pre_install + userdata_post_install = each.value.provider.ec2.userdata_post_install + runner_hook_job_started = each.value.provider.ec2.runner_hook_job_started + runner_hook_job_completed = each.value.provider.ec2.runner_hook_job_completed + key_name = var.key_name + runner_ec2_tags = each.value.provider.ec2.runner_ec2_tags + create_service_linked_role_spot = each.value.provider.ec2.create_service_linked_role_spot + associate_public_ipv4_address = var.associate_public_ipv4_address + } + } + + runner_iam = { + role = each.value.runner.iam.role + managed_policy_arns = each.value.runner.iam.managed_policy_arns + } ssm_paths = { root = "${local.ssm_root_path}/${each.key}" @@ -152,49 +199,26 @@ module "runner_stacks" { config = "${var.ssm_paths.runners}/config" } - runner_os = each.value.runner.runner_os - instance_types = each.value.provider.ec2.instance_types - instance_target_capacity_type = each.value.provider.ec2.instance_target_capacity_type - instance_allocation_strategy = each.value.provider.ec2.instance_allocation_strategy - instance_type_priorities = each.value.provider.ec2.instance_type_priorities - instance_max_spot_price = each.value.provider.ec2.instance_max_spot_price - block_device_mappings = each.value.provider.ec2.block_device_mappings - + runner_os = each.value.runner.runner_os runner_architecture = each.value.runner.runner_architecture - ami = each.value.provider.ec2.ami - sqs_build_queue = { "arn" : each.value.arn, "url" : each.value.url } - github_app_parameters = local.github_app_parameters - ebs_optimized = each.value.provider.ec2.ebs_optimized - enable_on_demand_failover_for_errors = each.value.provider.ec2.enable_on_demand_failover_for_errors - scale_errors = each.value.provider.ec2.scale_errors - enable_organization_runners = each.value.runner.enable_organization_runners - enable_ephemeral_runners = each.value.runner.enable_ephemeral_runners - enable_jit_config = each.value.runner.enable_jit_config - enable_job_queued_check = each.value.runner.enable_job_queued_check - disable_runner_autoupdate = each.value.runner.disable_runner_autoupdate - enable_managed_runner_security_group = var.enable_managed_runner_security_group - enable_runner_detailed_monitoring = each.value.provider.ec2.enable_runner_detailed_monitoring - scale_down_schedule_expression = each.value.runner.scale_down_schedule_expression - minimum_running_time_in_minutes = each.value.runner.minimum_running_time_in_minutes - runner_boot_time_in_minutes = each.value.runner.runner_boot_time_in_minutes - runner_disable_default_labels = each.value.runner.runner_disable_default_labels - runner_labels = each.value.runner.runner_disable_default_labels ? sort(distinct(each.value.runner.runner_extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.runner_os, each.value.runner.runner_architecture], each.value.runner.runner_extra_labels))) - runner_as_root = each.value.runner.runner_as_root - runner_run_as = each.value.runner.runner_run_as - runners_maximum_count = each.value.runner.runners_maximum_count - idle_config = each.value.runner.idle_config - enable_ssm_on_runners = each.value.provider.ec2.enable_ssm_on_runners - egress_rules = var.runner_egress_rules - runner_additional_security_group_ids = try(coalescelist(each.value.provider.ec2.runner_additional_security_group_ids, var.runner_additional_security_group_ids), []) - metadata_options = each.value.provider.ec2.runner_metadata_options - credit_specification = each.value.provider.ec2.credit_specification - cpu_options = each.value.provider.ec2.cpu_options - placement = each.value.provider.ec2.placement - license_specifications = each.value.provider.ec2.license_specifications - use_dedicated_host = each.value.provider.ec2.use_dedicated_host + sqs_build_queue = { "arn" : each.value.arn, "url" : each.value.url } + github_app_parameters = local.github_app_parameters + enable_organization_runners = each.value.runner.enable_organization_runners + enable_ephemeral_runners = each.value.runner.enable_ephemeral_runners + enable_jit_config = each.value.runner.enable_jit_config + enable_job_queued_check = each.value.runner.enable_job_queued_check + disable_runner_autoupdate = each.value.runner.disable_runner_autoupdate + scale_down_schedule_expression = each.value.runner.scale_down_schedule_expression + minimum_running_time_in_minutes = each.value.runner.minimum_running_time_in_minutes + runner_boot_time_in_minutes = each.value.runner.runner_boot_time_in_minutes + runner_disable_default_labels = each.value.runner.runner_disable_default_labels + runner_labels = each.value.runner.runner_disable_default_labels ? sort(distinct(each.value.runner.runner_extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.runner_os, each.value.runner.runner_architecture], each.value.runner.runner_extra_labels))) + runner_as_root = each.value.runner.runner_as_root + runner_run_as = each.value.runner.runner_run_as + runners_maximum_count = each.value.runner.runners_maximum_count + idle_config = each.value.runner.idle_config - enable_runner_binaries_syncer = each.value.provider.ec2.enable_runner_binaries_syncer lambda_s3_bucket = var.lambda_s3_bucket runners_lambda_s3_key = var.runners_lambda_s3_key runners_lambda_s3_object_version = var.runners_lambda_s3_object_version @@ -214,34 +238,15 @@ module "runner_stacks" { logging_retention_in_days = var.logging_retention_in_days logging_kms_key_id = var.logging_kms_key_id log_class = var.log_class - enable_cloudwatch_agent = each.value.provider.ec2.enable_cloudwatch_agent - cloudwatch_config = try(coalesce(each.value.provider.ec2.cloudwatch_config, var.cloudwatch_config), null) - runner_log_files = each.value.provider.ec2.runner_log_files runner_group_name = each.value.runner.runner_group_name runner_name_prefix = each.value.runner.runner_name_prefix parameter_store_tags = var.parameter_store_tags scale_up_reserved_concurrent_executions = each.value.runner.scale_up_reserved_concurrent_executions - instance_profile_path = var.instance_profile_path role_path = var.role_path role_permissions_boundary = var.role_permissions_boundary - enable_userdata = each.value.provider.ec2.enable_userdata - userdata_template = each.value.provider.ec2.userdata_template - userdata_content = each.value.provider.ec2.userdata_content - userdata_pre_install = each.value.provider.ec2.userdata_pre_install - userdata_post_install = each.value.provider.ec2.userdata_post_install - runner_hook_job_started = each.value.provider.ec2.runner_hook_job_started - runner_hook_job_completed = each.value.provider.ec2.runner_hook_job_completed - key_name = var.key_name - runner_ec2_tags = each.value.provider.ec2.runner_ec2_tags - - create_service_linked_role_spot = each.value.provider.ec2.create_service_linked_role_spot - - runner_iam_role_managed_policy_arns = each.value.provider.ec2.runner_iam_role_managed_policy_arns - iam_overrides = each.value.provider.ec2.iam_overrides - ghes_url = var.ghes_url ghes_ssl_verify = var.ghes_ssl_verify user_agent = var.user_agent @@ -254,7 +259,6 @@ module "runner_stacks" { pool_lambda_timeout = var.pool_lambda_timeout pool_runner_owner = each.value.runner.pool_runner_owner pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions - associate_public_ipv4_address = var.associate_public_ipv4_address ssm_housekeeper = var.runners_ssm_housekeeper diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index b2a8539a7d..6bd2cf6f67 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -1,4 +1,10 @@ mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } + mock_data "aws_iam_policy_document" { defaults = { json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" @@ -23,6 +29,7 @@ variables { lambda_s3_bucket = "lambda-artifacts" webhook_lambda_s3_key = "webhook.zip" runners_lambda_s3_key = "runners.zip" + syncer_lambda_s3_key = "runner-binaries-syncer.zip" } run "stable_v1_keeps_legacy_runner_module" { @@ -116,13 +123,17 @@ run "experimental_v2_routes_through_provider_stack" { schedule_expression = "cron(0 8 * * ? *)" size = 1 }] + iam = { + managed_policy_arns = { + readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } + } } provider = { type = "ec2" ec2 = { - instance_types = ["m5.large"] - enable_runner_binaries_syncer = false - runner_iam_role_managed_policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"] + instance_types = ["m5.large"] + enable_runner_binaries_syncer = false } } matcherConfig = { @@ -167,6 +178,7 @@ run "experimental_v2_routes_through_provider_stack" { "lambda_down_log_group", "lambda_pool", "lambda_pool_log_group", + "role_runner", "role_scale_up", "role_scale_down", "role_pool", @@ -179,21 +191,26 @@ run "experimental_v2_routes_through_provider_stack" { condition = ( output.runners_map["linux"].provider.type == "ec2" && can(output.runners_map["linux"].provider.ec2.launch_template) - && can(output.runners_map["linux"].provider.ec2.role_runner) && can(output.runners_map["linux"].provider.ec2.runners_log_groups) && can(output.runners_map["linux"].provider.ec2.logfiles) + && toset(keys(output.runners_map["linux"].provider.ec2)) == toset([ + "launch_template", + "runners_log_groups", + "logfiles", + ]) ) - error_message = "Experimental v2 EC2 resources must be available under runners_map..provider.ec2." + error_message = "Experimental v2 must expose only EC2-owned resources under runners_map..provider.ec2." } assert { condition = ( !can(output.runners_map["linux"].launch_template_name) - && !can(output.runners_map["linux"].role_runner) + && can(output.runners_map["linux"].role_runner) + && !can(output.runners_map["linux"].provider.ec2.role_runner) && !can(output.runners_map["linux"].runners_log_groups) && !can(output.runners_map["linux"].logfiles) ) - error_message = "Experimental v2 entries must not duplicate provider resources as flat output attributes." + error_message = "Experimental v2 must expose the common runner role at lane level without duplicating EC2 resources." } assert { @@ -202,12 +219,12 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = local.runner_config_by_provider.ec2["linux"].provider.ec2.runner_iam_role_managed_policy_arns[0] == "arn:aws:iam::aws:policy/ReadOnlyAccess" - error_message = "EC2 runner-role policies must remain in the EC2 provider contract." + condition = local.runner_config_by_provider.ec2["linux"].runner.iam.managed_policy_arns.readonly == "arn:aws:iam::aws:policy/ReadOnlyAccess" + error_message = "Runner-role policies must remain in the common runner contract." } } -run "experimental_v2_takes_precedence_without_legacy_addresses" { +run "stable_v1_and_experimental_v2_coexist" { command = plan variables { @@ -218,7 +235,7 @@ run "experimental_v2_takes_precedence_without_legacy_addresses" { runner_architecture = "x64" instance_types = ["m5.large"] runners_maximum_count = 2 - enable_runner_binaries_syncer = false + enable_runner_binaries_syncer = true enable_organization_runners = true } matcherConfig = { @@ -231,43 +248,123 @@ run "experimental_v2_takes_precedence_without_legacy_addresses" { experimental = { runner = { runner_os = "linux" - runner_architecture = "x64" + runner_architecture = "arm64" runners_maximum_count = 2 enable_organization_runners = true } provider = { type = "ec2" ec2 = { - instance_types = ["m5.large"] - enable_runner_binaries_syncer = false + instance_types = ["m7g.large"] + enable_runner_binaries_syncer = true } } matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "experimental"]] + labelMatchers = [["self-hosted", "linux", "arm64", "experimental"]] } } } } assert { - condition = length(module.runners) == 0 && keys(module.runner_stacks) == ["experimental"] - error_message = "Setting multi_runner_config_v2 must not instantiate any stable v1 runner modules." + condition = keys(local.runner_config_v1) == ["legacy"] && keys(local.runner_config_v2) == ["experimental"] + error_message = "Stable and experimental lanes must remain isolated in their respective lane maps." } assert { - condition = keys(aws_sqs_queue.queued_builds) == ["experimental"] && keys(local.runner_matcher_config) == ["experimental"] - error_message = "Queues and webhook routing must use only the selected v2 lane keys." + condition = keys(module.runners) == ["legacy"] && keys(module.runner_stacks) == ["experimental"] + error_message = "Stable lanes must keep module.runners addresses while v2 lanes use module.runner_stacks." } assert { - condition = keys(output.runners_map) == ["experimental"] - error_message = "The merged public runner map must expose only the selected v2 lane keys." + condition = ( + toset(keys(aws_sqs_queue.queued_builds)) == toset(["legacy", "experimental"]) + && toset(keys(local.runner_matcher_config)) == toset(["legacy", "experimental"]) + ) + error_message = "Queues and webhook routing must use the union of stable and experimental lane keys." + } + + assert { + condition = toset(keys(module.runner_binaries)) == toset(["linux_x64", "linux_arm64"]) + error_message = "Runner binary synchronization must include operating-system and architecture combinations from both input versions." + } + + assert { + condition = toset(keys(output.runners_map)) == toset(["legacy", "experimental"]) + error_message = "The public runner map must expose both stable and experimental lane keys." + } + + assert { + condition = toset(keys(output.runners_map["legacy"])) == toset( + [ + "launch_template_name", + "launch_template_id", + "launch_template_version", + "launch_template_ami_id", + "lambda_up", + "lambda_up_log_group", + "lambda_down", + "lambda_down_log_group", + "lambda_pool", + "lambda_pool_log_group", + "role_runner", + "role_scale_up", + "role_scale_down", + "role_pool", + "runners_log_groups", + "logfiles", + ] + ) + error_message = "A coexisting stable lane must retain the legacy flat runners_map entry shape." } assert { condition = output.runners_map["experimental"].provider.type == "ec2" && can(output.runners_map["experimental"].provider.ec2.launch_template) - error_message = "The selected v2 lane must retain its nested EC2 provider output." + error_message = "A coexisting v2 lane must retain its nested EC2 provider output." + } +} + +run "duplicate_lane_keys_are_rejected" { + command = plan + + variables { + multi_runner_config = { + duplicate = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 2 + enable_runner_binaries_syncer = false + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + + multi_runner_config_v2 = { + duplicate = { + runner = { + runner_os = "linux" + runner_architecture = "x64" + runners_maximum_count = 2 + } + provider = { + type = "ec2" + ec2 = { + instance_types = ["m5.large"] + enable_runner_binaries_syncer = false + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "experimental"]] + } + } + } } + + expect_failures = [random_string.random] } run "experimental_v2_rejects_future_providers" { @@ -293,3 +390,33 @@ run "experimental_v2_rejects_future_providers" { expect_failures = [var.multi_runner_config_v2] } + +run "experimental_v2_rejects_profile_without_role" { + command = plan + + variables { + multi_runner_config_v2 = { + invalid_profile = { + runner = { + runner_os = "linux" + runner_architecture = "x64" + runners_maximum_count = 2 + } + provider = { + type = "ec2" + ec2 = { + instance_types = ["m5.large"] + instance_profile = { + name = "external-profile" + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + + expect_failures = [var.multi_runner_config_v2] +} diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index e460883ed7..f577501707 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -1,6 +1,6 @@ variable "multi_runner_config_v2" { description = < This module is treated as internal module, breaking changes will not trigger a major release bump. -This module creates a provider-neutral runner control plane and selects its compute implementation with `provider_type`. EC2 is the only active provider today and remains the default. +This module creates a provider-neutral runner control plane and selects its compute implementation with `compute_provider.type`. Provider-owned settings are typed and nested under the selected provider block; for example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.ec2`. EC2 is the only active provider today. -The common layer owns scale-up, scale-down, pool, job retry, Lambda execution roles, shared SSM configuration, and the SSM housekeeper. [`../compute-providers/ec2`](../compute-providers/ec2) owns the launch template, runner instance role and policies, EC2 bootstrap parameters, runner log groups, and the EC2 environment and IAM fragments merged into the common Lambda resources. Future providers can implement the same output contract without copying the control plane. +The common layer owns scale-up, scale-down, pool, job retry, Lambda execution roles, the runner IAM role and policy attachments, shared SSM configuration, and the SSM housekeeper. [`../compute-providers/ec2/runner-role`](../compute-providers/ec2/runner-role) supplies the EC2 runner-role trust and permission documents without depending on the role. The common layer creates or selects the role and attaches those policies, then passes the role into [`../compute-providers/ec2`](../compute-providers/ec2), which owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and the EC2 environment and IAM fragments merged into the common Lambda resources. Future providers can implement the same two contracts without copying the control plane. ## Overview @@ -53,28 +53,29 @@ yarn run dist ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | +| [ec2\_runner\_role](#module\_ec2\_runner\_role) | ../compute-providers/ec2/runner-role | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | @@ -82,10 +83,12 @@ yarn run dist | [aws_cloudwatch_log_group.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_cloudwatch_log_group.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_cloudwatch_log_group.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.job_retry_sqs_publish](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.runner_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.scale_down_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.scale_down_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -97,6 +100,7 @@ yarn run dist | [aws_iam_role_policy.ssm_housekeeper_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.ssm_housekeeper_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy_attachment.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.scale_down_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.scale_up_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | @@ -120,44 +124,20 @@ yarn run dist ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | -| [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | +| ---- | ----------- | ---- | ------- | :------: | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | -| [block\_device\_mappings](#input\_block\_device\_mappings) | The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`. |
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
}
]
| no | -| [cloudwatch\_config](#input\_cloudwatch\_config) | (optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details. | `string` | `null` | no | -| [cpu\_options](#input\_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 | -| [create\_service\_linked\_role\_spot](#input\_create\_service\_linked\_role\_spot) | (optional) create the service linked role for spot instances that is required by the scale-up lambda. | `bool` | `false` | no | -| [credit\_specification](#input\_credit\_specification) | The credit option for CPU usage of a T instance. Can be unset, "standard" or "unlimited". | `string` | `null` | no | +| [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings must remain inside the selected provider block. |
object({
type = string

ec2 = optional(object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(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)
s3_runner_binaries = 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)
enable_userdata = optional(bool, true)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
enable_ssm_on_runners = optional(bool, false)
create_service_linked_role_spot = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
enable_managed_runner_security_group = optional(bool, true)
cloudwatch_config = optional(string, null)
runner_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)
runner_additional_security_group_ids = optional(list(string), [])
enable_runner_detailed_monitoring = 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
}])
runner_ec2_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)
}), {})
enable_runner_binaries_syncer = optional(bool, true)
enable_user_data_debug_logging = optional(bool, false)
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)
enable_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)
}), null)
})
| n/a | yes | | [disable\_runner\_autoupdate](#input\_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/) | `bool` | `false` | no | -| [ebs\_optimized](#input\_ebs\_optimized) | The EC2 EBS optimized configuration. | `bool` | `false` | no | -| [egress\_rules](#input\_egress\_rules) | List of egress rules for the GitHub runner instances. |
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"
],
"description": null,
"from_port": 0,
"ipv6_cidr_blocks": [
"::/0"
],
"prefix_list_ids": null,
"protocol": "-1",
"security_groups": null,
"self": null,
"to_port": 0
}
]
| no | -| [enable\_cloudwatch\_agent](#input\_enable\_cloudwatch\_agent) | Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`. | `bool` | `true` | no | | [enable\_ephemeral\_runners](#input\_enable\_ephemeral\_runners) | Enable ephemeral runners, runners will only be used once. | `bool` | `false` | no | | [enable\_jit\_config](#input\_enable\_jit\_config) | Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI. | `bool` | `null` | no | | [enable\_job\_queued\_check](#input\_enable\_job\_queued\_check) | Only scale if the job event received by the scale up lambda is is in the state queued. By default enabled for non ephemeral runners and disabled for ephemeral. Set this variable to overwrite the default behavior. | `bool` | `null` | no | -| [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | -| [enable\_on\_demand\_failover\_for\_errors](#input\_enable\_on\_demand\_failover\_for\_errors) | Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later. | `list(string)` | `[]` | no | | [enable\_organization\_runners](#input\_enable\_organization\_runners) | Register runners to organization, instead of repo level | `bool` | n/a | yes | -| [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI. | `bool` | `true` | no | -| [enable\_runner\_detailed\_monitoring](#input\_enable\_runner\_detailed\_monitoring) | Enable detailed monitoring for runners | `bool` | `false` | no | -| [enable\_ssm\_on\_runners](#input\_enable\_ssm\_on\_runners) | Enable to allow access to the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances. | `bool` | n/a | yes | -| [enable\_user\_data\_debug\_logging](#input\_enable\_user\_data\_debug\_logging) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no | -| [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI | `bool` | `true` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | | [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters. |
object({
key_base64 = map(string)
id = map(string)
})
| n/a | yes | -| [iam\_overrides](#input\_iam\_overrides) | This map provides the possibility to override some IAM defaults. The following attributes are supported: `instance_profile_name` overrides the instance profile name used in the launch template. `runner_role_arn` overrides the IAM role ARN used for the runner instances. |
object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
})
|
{
"instance_profile_name": null,
"override_instance_profile": false,
"override_runner_role": false,
"runner_role_arn": null
}
| no | | [idle\_config](#input\_idle\_config) | List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle. |
list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
}))
| `[]` | no | -| [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | -| [instance\_max\_spot\_price](#input\_instance\_max\_spot\_price) | Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet. | `string` | `null` | no | -| [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the prefix will be used. | `string` | `null` | no | -| [instance\_target\_capacity\_type](#input\_instance\_target\_capacity\_type) | Default lifecycle used runner instances, can be either `spot` or `on-demand`. | `string` | `"spot"` | no | -| [instance\_type\_priorities](#input\_instance\_type\_priorities) | A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`. | `map(number)` | `null` | 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)` | `null` | no | | [job\_retry](#input\_job\_retry) | 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\_reserved\_concurrent\_executions': Amount of reserved concurrent executions for the job retry check lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations.
`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_reserved_concurrent_executions = optional(number, 1)

lambda_timeout = optional(number, 30)

max_attempts = optional(number, 1)
})
| `{}` | no | -| [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no | | [kms\_key\_arn](#input\_kms\_key\_arn) | Optional CMK Key ARN to be used for Parameter Store. | `string` | `null` | no | | [lambda\_architecture](#input\_lambda\_architecture) | AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions. | `string` | `"arm64"` | no | | [lambda\_event\_source\_mapping\_batch\_size](#input\_lambda\_event\_source\_mapping\_batch\_size) | Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default of 10 events will be used. | `number` | `10` | no | @@ -172,17 +152,13 @@ yarn run dist | [lambda\_timeout\_scale\_down](#input\_lambda\_timeout\_scale\_down) | Time out for the scale down lambda in seconds. | `number` | `60` | no | | [lambda\_timeout\_scale\_up](#input\_lambda\_timeout\_scale\_up) | Time out for the scale up lambda in seconds. | `number` | `60` | no | | [lambda\_zip](#input\_lambda\_zip) | File location of the lambda zip file. | `string` | `null` | no | -| [license\_specifications](#input\_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 | | [log\_class](#input\_log\_class) | The log class of the CloudWatch log groups for the lambda functions. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. | `string` | `"STANDARD"` | no | | [log\_level](#input\_log\_level) | Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. | `string` | `"info"` | no | | [logging\_kms\_key\_id](#input\_logging\_kms\_key\_id) | Specifies the kms key id to encrypt the logs with | `string` | `null` | no | | [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 | -| [metadata\_options](#input\_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 | | [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 | -| [minimum\_running\_time\_in\_minutes](#input\_minimum\_running\_time\_in\_minutes) | The time an ec2 action runner should be running at minimum before terminated if non busy. If not set the default is calculated based on the OS. | `number` | `null` | no | -| [overrides](#input\_overrides) | This map provides the possibility to override some defaults. The following attributes are supported: `name_sg` overrides the `Name` tag for all security groups created by this module. `name_runner_agent_instance` overrides the `Name` tag for the ec2 instance defined in the auto launch configuration. `name_docker_machine_runners` overrides the `Name` tag spot instances created by the runner agent. | `map(string)` |
{
"name_runner": "",
"name_sg": ""
}
| no | +| [minimum\_running\_time\_in\_minutes](#input\_minimum\_running\_time\_in\_minutes) | Minimum time a runner should remain active before it can be terminated while idle. If unset, the default is calculated from runner\_os. | `number` | `null` | no | | [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | -| [placement](#input\_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 | | [pool\_config](#input\_pool\_config) | The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone ` to override the schedule time zone (defaults to UTC). |
list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
| `[]` | no | | [pool\_include\_busy\_runners](#input\_pool\_include\_busy\_runners) | Include busy runners in the pool calculation. By default busy runners are not included in the pool. | `bool` | `false` | no | | [pool\_lambda\_memory\_size](#input\_pool\_lambda\_memory\_size) | Lambda Memory size limit in MB for pool lambda | `number` | `512` | no | @@ -190,49 +166,34 @@ yarn run dist | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | | [pool\_runner\_owner](#input\_pool\_runner\_owner) | The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported. | `string` | `null` | no | | [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no | -| [provider\_type](#input\_provider\_type) | Compute provider used by the common runner control plane. | `string` | `"ec2"` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the prefix will be used. | `string` | `null` | no | | [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | -| [runner\_additional\_security\_group\_ids](#input\_runner\_additional\_security\_group\_ids) | (optional) List of additional security groups IDs to apply to the runner | `list(string)` | `[]` | no | -| [runner\_architecture](#input\_runner\_architecture) | The platform architecture of the runner instance\_type. | `string` | `"x64"` | no | +| [runner\_architecture](#input\_runner\_architecture) | Platform architecture used for runner labels and provider bootstrap. | `string` | `"x64"` | no | | [runner\_as\_root](#input\_runner\_as\_root) | Run the action runner under the root user. Variable `runner_run_as` will be ignored. | `bool` | `false` | 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\_boot\_time\_in\_minutes](#input\_runner\_boot\_time\_in\_minutes) | Minimum time for a compute runner to boot and register. | `number` | `5` | 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`. | `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 | | [runner\_group\_name](#input\_runner\_group\_name) | Name of the runner group. | `string` | `"Default"` | no | -| [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\_iam](#input\_runner\_iam) | Common runner-role configuration. Provider and user-managed policies are attached only when the runner stack creates the role; an external role must already contain all required policies. |
object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
})
| `{}` | no | | [runner\_labels](#input\_runner\_labels) | All the labels for the runners (GitHub) including the default one's(e.g: self-hosted, linux, x64, label1, label2). Separate each label by a comma | `list(string)` | n/a | yes | -| [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\_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, osx, windows). | `string` | `"linux"` | no | +| [runner\_os](#input\_runner\_os) | Operating system used for runner labels and provider bootstrap (linux, osx, windows). | `string` | `"linux"` | no | | [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | | [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for runners lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | | [runners\_lambda\_s3\_object\_version](#input\_runners\_lambda\_s3\_object\_version) | S3 object version for runners lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no | | [runners\_maximum\_count](#input\_runners\_maximum\_count) | The maximum number of runners that will be created. Setting the variable to `-1` desiables the maximum check. | `number` | `3` | no | -| [s3\_runner\_binaries](#input\_s3\_runner\_binaries) | Bucket details for cached GitHub binary. |
object({
arn = string
id = string
key = string
})
| n/a | yes | | [scale\_down\_schedule\_expression](#input\_scale\_down\_schedule\_expression) | Scheduler expression to check every x for scale down. | `string` | `"cron(*/5 * * * ? *)"` | no | -| [scale\_errors](#input\_scale\_errors) | List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors | `list(string)` |
[
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]
| no | | [scale\_up\_reserved\_concurrent\_executions](#input\_scale\_up\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [sqs\_build\_queue](#input\_sqs\_build\_queue) | SQS queue to consume accepted build events. |
object({
arn = string
url = string
})
| n/a | yes | | [ssm\_housekeeper](#input\_ssm\_housekeeper) | Configuration for the SSM housekeeper lambda. This lambda deletes token / JIT config from SSM.

`schedule_expression`: is used to configure the schedule for the lambda.
`state`: state of the cloudwatch event rule. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
`lambda_memory_size`: lambda memory size limit.
`lambda_timeout`: timeout for the lambda in seconds.
`config`: configuration for the lambda function. Token path will be read by default from the module. |
object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
lambda_memory_size = optional(number, 512)
lambda_timeout = optional(number, 60)
config = object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
})
})
|
{
"config": {}
}
| no | | [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
tokens = string
config = string
})
| n/a | yes | -| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes | | [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name. | `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) | Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly. | `bool` | `false` | no | | [user\_agent](#input\_user\_agent) | User agent used for API calls. | `string` | `null` | 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) | User-data script snippet to insert after GitHub action runner install | `string` | `""` | no | -| [userdata\_pre\_install](#input\_userdata\_pre\_install) | User-data script snippet to insert before GitHub action runner install | `string` | `""` | no | -| [userdata\_template](#input\_userdata\_template) | Alternative user-data template file path, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored. | `string` | `null` | no | -| [vpc\_id](#input\_vpc\_id) | The VPC for the security groups. | `string` | n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [lambda\_pool](#output\_lambda\_pool) | n/a | | [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a | | [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a | @@ -241,6 +202,7 @@ yarn run dist | [lambda\_scale\_up\_log\_group](#output\_lambda\_scale\_up\_log\_group) | n/a | | [provider](#output\_provider) | Selected compute provider type and its provider-specific resources. | | [role\_pool](#output\_role\_pool) | n/a | +| [role\_runner](#output\_role\_runner) | Runner IAM role created by the common stack. Empty when an external runner role is used. | | [role\_scale\_down](#output\_role\_scale\_down) | n/a | | [role\_scale\_up](#output\_role\_scale\_up) | n/a | diff --git a/modules/runner-stack/ec2.tf b/modules/runner-stack/ec2.tf new file mode 100644 index 0000000000..e8d167f0d5 --- /dev/null +++ b/modules/runner-stack/ec2.tf @@ -0,0 +1,73 @@ +locals { + provider_type = lower(trimspace(var.compute_provider.type)) + ec2 = var.compute_provider.ec2 + provider = one(module.ec2[*].control_plane) +} + +module "ec2" { + count = local.provider_type == "ec2" ? 1 : 0 + source = "../compute-providers/ec2" + + ami = local.ec2.ami + aws_region = var.aws_region + vpc_id = local.ec2.vpc_id + subnet_ids = local.ec2.subnet_ids + overrides = local.ec2.overrides + iam_overrides = { + override_instance_profile = local.ec2.instance_profile != null + instance_profile_name = try(local.ec2.instance_profile.name, null) + } + runner_role = local.runner_role + tags = var.tags + prefix = var.prefix + s3_runner_binaries = local.ec2.s3_runner_binaries + block_device_mappings = local.ec2.block_device_mappings + ebs_optimized = local.ec2.ebs_optimized + instance_target_capacity_type = local.ec2.instance_target_capacity_type + instance_allocation_strategy = local.ec2.instance_allocation_strategy + instance_type_priorities = local.ec2.instance_type_priorities + instance_max_spot_price = local.ec2.instance_max_spot_price + runner_os = var.runner_os + instance_types = local.ec2.instance_types + enable_userdata = local.ec2.enable_userdata + userdata_template = local.ec2.userdata_template + userdata_content = local.ec2.userdata_content + userdata_pre_install = local.ec2.userdata_pre_install + userdata_post_install = local.ec2.userdata_post_install + runner_hook_job_started = local.ec2.runner_hook_job_started + runner_hook_job_completed = local.ec2.runner_hook_job_completed + runner_boot_time_in_minutes = var.runner_boot_time_in_minutes + role_path = var.role_path + instance_profile_path = local.ec2.instance_profile_path + runner_as_root = var.runner_as_root + runner_run_as = var.runner_run_as + runner_architecture = var.runner_architecture + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + create_service_linked_role_spot = local.ec2.create_service_linked_role_spot + aws_partition = var.aws_partition + enable_cloudwatch_agent = local.ec2.enable_cloudwatch_agent + enable_managed_runner_security_group = local.ec2.enable_managed_runner_security_group + cloudwatch_config = local.ec2.cloudwatch_config + runner_log_files = local.ec2.runner_log_files + ghes_url = var.ghes_url + ghes_ssl_verify = var.ghes_ssl_verify + key_name = local.ec2.key_name + runner_additional_security_group_ids = local.ec2.runner_additional_security_group_ids + enable_runner_detailed_monitoring = local.ec2.enable_runner_detailed_monitoring + egress_rules = local.ec2.egress_rules + runner_ec2_tags = local.ec2.runner_ec2_tags + metadata_options = local.ec2.metadata_options + enable_runner_binaries_syncer = local.ec2.enable_runner_binaries_syncer + enable_user_data_debug_logging = local.ec2.enable_user_data_debug_logging + ssm_paths = var.ssm_paths + runner_name_prefix = var.runner_name_prefix + credit_specification = local.ec2.credit_specification + cpu_options = local.ec2.cpu_options + placement = local.ec2.placement + license_specifications = local.ec2.license_specifications + associate_public_ipv4_address = local.ec2.associate_public_ipv4_address + enable_on_demand_failover_for_errors = local.ec2.enable_on_demand_failover_for_errors + scale_errors = local.ec2.scale_errors + use_dedicated_host = local.ec2.use_dedicated_host +} diff --git a/modules/runner-stack/job-retry/main.tf b/modules/runner-stack/job-retry/job-retry.tf similarity index 98% rename from modules/runner-stack/job-retry/main.tf rename to modules/runner-stack/job-retry/job-retry.tf index 9c3377f59d..80aab8ad9f 100644 --- a/modules/runner-stack/job-retry/main.tf +++ b/modules/runner-stack/job-retry/job-retry.tf @@ -1,3 +1,4 @@ +# Provider-neutral job-retry queue and Lambda wiring. locals { lambda_zip = var.config.zip == null ? "${path.module}/../../../lambdas/functions/control-plane/runners.zip" : var.config.zip name = "job-retry" diff --git a/modules/runner-stack/local.tf b/modules/runner-stack/local.tf deleted file mode 100644 index 7cdc3a0a0c..0000000000 --- a/modules/runner-stack/local.tf +++ /dev/null @@ -1,8 +0,0 @@ -locals { - parameter_store_tags = jsonencode([ - for key, value in merge(var.tags, var.parameter_store_tags) : { - Key = key - Value = value - } - ]) -} diff --git a/modules/runner-stack/main.tf b/modules/runner-stack/main.tf deleted file mode 100644 index be531b8cc5..0000000000 --- a/modules/runner-stack/main.tf +++ /dev/null @@ -1,91 +0,0 @@ -locals { - tags = merge( - { - "Name" = format("%s-action-runner", var.prefix) - }, - { - "ghr:ssm_config_path" = "${var.ssm_paths.root}/${var.ssm_paths.config}" - }, - var.tags, - ) - - role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path - lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/control-plane/runners.zip" : var.lambda_zip - kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" - enable_job_queued_check = var.enable_job_queued_check == null ? !var.enable_ephemeral_runners : var.enable_job_queued_check - token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" - arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" - provider_type = lower(trimspace(var.provider_type)) - - provider = one(module.ec2[*].control_plane) -} - -data "aws_caller_identity" "current" {} - -module "ec2" { - count = local.provider_type == "ec2" ? 1 : 0 - source = "../compute-providers/ec2" - - ami = var.ami - aws_region = var.aws_region - vpc_id = var.vpc_id - subnet_ids = var.subnet_ids - overrides = var.overrides - iam_overrides = var.iam_overrides - tags = var.tags - prefix = var.prefix - s3_runner_binaries = var.s3_runner_binaries - block_device_mappings = var.block_device_mappings - ebs_optimized = var.ebs_optimized - instance_target_capacity_type = var.instance_target_capacity_type - instance_allocation_strategy = var.instance_allocation_strategy - instance_type_priorities = var.instance_type_priorities - instance_max_spot_price = var.instance_max_spot_price - runner_os = var.runner_os - instance_types = var.instance_types - enable_userdata = var.enable_userdata - userdata_template = var.userdata_template - userdata_content = var.userdata_content - userdata_pre_install = var.userdata_pre_install - userdata_post_install = var.userdata_post_install - runner_hook_job_started = var.runner_hook_job_started - runner_hook_job_completed = var.runner_hook_job_completed - runner_boot_time_in_minutes = var.runner_boot_time_in_minutes - role_permissions_boundary = var.role_permissions_boundary - role_path = var.role_path - instance_profile_path = var.instance_profile_path - runner_as_root = var.runner_as_root - runner_run_as = var.runner_run_as - runner_architecture = var.runner_architecture - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id - enable_ssm_on_runners = var.enable_ssm_on_runners - create_service_linked_role_spot = var.create_service_linked_role_spot - aws_partition = var.aws_partition - runner_iam_role_managed_policy_arns = var.runner_iam_role_managed_policy_arns - enable_cloudwatch_agent = var.enable_cloudwatch_agent - enable_managed_runner_security_group = var.enable_managed_runner_security_group - cloudwatch_config = var.cloudwatch_config - runner_log_files = var.runner_log_files - ghes_url = var.ghes_url - ghes_ssl_verify = var.ghes_ssl_verify - key_name = var.key_name - runner_additional_security_group_ids = var.runner_additional_security_group_ids - enable_runner_detailed_monitoring = var.enable_runner_detailed_monitoring - egress_rules = var.egress_rules - runner_ec2_tags = var.runner_ec2_tags - metadata_options = var.metadata_options - enable_runner_binaries_syncer = var.enable_runner_binaries_syncer - enable_user_data_debug_logging = var.enable_user_data_debug_logging - ssm_paths = var.ssm_paths - runner_name_prefix = var.runner_name_prefix - tracing_config = var.tracing_config - credit_specification = var.credit_specification - cpu_options = var.cpu_options - placement = var.placement - license_specifications = var.license_specifications - associate_public_ipv4_address = var.associate_public_ipv4_address - enable_on_demand_failover_for_errors = var.enable_on_demand_failover_for_errors - scale_errors = var.scale_errors - use_dedicated_host = var.use_dedicated_host -} diff --git a/modules/runner-stack/outputs.tf b/modules/runner-stack/outputs.tf index 6092e628ac..ffb6ba287e 100644 --- a/modules/runner-stack/outputs.tf +++ b/modules/runner-stack/outputs.tf @@ -41,3 +41,8 @@ output "lambda_pool_log_group" { output "role_pool" { value = try(module.pool[0].role_pool, null) } + +output "role_runner" { + description = "Runner IAM role created by the common stack. Empty when an external runner role is used." + value = aws_iam_role.runner +} diff --git a/modules/runner-stack/pool/main.tf b/modules/runner-stack/pool/pool.tf similarity index 99% rename from modules/runner-stack/pool/main.tf rename to modules/runner-stack/pool/pool.tf index aac9f23266..da2a545fef 100644 --- a/modules/runner-stack/pool/main.tf +++ b/modules/runner-stack/pool/pool.tf @@ -1,3 +1,4 @@ +# Provider-neutral pool Lambda and scheduler wiring. locals { pool_name_prefix = ( length("${var.config.prefix}-pool") <= 38 diff --git a/modules/runner-stack/runner-role.tf b/modules/runner-stack/runner-role.tf new file mode 100644 index 0000000000..cf0a93ca51 --- /dev/null +++ b/modules/runner-stack/runner-role.tf @@ -0,0 +1,69 @@ +# The common stack owns the runner role. The selected compute provider supplies +# the trust policy and provider-specific permission documents attached to it. +module "ec2_runner_role" { + count = local.provider_type == "ec2" ? 1 : 0 + source = "../compute-providers/ec2/runner-role" + + aws_partition = var.aws_partition + aws_region = var.aws_region + enable_cloudwatch_agent = local.ec2.enable_cloudwatch_agent + enable_runner_binaries_syncer = local.ec2.enable_runner_binaries_syncer + enable_ssm_on_runners = local.ec2.enable_ssm_on_runners + s3_runner_binaries = local.ec2.s3_runner_binaries + ssm_paths = var.ssm_paths +} + +locals { + provider_runner_role = one(module.ec2_runner_role[*]) + create_runner_role = local.provider_type == "ec2" && var.runner_iam.role == null + + runner_role = { + arn = local.create_runner_role ? one(aws_iam_role.runner[*].arn) : var.runner_iam.role.arn + name = local.create_runner_role ? one(aws_iam_role.runner[*].name) : basename(var.runner_iam.role.arn) + } + + runner_managed_policy_arns = merge( + { + for policy_name, policy_arn in var.runner_iam.managed_policy_arns : + "user-${policy_name}" => policy_arn + }, + var.tracing_config.mode != null ? { + xray = "arn:${var.aws_partition}:iam::aws:policy/AWSXRayDaemonWriteAccess" + } : {}, + { + for policy_name, policy_arn in try(local.provider_runner_role.managed_policy_arns, {}) : + "provider-${policy_name}" => policy_arn + }, + ) +} + +resource "aws_iam_role" "runner" { + count = local.create_runner_role ? 1 : 0 + name = "${substr("${var.prefix}-runner", 0, 54)}-${substr(md5("${var.prefix}-runner"), 0, 8)}" + assume_role_policy = local.provider_runner_role.assume_role_policy_json + path = local.role_path + permissions_boundary = var.role_permissions_boundary + tags = local.tags + + lifecycle { + precondition { + condition = local.ec2.instance_profile == null || var.runner_iam.role != null + error_message = "runner_iam.role must be set when compute_provider.ec2.instance_profile selects an external instance profile." + } + } +} + +resource "aws_iam_role_policy" "runner_provider" { + for_each = local.create_runner_role ? local.provider_runner_role.inline_policies : {} + + name = each.value.name + role = aws_iam_role.runner[0].name + policy = each.value.policy_json +} + +resource "aws_iam_role_policy_attachment" "runner" { + for_each = local.create_runner_role ? local.runner_managed_policy_arns : {} + + role = aws_iam_role.runner[0].name + policy_arn = each.value +} diff --git a/modules/runner-stack/stack-config.tf b/modules/runner-stack/stack-config.tf new file mode 100644 index 0000000000..f4065df53a --- /dev/null +++ b/modules/runner-stack/stack-config.tf @@ -0,0 +1,28 @@ +# Shared control-plane naming, paths, and tags. +locals { + tags = merge( + { + "Name" = format("%s-action-runner", var.prefix) + }, + { + "ghr:ssm_config_path" = "${var.ssm_paths.root}/${var.ssm_paths.config}" + }, + var.tags, + ) + + role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path + lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/control-plane/runners.zip" : var.lambda_zip + kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" + enable_job_queued_check = var.enable_job_queued_check == null ? !var.enable_ephemeral_runners : var.enable_job_queued_check + token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" + arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" + + parameter_store_tags = jsonencode([ + for key, value in merge(var.tags, var.parameter_store_tags) : { + Key = key + Value = value + } + ]) +} + +data "aws_caller_identity" "current" {} diff --git a/modules/runner-stack/tests/computed-iam-inputs.tftest.hcl b/modules/runner-stack/tests/computed-iam-inputs.tftest.hcl new file mode 100644 index 0000000000..bf4469e1f5 --- /dev/null +++ b/modules/runner-stack/tests/computed-iam-inputs.tftest.hcl @@ -0,0 +1,25 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } +} + +run "computed_iam_values_keep_plan_shape_known" { + command = plan + + module { + source = "./tests/fixtures/computed-iam-inputs" + } + + assert { + condition = output.external_role_runner_count == 0 + error_message = "A computed external role ARN and profile name must not make role or profile counts unknown." + } + + assert { + condition = output.generated_policy_role_runner_count == 1 + error_message = "A computed managed-policy ARN under a caller-known map key must keep attachment planning stable." + } +} diff --git a/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf b/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf new file mode 100644 index 0000000000..91414be955 --- /dev/null +++ b/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -0,0 +1,123 @@ +# Plan-time regression fixture for computed role, profile, and policy values. +resource "random_id" "external" { + byte_length = 4 +} + +resource "random_id" "generated_policy" { + byte_length = 4 +} + +module "external_iam" { + source = "../../.." + + aws_region = "eu-west-1" + prefix = "computed-external" + + compute_provider = { + type = "ec2" + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + instance_profile = { + name = "external-runner-${random_id.external.hex}" + } + enable_cloudwatch_agent = false + enable_runner_binaries_syncer = false + } + } + + runner_iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external-runner-${random_id.external.hex}" + } + } + + sqs_build_queue = { + arn = "arn:aws:sqs:eu-west-1:123456789012:computed-external" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-external" + } + + enable_organization_runners = true + runner_labels = ["self-hosted", "linux", "x64"] + + lambda_s3_bucket = "lambda-artifacts" + runners_lambda_s3_key = "runners.zip" + + github_app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } + } + + ssm_paths = { + root = "/github-runner/computed-external" + tokens = "tokens" + config = "config" + } +} + +module "generated_policy" { + source = "../../.." + + aws_region = "eu-west-1" + prefix = "computed-policy" + + compute_provider = { + type = "ec2" + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + enable_cloudwatch_agent = false + enable_runner_binaries_syncer = false + } + } + + runner_iam = { + managed_policy_arns = { + generated = "arn:aws:iam::123456789012:policy/generated-runner-${random_id.generated_policy.hex}" + } + } + + sqs_build_queue = { + arn = "arn:aws:sqs:eu-west-1:123456789012:computed-policy" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-policy" + } + + enable_organization_runners = true + runner_labels = ["self-hosted", "linux", "x64"] + + lambda_s3_bucket = "lambda-artifacts" + runners_lambda_s3_key = "runners.zip" + + github_app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } + } + + ssm_paths = { + root = "/github-runner/computed-policy" + tokens = "tokens" + config = "config" + } +} + +output "external_role_runner_count" { + value = length(module.external_iam.role_runner) +} + +output "generated_policy_role_runner_count" { + value = length(module.generated_policy.role_runner) +} diff --git a/modules/runner-stack/tests/fixtures/computed-iam-inputs/versions.tf b/modules/runner-stack/tests/fixtures/computed-iam-inputs/versions.tf new file mode 100644 index 0000000000..9fd85fad8f --- /dev/null +++ b/modules/runner-stack/tests/fixtures/computed-iam-inputs/versions.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.3" + + required_providers { + aws = { + source = "hashicorp/aws" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl index b6b98945d7..ad7a7ddd11 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -20,22 +20,26 @@ mock_provider "aws" { variables { aws_region = "eu-west-1" - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - instance_types = ["m5.large"] - - ami = { - filter = { state = ["available"] } - owners = ["amazon"] - id_ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id" - kms_key_arn = null - } - - s3_runner_binaries = { - arn = "arn:aws:s3:::my-bucket" - id = "my-bucket" - key = "runners/linux/actions-runner.tar.gz" + compute_provider = { + type = "ec2" + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id" + kms_key_arn = null + } + s3_runner_binaries = { + arn = "arn:aws:s3:::my-bucket" + id = "my-bucket" + key = "runners/linux/actions-runner.tar.gz" + } + enable_ssm_on_runners = true + } } sqs_build_queue = { @@ -44,7 +48,6 @@ variables { } enable_organization_runners = true - enable_ssm_on_runners = true runner_labels = ["self-hosted", "linux", "x64"] # Use S3 bucket to avoid filebase64sha256 needing local zip files @@ -87,6 +90,29 @@ run "plan_with_pool_enabled" { error_message = "The runner stack must expose EC2 resources only under provider.ec2." } + assert { + condition = length(aws_iam_role.runner) == 1 && length(output.role_runner) == 1 + error_message = "The common runner stack must create and expose the runner role." + } + + assert { + condition = !contains(keys(output.provider.ec2), "role_runner") + error_message = "The common runner role must not be duplicated in the EC2 resource output." + } + + assert { + condition = toset(keys(aws_iam_role_policy.runner_provider)) == toset([ + "ssm_parameters", + "describe_tags", + "create_tags", + "terminate_self", + "session_manager", + "distribution_bucket", + "cloudwatch", + ]) + error_message = "The common stack must attach every enabled EC2 runner policy by its stable provider key." + } + assert { condition = aws_lambda_function.scale_up.environment[0].variables["RUNNER_PROVIDER_TYPE"] == "ec2" error_message = "Scale-up must receive the provider type from the selected provider." @@ -109,14 +135,148 @@ run "plan_with_pool_enabled" { } +run "external_runner_role_is_not_managed_by_common" { + command = plan + + variables { + runner_iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" + } + } + } + + assert { + condition = length(aws_iam_role.runner) == 0 && length(aws_iam_role_policy.runner_provider) == 0 && length(aws_iam_role_policy_attachment.runner) == 0 + error_message = "An external runner role must remain unmanaged by the common stack." + } + + assert { + condition = length(output.role_runner) == 0 + error_message = "The role_runner output must be empty when an external role is selected." + } + + + assert { + condition = output.provider.ec2.launch_template.iam_instance_profile[0].name == "github-actions-runner-profile" + error_message = "EC2 must create an instance profile around an externally supplied runner role when no profile override is provided." + } +} + +run "external_runner_role_and_profile_remain_external" { + command = plan + + variables { + runner_iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" + } + } + compute_provider = { + type = "ec2" + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + instance_profile = { + name = "external-runner-profile" + } + enable_runner_binaries_syncer = false + } + } + } + + assert { + condition = length(aws_iam_role.runner) == 0 && length(aws_iam_role_policy.runner_provider) == 0 + error_message = "The common stack must not manage an external role." + } + + assert { + condition = output.provider.ec2.launch_template.iam_instance_profile[0].name == "external-runner-profile" + error_message = "The EC2 launch template must use the external instance profile." + } +} + +run "external_profile_requires_external_role" { + command = plan + + variables { + compute_provider = { + type = "ec2" + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + instance_profile = { + name = "external-runner-profile" + } + enable_runner_binaries_syncer = false + } + } + } + + expect_failures = [aws_iam_role.runner] +} + +run "empty_runner_iam_uses_common_role" { + command = plan + + variables { + runner_iam = {} + } + + assert { + condition = length(aws_iam_role.runner) == 1 + error_message = "An empty runner_iam object must use common role ownership." + } +} + +run "external_role_rejects_managed_policy_attachments" { + command = plan + + variables { + runner_iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" + } + managed_policy_arns = { + readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } + } + } + + expect_failures = [var.runner_iam] +} + +run "requires_distribution_object_when_sync_is_enabled" { + command = plan + + variables { + compute_provider = { + type = "ec2" + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + enable_runner_binaries_syncer = true + s3_runner_binaries = null + } + } + } + + expect_failures = [var.compute_provider] +} + run "rejects_unimplemented_compute_provider" { command = plan variables { - provider_type = "microvm" + compute_provider = { + type = "microvm" + } } - expect_failures = [var.provider_type] + expect_failures = [var.compute_provider] } run "job_retry_uses_common_lane_identity" { diff --git a/modules/runner-stack/variables-compute-provider.tf b/modules/runner-stack/variables-compute-provider.tf new file mode 100644 index 0000000000..53bda5c211 --- /dev/null +++ b/modules/runner-stack/variables-compute-provider.tf @@ -0,0 +1,211 @@ +# Typed boundary between the common control plane and compute implementations. +variable "compute_provider" { + description = "Typed compute-provider configuration. Provider-owned settings must remain inside the selected provider block." + + type = object({ + type = string + + ec2 = optional(object({ + ami = optional(object({ + filter = optional(map(list(string)), { state = ["available"] }) + owners = optional(list(string), ["amazon"]) + id_ssm_parameter_arn = optional(string, null) + kms_key_arn = optional(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) + s3_runner_binaries = 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) + enable_userdata = optional(bool, true) + userdata_template = optional(string, null) + userdata_content = optional(string, null) + userdata_pre_install = optional(string, "") + userdata_post_install = optional(string, "") + runner_hook_job_started = optional(string, "") + runner_hook_job_completed = optional(string, "") + enable_ssm_on_runners = optional(bool, false) + create_service_linked_role_spot = optional(bool, false) + enable_cloudwatch_agent = optional(bool, true) + enable_managed_runner_security_group = optional(bool, true) + cloudwatch_config = optional(string, null) + runner_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) + runner_additional_security_group_ids = optional(list(string), []) + enable_runner_detailed_monitoring = 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 + }]) + runner_ec2_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) + }), {}) + enable_runner_binaries_syncer = optional(bool, true) + enable_user_data_debug_logging = optional(bool, false) + 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) + enable_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) + }), null) + }) + + validation { + condition = contains(["ec2"], lower(trimspace(var.compute_provider.type))) + error_message = "Supported compute providers: ec2." + } + + validation { + condition = lower(trimspace(var.compute_provider.type)) != "ec2" || var.compute_provider.ec2 != null + error_message = "compute_provider.ec2 must be set when compute_provider.type is ec2." + } + + validation { + condition = var.compute_provider.ec2 == null ? true : contains( + ["spot", "on-demand"], + var.compute_provider.ec2.instance_target_capacity_type, + ) + error_message = "compute_provider.ec2.instance_target_capacity_type must be spot or on-demand." + } + + validation { + condition = var.compute_provider.ec2 == null ? true : contains( + ["lowest-price", "diversified", "capacity-optimized", "capacity-optimized-prioritized", "price-capacity-optimized", "prioritized"], + var.compute_provider.ec2.instance_allocation_strategy, + ) + error_message = "compute_provider.ec2.instance_allocation_strategy is not supported." + } + + validation { + condition = var.compute_provider.ec2 == null ? true : ( + var.compute_provider.ec2.credit_specification == null ? true : contains( + ["standard", "unlimited"], + var.compute_provider.ec2.credit_specification, + ) + ) + error_message = "compute_provider.ec2.credit_specification must be null, standard, or unlimited." + } + + validation { + condition = var.compute_provider.ec2 == null ? true : ( + var.compute_provider.ec2.cpu_options == null ? true : ( + (var.compute_provider.ec2.cpu_options.amd_sev_snp == null ? true : contains(["enabled", "disabled"], var.compute_provider.ec2.cpu_options.amd_sev_snp)) && + (var.compute_provider.ec2.cpu_options.nested_virtualization == null ? true : contains(["enabled", "disabled"], var.compute_provider.ec2.cpu_options.nested_virtualization)) + ) + ) + error_message = "compute_provider.ec2.cpu_options amd_sev_snp and nested_virtualization must be enabled or disabled when set." + } + + validation { + condition = var.compute_provider.ec2 == null ? true : ( + !var.compute_provider.ec2.enable_runner_binaries_syncer || var.compute_provider.ec2.s3_runner_binaries != null + ) + error_message = "compute_provider.ec2.s3_runner_binaries must be set when enable_runner_binaries_syncer is true." + } +} + +variable "runner_iam" { + description = "Common runner-role configuration. Provider and user-managed policies are attached only when the runner stack creates the role; an external role must already contain all required policies." + type = object({ + role = optional(object({ + arn = string + }), null) + managed_policy_arns = optional(map(string), {}) + }) + default = {} + + validation { + condition = var.runner_iam.role == null ? true : trimspace(var.runner_iam.role.arn) != "" + error_message = "runner_iam.role.arn must be a non-empty ARN when set." + } + + validation { + condition = var.runner_iam.role == null || length(var.runner_iam.managed_policy_arns) == 0 + error_message = "runner_iam.managed_policy_arns cannot be set with an external runner_iam.role because external roles are not managed by this module." + } +} diff --git a/modules/runner-stack/variables.tf b/modules/runner-stack/variables.tf index 0822782967..8c744b4d51 100644 --- a/modules/runner-stack/variables.tf +++ b/modules/runner-stack/variables.tf @@ -1,88 +1,8 @@ -variable "provider_type" { - description = "Compute provider used by the common runner control plane." - type = string - default = "ec2" - - validation { - condition = contains(["ec2"], lower(trimspace(var.provider_type))) - error_message = "Supported runner providers: ec2." - } -} - -variable "ami" { - description = < Date: Fri, 7 Aug 2026 01:27:12 +0000 Subject: [PATCH 08/27] docs: auto update terraform docs --- modules/compute-providers/ec2/README.md | 12 +++--- .../ec2/runner-role/README.md | 12 +++--- modules/multi-runner/README.md | 16 ++++---- modules/runner-stack/README.md | 14 +++---- .../fixtures/computed-iam-inputs/README.md | 39 +++++++++++++++++++ 5 files changed, 66 insertions(+), 27 deletions(-) create mode 100644 modules/runner-stack/tests/fixtures/computed-iam-inputs/README.md diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index f52ad02dfb..7cb4f698df 100644 --- a/modules/compute-providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -10,15 +10,15 @@ EC2 is the only active compute provider. A future provider must implement the sa ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -27,7 +27,7 @@ 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 | @@ -48,7 +48,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | @@ -112,7 +112,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [control\_plane](#output\_control\_plane) | Provider-neutral control-plane contract containing the EC2 environment and IAM fragments. | | [resources](#output\_resources) | EC2-specific resources and bootstrap logging details. | diff --git a/modules/compute-providers/ec2/runner-role/README.md b/modules/compute-providers/ec2/runner-role/README.md index ae2dbe82e4..3495b83afc 100644 --- a/modules/compute-providers/ec2/runner-role/README.md +++ b/modules/compute-providers/ec2/runner-role/README.md @@ -6,15 +6,15 @@ This internal module builds only the EC2-specific IAM documents required by a ru ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -23,7 +23,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | | [aws_iam_policy_document.assume_role](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 | @@ -37,7 +37,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to build IAM and SSM ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region containing the runner configuration parameters. | `string` | n/a | yes | | [enable\_cloudwatch\_agent](#input\_enable\_cloudwatch\_agent) | Include the CloudWatch agent policy in the runner role contract. | `bool` | n/a | yes | @@ -49,7 +49,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [assume\_role\_policy\_json](#output\_assume\_role\_policy\_json) | EC2 runner-role trust policy document. | | [inline\_policies](#output\_inline\_policies) | EC2 runner-role inline policies keyed by stable provider policy identifiers. | | [managed\_policy\_arns](#output\_managed\_policy\_arns) | EC2 provider-managed runner-role policy ARNs keyed by stable identifiers. | diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 56aaa9de0f..5ab8b8dfdb 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -115,7 +115,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -123,14 +123,14 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | -| [random](#provider\_random) | 3.9.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | +| [random](#provider\_random) | ~> 3.0 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -142,7 +142,7 @@ module "multi-runner" { ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -153,7 +153,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | | [ami\_housekeeper\_lambda\_s3\_key](#input\_ami\_housekeeper\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | @@ -237,7 +237,7 @@ module "multi-runner" { ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index b31d22efd8..1394b136db 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -53,20 +53,20 @@ yarn run dist ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [ec2\_runner\_role](#module\_ec2\_runner\_role) | ../compute-providers/ec2/runner-role | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | @@ -75,7 +75,7 @@ yarn run dist ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | @@ -124,7 +124,7 @@ yarn run dist ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | | [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings must remain inside the selected provider block. |
object({
type = string

ec2 = optional(object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(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)
s3_runner_binaries = 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)
enable_userdata = optional(bool, true)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
enable_ssm_on_runners = optional(bool, false)
create_service_linked_role_spot = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
enable_managed_runner_security_group = optional(bool, true)
cloudwatch_config = optional(string, null)
runner_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)
runner_additional_security_group_ids = optional(list(string), [])
enable_runner_detailed_monitoring = 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
}])
runner_ec2_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)
}), {})
enable_runner_binaries_syncer = optional(bool, true)
enable_user_data_debug_logging = optional(bool, false)
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)
enable_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)
}), null)
})
| n/a | yes | @@ -193,7 +193,7 @@ yarn run dist ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [lambda\_pool](#output\_lambda\_pool) | n/a | | [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a | | [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a | diff --git a/modules/runner-stack/tests/fixtures/computed-iam-inputs/README.md b/modules/runner-stack/tests/fixtures/computed-iam-inputs/README.md new file mode 100644 index 0000000000..08c02ba66d --- /dev/null +++ b/modules/runner-stack/tests/fixtures/computed-iam-inputs/README.md @@ -0,0 +1,39 @@ + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3 | +| [random](#requirement\_random) | ~> 3.0 | + +## Providers + +| Name | Version | +|------|---------| +| [random](#provider\_random) | ~> 3.0 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [external\_iam](#module\_external\_iam) | ../../.. | n/a | +| [generated\_policy](#module\_generated\_policy) | ../../.. | n/a | + +## Resources + +| Name | Type | +|------|------| +| [random_id.external](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | +| [random_id.generated_policy](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | + +## Inputs + +No inputs. + +## Outputs + +| Name | Description | +|------|-------------| +| [external\_role\_runner\_count](#output\_external\_role\_runner\_count) | n/a | +| [generated\_policy\_role\_runner\_count](#output\_generated\_policy\_role\_runner\_count) | n/a | + \ No newline at end of file From 1339e1f3dc443c1c8077c8c89b3f5b6915c7e44b Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 03:47:08 +0200 Subject: [PATCH 09/27] refactor(runner-stack): define IAM policies with data sources --- modules/runner-stack/job-retry/job-retry.tf | 12 +-- modules/runner-stack/job-retry/policies.tf | 54 ++++++++++++++ .../job-retry/policies/lambda.json | 44 ----------- .../policies/lambda-cloudwatch.json | 10 --- .../policies/lambda-publish-sqs-policy.json | 10 --- .../policies/lambda-scale-down.json | 17 ----- .../policies/lambda-scale-up.json | 31 -------- .../policies/lambda-ssm-housekeeper.json | 13 ---- modules/runner-stack/pool/policies.tf | 65 ++++++++++++++++ .../pool/policies/lambda-pool.json | 45 ----------- modules/runner-stack/pool/pool.tf | 15 +--- modules/runner-stack/scale-down-policies.tf | 41 ++++++++++ modules/runner-stack/scale-down.tf | 19 +---- modules/runner-stack/scale-up-policies.tf | 74 +++++++++++++++++++ modules/runner-stack/scale-up.tf | 33 ++------- .../runner-stack/ssm-housekeeper-policies.tf | 23 ++++++ modules/runner-stack/ssm-housekeeper.tf | 16 ++-- 17 files changed, 280 insertions(+), 242 deletions(-) create mode 100644 modules/runner-stack/job-retry/policies.tf delete mode 100644 modules/runner-stack/job-retry/policies/lambda.json delete mode 100644 modules/runner-stack/policies/lambda-cloudwatch.json delete mode 100644 modules/runner-stack/policies/lambda-publish-sqs-policy.json delete mode 100644 modules/runner-stack/policies/lambda-scale-down.json delete mode 100644 modules/runner-stack/policies/lambda-scale-up.json delete mode 100644 modules/runner-stack/policies/lambda-ssm-housekeeper.json create mode 100644 modules/runner-stack/pool/policies.tf delete mode 100644 modules/runner-stack/pool/policies/lambda-pool.json create mode 100644 modules/runner-stack/scale-down-policies.tf create mode 100644 modules/runner-stack/scale-up-policies.tf create mode 100644 modules/runner-stack/ssm-housekeeper-policies.tf diff --git a/modules/runner-stack/job-retry/job-retry.tf b/modules/runner-stack/job-retry/job-retry.tf index 80aab8ad9f..411b0903c9 100644 --- a/modules/runner-stack/job-retry/job-retry.tf +++ b/modules/runner-stack/job-retry/job-retry.tf @@ -61,15 +61,9 @@ resource "aws_lambda_permission" "job_retry" { } resource "aws_iam_role_policy" "job_retry" { - name = "job_retry-policy" - role = module.job_retry.lambda.role.name - policy = templatefile("${path.module}/policies/lambda.json", { - kms_key_arn = var.config.kms_key_arn != null ? var.config.kms_key_arn : "" - sqs_build_queue_arn = var.config.sqs_build_queue.arn - sqs_job_retry_queue_arn = aws_sqs_queue.job_retry_check_queue.arn - github_app_id_arn = var.config.github_app_parameters.id.arn - github_app_key_base64_arn = var.config.github_app_parameters.key_base64.arn - }) + name = "job_retry-policy" + role = module.job_retry.lambda.role.name + policy = data.aws_iam_policy_document.job_retry.json } data "aws_iam_policy_document" "deny_insecure_transport" { diff --git a/modules/runner-stack/job-retry/policies.tf b/modules/runner-stack/job-retry/policies.tf new file mode 100644 index 0000000000..8f8a912237 --- /dev/null +++ b/modules/runner-stack/job-retry/policies.tf @@ -0,0 +1,54 @@ +data "aws_iam_policy_document" "job_retry" { + statement { + effect = "Allow" + + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + + resources = [ + var.config.github_app_parameters.key_base64.arn, + var.config.github_app_parameters.id.arn, + ] + } + + statement { + effect = "Allow" + + actions = [ + "sqs:ReceiveMessage", + "sqs:GetQueueAttributes", + "sqs:DeleteMessage", + ] + + resources = [aws_sqs_queue.job_retry_check_queue.arn] + } + + statement { + effect = "Allow" + + actions = [ + "sqs:SendMessage", + "sqs:GetQueueAttributes", + ] + + resources = [var.config.sqs_build_queue.arn] + } + + dynamic "statement" { + for_each = var.config.kms_key_arn == null ? [] : var.config.kms_key_arn == "" ? [] : [var.config.kms_key_arn] + + content { + effect = "Allow" + + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [statement.value] + } + } +} diff --git a/modules/runner-stack/job-retry/policies/lambda.json b/modules/runner-stack/job-retry/policies/lambda.json deleted file mode 100644 index f1c9efd569..0000000000 --- a/modules/runner-stack/job-retry/policies/lambda.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameter", - "ssm:GetParameters" - ], - "Resource": [ - "${github_app_key_base64_arn}", - "${github_app_id_arn}" - ] - }, - { - "Effect": "Allow", - "Action": [ - "sqs:ReceiveMessage", - "sqs:GetQueueAttributes", - "sqs:DeleteMessage" - ], - "Resource": "${sqs_job_retry_queue_arn}" - }, - { - "Effect": "Allow", - "Action": [ - "sqs:SendMessage", - "sqs:GetQueueAttributes" - ], - "Resource": "${sqs_build_queue_arn}" -%{ if kms_key_arn != "" ~} - }, - { - "Effect": "Allow", - "Action": [ - "kms:Encrypt", - "kms:Decrypt", - "kms:GenerateDataKey" - ], - "Resource": "${kms_key_arn}" -%{ endif ~} - } - ] -} diff --git a/modules/runner-stack/policies/lambda-cloudwatch.json b/modules/runner-stack/policies/lambda-cloudwatch.json deleted file mode 100644 index ad9246bcb3..0000000000 --- a/modules/runner-stack/policies/lambda-cloudwatch.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], - "Resource": "${log_group_arn}*" - } - ] -} diff --git a/modules/runner-stack/policies/lambda-publish-sqs-policy.json b/modules/runner-stack/policies/lambda-publish-sqs-policy.json deleted file mode 100644 index 031560874b..0000000000 --- a/modules/runner-stack/policies/lambda-publish-sqs-policy.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["sqs:SendMessage", "sqs:GetQueueAttributes"], - "Resource": ${sqs_resource_arns} - } - ] -} diff --git a/modules/runner-stack/policies/lambda-scale-down.json b/modules/runner-stack/policies/lambda-scale-down.json deleted file mode 100644 index dbd77b2383..0000000000 --- a/modules/runner-stack/policies/lambda-scale-down.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["ssm:GetParameter", "ssm:GetParameters"], - "Resource": ["${github_app_key_base64_arn}", "${github_app_id_arn}"] - } -%{ if kms_key_arn != "" ~} - ,{ - "Effect": "Allow", - "Action": ["kms:Decrypt"], - "Resource": "${kms_key_arn}" - } -%{ endif ~} - ] -} diff --git a/modules/runner-stack/policies/lambda-scale-up.json b/modules/runner-stack/policies/lambda-scale-up.json deleted file mode 100644 index c7a98ffa88..0000000000 --- a/modules/runner-stack/policies/lambda-scale-up.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["ssm:PutParameter", "ssm:AddTagsToResource"], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": ["ssm:GetParameter", "ssm:GetParameters"], - "Resource": [ - "${github_app_key_base64_arn}", - "${github_app_id_arn}", - "${ssm_config_path}/*" - ] - }, - { - "Effect": "Allow", - "Action": ["sqs:ReceiveMessage", "sqs:GetQueueAttributes", "sqs:DeleteMessage"], - "Resource": "${sqs_arn}" - } -%{ if kms_key_arn != "" ~} - ,{ - "Effect": "Allow", - "Action": ["kms:Decrypt"], - "Resource": "${kms_key_arn}" - } -%{ endif ~} - ] -} diff --git a/modules/runner-stack/policies/lambda-ssm-housekeeper.json b/modules/runner-stack/policies/lambda-ssm-housekeeper.json deleted file mode 100644 index 5e49baafaa..0000000000 --- a/modules/runner-stack/policies/lambda-ssm-housekeeper.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "ssm:DeleteParameter", - "ssm:GetParametersByPath" - ], - "Resource": "${ssm_token_path}*" - } - ] -} diff --git a/modules/runner-stack/pool/policies.tf b/modules/runner-stack/pool/policies.tf new file mode 100644 index 0000000000..2d64b7294b --- /dev/null +++ b/modules/runner-stack/pool/policies.tf @@ -0,0 +1,65 @@ +data "aws_iam_policy_document" "pool_common" { + statement { + effect = "Allow" + + actions = [ + "ssm:AddTagsToResource", + "ssm:PutParameter", + ] + + resources = ["*"] + } + + statement { + effect = "Allow" + + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + ] + + resources = [ + var.config.arn_ssm_parameters_path_config, + "${var.config.arn_ssm_parameters_path_config}/*", + ] + } + + statement { + effect = "Allow" + + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + + resources = [ + var.config.github_app_parameters.key_base64.arn, + var.config.github_app_parameters.id.arn, + ] + } + + dynamic "statement" { + for_each = var.config.kms_key_arn == "" ? [] : [var.config.kms_key_arn] + + content { + effect = "Allow" + + actions = ["kms:Decrypt"] + resources = [statement.value] + } + } +} + +data "aws_iam_policy_document" "pool_logging" { + statement { + effect = "Allow" + + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + + resources = ["${aws_cloudwatch_log_group.pool.arn}*"] + } +} diff --git a/modules/runner-stack/pool/policies/lambda-pool.json b/modules/runner-stack/pool/policies/lambda-pool.json deleted file mode 100644 index b44f135a64..0000000000 --- a/modules/runner-stack/pool/policies/lambda-pool.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "ssm:AddTagsToResource", - "ssm:PutParameter" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameter", - "ssm:GetParameters", - "ssm:GetParametersByPath" - ], - "Resource": [ - "${arn_ssm_parameters_path_config}", - "${arn_ssm_parameters_path_config}/*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameter", - "ssm:GetParameters" - ], - "Resource": [ - "${github_app_key_base64_arn}", - "${github_app_id_arn}" - ] -%{ if kms_key_arn != "" ~} - }, - { - "Effect": "Allow", - "Action": [ - "kms:Decrypt" - ], - "Resource": "${kms_key_arn}" -%{ endif ~} - } - ] -} diff --git a/modules/runner-stack/pool/pool.tf b/modules/runner-stack/pool/pool.tf index da2a545fef..15fec39a7e 100644 --- a/modules/runner-stack/pool/pool.tf +++ b/modules/runner-stack/pool/pool.tf @@ -95,22 +95,15 @@ resource "aws_iam_role_policy" "pool" { data "aws_iam_policy_document" "pool" { source_policy_documents = [ - templatefile("${path.module}/policies/lambda-pool.json", { - arn_ssm_parameters_path_config = var.config.arn_ssm_parameters_path_config - github_app_id_arn = var.config.github_app_parameters.id.arn - github_app_key_base64_arn = var.config.github_app_parameters.key_base64.arn - kms_key_arn = var.config.kms_key_arn - }), + data.aws_iam_policy_document.pool_common.json, var.runner_provider.iam_policy_json, ] } resource "aws_iam_role_policy" "pool_logging" { - name = "logging-policy" - role = aws_iam_role.pool.name - policy = templatefile("${path.module}/../policies/lambda-cloudwatch.json", { - log_group_arn = aws_cloudwatch_log_group.pool.arn - }) + name = "logging-policy" + role = aws_iam_role.pool.name + policy = data.aws_iam_policy_document.pool_logging.json } resource "aws_iam_role_policy_attachment" "pool_vpc_execution_role" { diff --git a/modules/runner-stack/scale-down-policies.tf b/modules/runner-stack/scale-down-policies.tf new file mode 100644 index 0000000000..544d6b7c16 --- /dev/null +++ b/modules/runner-stack/scale-down-policies.tf @@ -0,0 +1,41 @@ +data "aws_iam_policy_document" "scale_down_common" { + statement { + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + resources = [ + var.github_app_parameters.key_base64.arn, + var.github_app_parameters.id.arn, + ] + } + + dynamic "statement" { + for_each = local.kms_key_arn == "" ? [] : [local.kms_key_arn] + + content { + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [statement.value] + } + } +} + +data "aws_iam_policy_document" "scale_down" { + source_policy_documents = [ + data.aws_iam_policy_document.scale_down_common.json, + local.provider.scale_down.iam_policy_json, + ] +} + +data "aws_iam_policy_document" "scale_down_logging" { + statement { + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + resources = ["${aws_cloudwatch_log_group.scale_down.arn}*"] + } +} diff --git a/modules/runner-stack/scale-down.tf b/modules/runner-stack/scale-down.tf index 2a471839b3..67bf9a4825 100644 --- a/modules/runner-stack/scale-down.tf +++ b/modules/runner-stack/scale-down.tf @@ -102,23 +102,10 @@ resource "aws_iam_role_policy" "scale_down" { policy = data.aws_iam_policy_document.scale_down.json } -data "aws_iam_policy_document" "scale_down" { - source_policy_documents = [ - templatefile("${path.module}/policies/lambda-scale-down.json", { - github_app_id_arn = var.github_app_parameters.id.arn - github_app_key_base64_arn = var.github_app_parameters.key_base64.arn - kms_key_arn = local.kms_key_arn - }), - local.provider.scale_down.iam_policy_json, - ] -} - resource "aws_iam_role_policy" "scale_down_logging" { - name = "logging-policy" - role = aws_iam_role.scale_down.name - policy = templatefile("${path.module}/policies/lambda-cloudwatch.json", { - log_group_arn = aws_cloudwatch_log_group.scale_down.arn - }) + name = "logging-policy" + role = aws_iam_role.scale_down.name + policy = data.aws_iam_policy_document.scale_down_logging.json } resource "aws_iam_role_policy_attachment" "scale_down_vpc_execution_role" { diff --git a/modules/runner-stack/scale-up-policies.tf b/modules/runner-stack/scale-up-policies.tf new file mode 100644 index 0000000000..a53a0ffb5a --- /dev/null +++ b/modules/runner-stack/scale-up-policies.tf @@ -0,0 +1,74 @@ +data "aws_iam_policy_document" "scale_up_common" { + statement { + effect = "Allow" + actions = [ + "ssm:PutParameter", + "ssm:AddTagsToResource", + ] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + resources = [ + var.github_app_parameters.key_base64.arn, + var.github_app_parameters.id.arn, + "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}/*", + ] + } + + statement { + effect = "Allow" + actions = [ + "sqs:ReceiveMessage", + "sqs:GetQueueAttributes", + "sqs:DeleteMessage", + ] + resources = [var.sqs_build_queue.arn] + } + + dynamic "statement" { + for_each = local.kms_key_arn == "" ? [] : [local.kms_key_arn] + + content { + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [statement.value] + } + } +} + +data "aws_iam_policy_document" "scale_up" { + source_policy_documents = [ + data.aws_iam_policy_document.scale_up_common.json, + local.provider.scale_up.iam_policy_json, + ] +} + +data "aws_iam_policy_document" "scale_up_logging" { + statement { + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + resources = ["${aws_cloudwatch_log_group.scale_up.arn}*"] + } +} + +data "aws_iam_policy_document" "scale_up_job_retry_publish" { + count = local.job_retry_enabled ? 1 : 0 + + statement { + effect = "Allow" + actions = [ + "sqs:SendMessage", + "sqs:GetQueueAttributes", + ] + resources = [module.job_retry[0].job_retry_check_queue.arn] + } +} diff --git a/modules/runner-stack/scale-up.tf b/modules/runner-stack/scale-up.tf index 1d2b4970b8..7ef3c3d019 100644 --- a/modules/runner-stack/scale-up.tf +++ b/modules/runner-stack/scale-up.tf @@ -112,25 +112,10 @@ resource "aws_iam_role_policy" "scale_up" { policy = data.aws_iam_policy_document.scale_up.json } -data "aws_iam_policy_document" "scale_up" { - source_policy_documents = [ - templatefile("${path.module}/policies/lambda-scale-up.json", { - sqs_arn = var.sqs_build_queue.arn - github_app_id_arn = var.github_app_parameters.id.arn - github_app_key_base64_arn = var.github_app_parameters.key_base64.arn - ssm_config_path = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" - kms_key_arn = local.kms_key_arn - }), - local.provider.scale_up.iam_policy_json, - ] -} - resource "aws_iam_role_policy" "scale_up_logging" { - name = "logging-policy" - role = aws_iam_role.scale_up.name - policy = templatefile("${path.module}/policies/lambda-cloudwatch.json", { - log_group_arn = aws_cloudwatch_log_group.scale_up.arn - }) + name = "logging-policy" + role = aws_iam_role.scale_up.name + policy = data.aws_iam_policy_document.scale_up_logging.json } resource "aws_iam_role_policy" "service_linked_role" { @@ -160,12 +145,8 @@ resource "aws_iam_role_policy" "scale_up_xray" { } resource "aws_iam_role_policy" "job_retry_sqs_publish" { - count = local.job_retry_enabled ? 1 : 0 - name = "publish-retry-check-sqs-policy" - role = aws_iam_role.scale_up.name - - policy = templatefile("${path.module}/policies/lambda-publish-sqs-policy.json", { - sqs_resource_arns = jsonencode([module.job_retry[0].job_retry_check_queue.arn]) - kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" - }) + count = local.job_retry_enabled ? 1 : 0 + name = "publish-retry-check-sqs-policy" + role = aws_iam_role.scale_up.name + policy = data.aws_iam_policy_document.scale_up_job_retry_publish[0].json } diff --git a/modules/runner-stack/ssm-housekeeper-policies.tf b/modules/runner-stack/ssm-housekeeper-policies.tf new file mode 100644 index 0000000000..450ea3d34a --- /dev/null +++ b/modules/runner-stack/ssm-housekeeper-policies.tf @@ -0,0 +1,23 @@ +data "aws_iam_policy_document" "ssm_housekeeper" { + statement { + effect = "Allow" + actions = [ + "ssm:DeleteParameter", + "ssm:GetParametersByPath", + ] + resources = [ + "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${local.token_path}*", + ] + } +} + +data "aws_iam_policy_document" "ssm_housekeeper_logging" { + statement { + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + resources = ["${aws_cloudwatch_log_group.ssm_housekeeper.arn}*"] + } +} diff --git a/modules/runner-stack/ssm-housekeeper.tf b/modules/runner-stack/ssm-housekeeper.tf index ab2e014232..5b53e8ffe6 100644 --- a/modules/runner-stack/ssm-housekeeper.tf +++ b/modules/runner-stack/ssm-housekeeper.tf @@ -93,19 +93,15 @@ resource "aws_iam_role" "ssm_housekeeper" { } resource "aws_iam_role_policy" "ssm_housekeeper" { - name = "ssm-policy" - role = aws_iam_role.ssm_housekeeper.name - policy = templatefile("${path.module}/policies/lambda-ssm-housekeeper.json", { - ssm_token_path = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${local.token_path}" - }) + name = "ssm-policy" + role = aws_iam_role.ssm_housekeeper.name + policy = data.aws_iam_policy_document.ssm_housekeeper.json } resource "aws_iam_role_policy" "ssm_housekeeper_logging" { - name = "logging-policy" - role = aws_iam_role.ssm_housekeeper.name - policy = templatefile("${path.module}/policies/lambda-cloudwatch.json", { - log_group_arn = aws_cloudwatch_log_group.ssm_housekeeper.arn - }) + name = "logging-policy" + role = aws_iam_role.ssm_housekeeper.name + policy = data.aws_iam_policy_document.ssm_housekeeper_logging.json } resource "aws_iam_role_policy_attachment" "ssm_housekeeper_vpc_execution_role" { From a87d81dcbdcd0d344f03da5c660296feddb58c49 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:47:47 +0000 Subject: [PATCH 10/27] docs: auto update terraform docs --- modules/runner-stack/README.md | 7 +++++++ modules/runner-stack/job-retry/README.md | 1 + modules/runner-stack/pool/README.md | 2 ++ 3 files changed, 10 insertions(+) diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index 1394b136db..254e35b6cb 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -119,7 +119,14 @@ yarn run dist | [aws_iam_policy_document.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.lambda_xray](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_down_common](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.scale_down_logging](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.scale_up_common](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.scale_up_job_retry_publish](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.scale_up_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.ssm_housekeeper_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md index 0ba065e092..1f76daaee4 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/runner-stack/job-retry/README.md @@ -37,6 +37,7 @@ The module is an inner module used by the runner stack when the opt-in feature f | [aws_sqs_queue.job_retry_check_queue](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.job_retry_check_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | | [aws_iam_policy_document.deny_insecure_transport](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs diff --git a/modules/runner-stack/pool/README.md b/modules/runner-stack/pool/README.md index a6962c832b..932eb26341 100644 --- a/modules/runner-stack/pool/README.md +++ b/modules/runner-stack/pool/README.md @@ -42,6 +42,8 @@ No modules. | [aws_iam_policy_document.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.lambda_xray](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.pool_common](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.pool_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.scheduler_assume](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | From 91b924f78c31c92edbb76dce24afeef42fe2d33d Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 12:30:16 +0200 Subject: [PATCH 11/27] refactor(runner-stack): clarify Terraform file names --- modules/runner-stack/{stack-config.tf => common-config.tf} | 2 +- ...iables-compute-provider.tf => compute-provider-variables.tf} | 2 +- modules/runner-stack/job-retry/{policies.tf => iam-policies.tf} | 1 + ...{policies-lambda-common.tf => lambda-common-iam-policies.tf} | 1 + modules/runner-stack/pool/{policies.tf => iam-policies.tf} | 1 + .../runner-stack/{runner-config.tf => runner-ssm-parameters.tf} | 1 + .../{scale-down-policies.tf => scale-down-iam-policies.tf} | 1 + .../{scale-up-policies.tf => scale-up-iam-policies.tf} | 1 + ...-housekeeper-policies.tf => ssm-housekeeper-iam-policies.tf} | 1 + 9 files changed, 9 insertions(+), 2 deletions(-) rename modules/runner-stack/{stack-config.tf => common-config.tf} (93%) rename modules/runner-stack/{variables-compute-provider.tf => compute-provider-variables.tf} (99%) rename modules/runner-stack/job-retry/{policies.tf => iam-policies.tf} (94%) rename modules/runner-stack/{policies-lambda-common.tf => lambda-common-iam-policies.tf} (89%) rename modules/runner-stack/pool/{policies.tf => iam-policies.tf} (95%) rename modules/runner-stack/{runner-config.tf => runner-ssm-parameters.tf} (93%) rename modules/runner-stack/{scale-down-policies.tf => scale-down-iam-policies.tf} (94%) rename modules/runner-stack/{scale-up-policies.tf => scale-up-iam-policies.tf} (96%) rename modules/runner-stack/{ssm-housekeeper-policies.tf => ssm-housekeeper-iam-policies.tf} (90%) diff --git a/modules/runner-stack/stack-config.tf b/modules/runner-stack/common-config.tf similarity index 93% rename from modules/runner-stack/stack-config.tf rename to modules/runner-stack/common-config.tf index f4065df53a..837059a944 100644 --- a/modules/runner-stack/stack-config.tf +++ b/modules/runner-stack/common-config.tf @@ -1,4 +1,4 @@ -# Shared control-plane naming, paths, and tags. +# Shared control-plane configuration: naming, paths, tags, and normalized values. locals { tags = merge( { diff --git a/modules/runner-stack/variables-compute-provider.tf b/modules/runner-stack/compute-provider-variables.tf similarity index 99% rename from modules/runner-stack/variables-compute-provider.tf rename to modules/runner-stack/compute-provider-variables.tf index 53bda5c211..d045021ab3 100644 --- a/modules/runner-stack/variables-compute-provider.tf +++ b/modules/runner-stack/compute-provider-variables.tf @@ -1,4 +1,4 @@ -# Typed boundary between the common control plane and compute implementations. +# Typed input boundary between the common control plane and compute implementations. variable "compute_provider" { description = "Typed compute-provider configuration. Provider-owned settings must remain inside the selected provider block." diff --git a/modules/runner-stack/job-retry/policies.tf b/modules/runner-stack/job-retry/iam-policies.tf similarity index 94% rename from modules/runner-stack/job-retry/policies.tf rename to modules/runner-stack/job-retry/iam-policies.tf index 8f8a912237..f8eca8cebd 100644 --- a/modules/runner-stack/job-retry/policies.tf +++ b/modules/runner-stack/job-retry/iam-policies.tf @@ -1,3 +1,4 @@ +# IAM policies attached to the job-retry Lambda role. data "aws_iam_policy_document" "job_retry" { statement { effect = "Allow" diff --git a/modules/runner-stack/policies-lambda-common.tf b/modules/runner-stack/lambda-common-iam-policies.tf similarity index 89% rename from modules/runner-stack/policies-lambda-common.tf rename to modules/runner-stack/lambda-common-iam-policies.tf index e3ce5c427f..9313557c98 100644 --- a/modules/runner-stack/policies-lambda-common.tf +++ b/modules/runner-stack/lambda-common-iam-policies.tf @@ -1,3 +1,4 @@ +# Shared IAM policies used by the control-plane Lambda functions. data "aws_iam_policy_document" "lambda_assume_role_policy" { statement { actions = ["sts:AssumeRole"] diff --git a/modules/runner-stack/pool/policies.tf b/modules/runner-stack/pool/iam-policies.tf similarity index 95% rename from modules/runner-stack/pool/policies.tf rename to modules/runner-stack/pool/iam-policies.tf index 2d64b7294b..4815b993eb 100644 --- a/modules/runner-stack/pool/policies.tf +++ b/modules/runner-stack/pool/iam-policies.tf @@ -1,3 +1,4 @@ +# IAM policies attached to the pool Lambda role. data "aws_iam_policy_document" "pool_common" { statement { effect = "Allow" diff --git a/modules/runner-stack/runner-config.tf b/modules/runner-stack/runner-ssm-parameters.tf similarity index 93% rename from modules/runner-stack/runner-config.tf rename to modules/runner-stack/runner-ssm-parameters.tf index 2c2de2c203..fbbafcf7cc 100644 --- a/modules/runner-stack/runner-config.tf +++ b/modules/runner-stack/runner-ssm-parameters.tf @@ -1,3 +1,4 @@ +# Shared runner configuration stored in SSM Parameter Store. resource "aws_ssm_parameter" "runner_agent_mode" { name = "${var.ssm_paths.root}/${var.ssm_paths.config}/agent_mode" type = "String" diff --git a/modules/runner-stack/scale-down-policies.tf b/modules/runner-stack/scale-down-iam-policies.tf similarity index 94% rename from modules/runner-stack/scale-down-policies.tf rename to modules/runner-stack/scale-down-iam-policies.tf index 544d6b7c16..b00432f9ef 100644 --- a/modules/runner-stack/scale-down-policies.tf +++ b/modules/runner-stack/scale-down-iam-policies.tf @@ -1,3 +1,4 @@ +# IAM policies attached to the scale-down Lambda role. data "aws_iam_policy_document" "scale_down_common" { statement { effect = "Allow" diff --git a/modules/runner-stack/scale-up-policies.tf b/modules/runner-stack/scale-up-iam-policies.tf similarity index 96% rename from modules/runner-stack/scale-up-policies.tf rename to modules/runner-stack/scale-up-iam-policies.tf index a53a0ffb5a..e2329e8eeb 100644 --- a/modules/runner-stack/scale-up-policies.tf +++ b/modules/runner-stack/scale-up-iam-policies.tf @@ -1,3 +1,4 @@ +# IAM policies attached to the scale-up Lambda role. data "aws_iam_policy_document" "scale_up_common" { statement { effect = "Allow" diff --git a/modules/runner-stack/ssm-housekeeper-policies.tf b/modules/runner-stack/ssm-housekeeper-iam-policies.tf similarity index 90% rename from modules/runner-stack/ssm-housekeeper-policies.tf rename to modules/runner-stack/ssm-housekeeper-iam-policies.tf index 450ea3d34a..7ae3dc1c6e 100644 --- a/modules/runner-stack/ssm-housekeeper-policies.tf +++ b/modules/runner-stack/ssm-housekeeper-iam-policies.tf @@ -1,3 +1,4 @@ +# IAM policies attached to the SSM housekeeper Lambda role. data "aws_iam_policy_document" "ssm_housekeeper" { statement { effect = "Allow" From c983370b6d9b997b82b0dbc35357fd36e4c34716 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 12:37:35 +0200 Subject: [PATCH 12/27] test(multi-runner): assert provider output keys --- .../multi-runner/tests/provider-routing.tftest.hcl | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 6bd2cf6f67..349067db27 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -190,9 +190,6 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( output.runners_map["linux"].provider.type == "ec2" - && can(output.runners_map["linux"].provider.ec2.launch_template) - && can(output.runners_map["linux"].provider.ec2.runners_log_groups) - && can(output.runners_map["linux"].provider.ec2.logfiles) && toset(keys(output.runners_map["linux"].provider.ec2)) == toset([ "launch_template", "runners_log_groups", @@ -204,11 +201,11 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( - !can(output.runners_map["linux"].launch_template_name) - && can(output.runners_map["linux"].role_runner) - && !can(output.runners_map["linux"].provider.ec2.role_runner) - && !can(output.runners_map["linux"].runners_log_groups) - && !can(output.runners_map["linux"].logfiles) + !contains(keys(output.runners_map["linux"]), "launch_template_name") + && contains(keys(output.runners_map["linux"]), "role_runner") + && !contains(keys(output.runners_map["linux"].provider.ec2), "role_runner") + && !contains(keys(output.runners_map["linux"]), "runners_log_groups") + && !contains(keys(output.runners_map["linux"]), "logfiles") ) error_message = "Experimental v2 must expose the common runner role at lane level without duplicating EC2 resources." } From 2a6684d4fbc11156f6f35416352d43110bf27244 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 12:40:05 +0200 Subject: [PATCH 13/27] test(multi-runner): avoid unknown plan assertions --- modules/multi-runner/tests/provider-routing.tftest.hcl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 349067db27..24dae5e7bd 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -316,7 +316,7 @@ run "stable_v1_and_experimental_v2_coexist" { } assert { - condition = output.runners_map["experimental"].provider.type == "ec2" && can(output.runners_map["experimental"].provider.ec2.launch_template) + condition = output.runners_map["experimental"].provider.type == "ec2" && contains(keys(output.runners_map["experimental"].provider.ec2), "launch_template") error_message = "A coexisting v2 lane must retain its nested EC2 provider output." } } From 9c95d0af10ea902c0b3702e370b753aa5d919d07 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 13:17:59 +0200 Subject: [PATCH 14/27] refactor(runner-stack): nest configuration inputs --- modules/lambda/README.md | 12 +- modules/lambda/main.tf | 25 +- modules/lambda/variables.tf | 28 +- modules/multi-runner/README.md | 22 +- modules/multi-runner/multi-runner-config.tf | 188 +++--- modules/multi-runner/runners.tf | 386 ++++++----- .../tests/provider-routing.tftest.hcl | 95 ++- .../multi-runner/variables.experimental.tf | 198 +++--- modules/runner-stack/README.md | 93 +-- modules/runner-stack/common-config.tf | 17 +- .../compute-provider-variables.tf | 91 ++- modules/runner-stack/ec2.tf | 58 +- modules/runner-stack/job-retry.tf | 58 +- modules/runner-stack/job-retry/README.md | 16 +- modules/runner-stack/job-retry/variables.tf | 2 + .../lambda-common-iam-policies.tf | 2 +- modules/runner-stack/pool.tf | 70 +- modules/runner-stack/runner-role.tf | 28 +- modules/runner-stack/runner-ssm-parameters.tf | 16 +- .../runner-stack/scale-down-iam-policies.tf | 4 +- modules/runner-stack/scale-down.tf | 74 +-- modules/runner-stack/scale-up-iam-policies.tf | 8 +- modules/runner-stack/scale-up.tf | 100 +-- modules/runner-stack/ssm-housekeeper.tf | 62 +- .../computed-iam-inputs.tf | 136 ++-- modules/runner-stack/tests/pool.tftest.hcl | 141 ++-- modules/runner-stack/variables.tf | 613 ++++++------------ 27 files changed, 1241 insertions(+), 1302 deletions(-) diff --git a/modules/lambda/README.md b/modules/lambda/README.md index 19e9c2a072..99266e9b63 100644 --- a/modules/lambda/README.md +++ b/modules/lambda/README.md @@ -8,14 +8,14 @@ Generic module to create lambda functions ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.21 | ## Modules @@ -25,7 +25,7 @@ No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_cloudwatch_log_group.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.lambda_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -38,12 +38,12 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [lambda](#input\_lambda) | Configuration for the lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`handler`: The entrypoint for the lambda.
`principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`.
`memory_size`: Memory size limit in MB of the lambda.
`metrics_namespace`: Namespace for the metrics emitted by the lambda.
`name`: The name of the lambda function.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, "aws")
architecture = optional(string, "arm64")
environment_variables = optional(map(string), {})
handler = string
lambda_tags = optional(map(string), {})
log_level = optional(string, "info")
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, 180)
memory_size = optional(number, 256)
metrics_namespace = optional(string, "GitHub Runners")
name = string
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, "nodejs24.x")
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
tags = optional(map(string), {})
timeout = optional(number, 60)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | +| ---- | ----------- | ---- | ------- | :------: | +| [lambda](#input\_lambda) | Configuration for the lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`handler`: The entrypoint for the lambda.
`principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`.
`memory_size`: Memory size limit in MB of the lambda.
`reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
`metrics_namespace`: Namespace for the metrics emitted by the lambda.
`name`: The name of the lambda function.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, "aws")
architecture = optional(string, "arm64")
environment_variables = optional(map(string), {})
handler = string
lambda_tags = optional(map(string), {})
log_level = optional(string, "info")
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, 180)
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, null)
metrics_namespace = optional(string, "GitHub Runners")
name = string
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, "nodejs24.x")
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
tags = optional(map(string), {})
timeout = optional(number, 60)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [lambda](#output\_lambda) | n/a | diff --git a/modules/lambda/main.tf b/modules/lambda/main.tf index 7cc3094f28..ff7cec5de2 100644 --- a/modules/lambda/main.tf +++ b/modules/lambda/main.tf @@ -17,18 +17,19 @@ locals { } resource "aws_lambda_function" "main" { - s3_bucket = var.lambda.s3_bucket != null ? var.lambda.s3_bucket : null - s3_key = var.lambda.s3_key != null ? var.lambda.s3_key : null - s3_object_version = var.lambda.s3_object_version != null ? var.lambda.s3_object_version : null - filename = var.lambda.s3_bucket == null ? var.lambda.zip : null - source_code_hash = var.lambda.s3_bucket == null ? filebase64sha256(var.lambda.zip) : null - function_name = "${var.lambda.prefix}-${var.lambda.name}" - role = aws_iam_role.main.arn - handler = var.lambda.handler - runtime = var.lambda.runtime - timeout = var.lambda.timeout - memory_size = var.lambda.memory_size - architectures = [var.lambda.architecture] + s3_bucket = var.lambda.s3_bucket != null ? var.lambda.s3_bucket : null + s3_key = var.lambda.s3_key != null ? var.lambda.s3_key : null + s3_object_version = var.lambda.s3_object_version != null ? var.lambda.s3_object_version : null + filename = var.lambda.s3_bucket == null ? var.lambda.zip : null + source_code_hash = var.lambda.s3_bucket == null ? filebase64sha256(var.lambda.zip) : null + function_name = "${var.lambda.prefix}-${var.lambda.name}" + role = aws_iam_role.main.arn + handler = var.lambda.handler + runtime = var.lambda.runtime + timeout = var.lambda.timeout + memory_size = var.lambda.memory_size + reserved_concurrent_executions = var.lambda.reserved_concurrent_executions + architectures = [var.lambda.architecture] environment { variables = local.environment_variable diff --git a/modules/lambda/variables.tf b/modules/lambda/variables.tf index a6e27168fa..76416b8bc1 100644 --- a/modules/lambda/variables.tf +++ b/modules/lambda/variables.tf @@ -13,6 +13,7 @@ variable "lambda" { `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. `log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. `memory_size`: Memory size limit in MB of the lambda. + `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation. `metrics_namespace`: Namespace for the metrics emitted by the lambda. `name`: The name of the lambda function. `prefix`: The prefix used for naming resources. @@ -30,19 +31,20 @@ variable "lambda" { `zip`: File location of the lambda zip file. EOF type = object({ - aws_partition = optional(string, "aws") - architecture = optional(string, "arm64") - environment_variables = optional(map(string), {}) - handler = string - lambda_tags = optional(map(string), {}) - log_level = optional(string, "info") - log_class = optional(string, "STANDARD") - logging_kms_key_id = optional(string, null) - logging_retention_in_days = optional(number, 180) - memory_size = optional(number, 256) - metrics_namespace = optional(string, "GitHub Runners") - name = string - prefix = optional(string, null) + aws_partition = optional(string, "aws") + architecture = optional(string, "arm64") + environment_variables = optional(map(string), {}) + handler = string + lambda_tags = optional(map(string), {}) + log_level = optional(string, "info") + log_class = optional(string, "STANDARD") + logging_kms_key_id = optional(string, null) + logging_retention_in_days = optional(number, 180) + memory_size = optional(number, 256) + reserved_concurrent_executions = optional(number, null) + metrics_namespace = optional(string, "GitHub Runners") + name = string + prefix = optional(string, null) principals = optional(list(object({ type = string identifiers = list(string) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 5ab8b8dfdb..d2440f5a4a 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -10,9 +10,9 @@ The module takes a configuration as input containing a matcher for the labels. T The multi-runner module owns provider-neutral lane normalization, queues, shared runner-binary discovery, and webhook routing. Stable `multi_runner_config` lanes continue to use the existing `modules/runners` module at their historical `module.runners["lane"]` addresses. -Lanes in `multi_runner_config_v2` opt into `modules/runner-stack` at `module.runner_stacks["lane"]`. That stack owns common scale-up, scale-down, pool, retry, Lambda roles, and the runner role and attachments. It dispatches typed provider configuration through `provider.type`; the EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. +Lanes in `multi_runner_config_v2` opt into `modules/runner-stack` at `module.runner_stacks["lane"]`. That stack owns common scale-up, scale-down, pool, retry, Lambda roles, and the runner role and attachments. It dispatches typed provider configuration through `compute_provider.type`; the EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. -In v2, common runner-role configuration belongs under `runner.iam`; EC2's optional external instance-profile selection belongs under `provider.ec2.instance_profile`. Provider policy documents are generated internally and attached by the common stack when it creates the role. An external role remains unmanaged and must already contain the required policies. +In v2, common runner-role configuration belongs under `runner.iam`; EC2's optional external instance-profile selection belongs under `compute_provider.ec2.instance_profile`. Provider policy documents are generated internally and attached by the common stack when it creates the role. An external role remains unmanaged and must already contain the required policies. The two input maps can be used in the same module instance during phase 1, provided their lane keys do not overlap. Shared queues, webhook routing, and runner-binary discovery use the union of both maps. A duplicate key is rejected so an existing lane cannot silently switch module address or output contract. @@ -115,7 +115,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -123,14 +123,14 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | -| [random](#provider\_random) | ~> 3.0 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | +| [random](#provider\_random) | 3.9.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -142,7 +142,7 @@ module "multi-runner" { ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -153,7 +153,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | | [ami\_housekeeper\_lambda\_s3\_key](#input\_ami\_housekeeper\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | @@ -192,7 +192,7 @@ module "multi-runner" { | [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, osx, 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\_job\_queued\_check: Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
enable\_on\_demand\_failover\_for\_errors: "Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later."
scale\_errors: "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`."
instance\_type\_priorities: "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
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)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
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."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` disables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_reserved\_concurrent\_executions: "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
enable\_jit\_config: "Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
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."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`."
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."
pool\_config: "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
map(object({
runner_config = object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
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
})), [])
use_dedicated_host = optional(bool, false)
runner_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)
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
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}))
| `{}` | no | -| [multi\_runner\_config\_v2](#input\_multi\_runner\_config\_v2) | Experimental runner lane configuration keyed by lane name. This v2 shape separates common runner routing from provider-specific backend configuration. The schema can change while the provider model is being finalized. V1 and v2 maps can be used together when their lane keys do not overlap.

Each lane has:
- `runner`: GitHub runner behavior shared by all providers.
- `provider`: backend discriminator plus typed provider configuration.
- `queue`: queue and event-source settings for the lane.
- `matcherConfig`: webhook routing labels and priority. |
map(object({
runner = object({
runner_os = string
runner_architecture = string
disable_runner_autoupdate = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_jit_config = optional(bool, null)
enable_organization_runners = optional(bool, false)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
}), {})
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
})

provider = object({
type = string

ec2 = optional(object({
runner_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)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), 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
}])
cloudwatch_config = optional(string, null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
enable_runner_binaries_syncer = optional(bool, true)
enable_runner_detailed_monitoring = optional(bool, false)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
runner_additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = 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
})), [])
use_dedicated_host = optional(bool, false)
runner_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)
runner_ec2_tags = optional(map(string), {})
runner_hook_job_completed = optional(string, "")
runner_hook_job_started = optional(string, "")
userdata_content = optional(string, null)
userdata_post_install = optional(string, "")
userdata_pre_install = optional(string, "")
userdata_template = optional(string, null)
}), null)

# Future provider references only. Do not uncomment until the Terraform
# resources for these lanes are implemented.
#
# microvm = optional(object({
# environment_variables = optional(map(string), {})
# }), null)
})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| `{}` | no | +| [multi\_runner\_config\_v2](#input\_multi\_runner\_config\_v2) | Experimental runner lane configuration keyed by lane name. This v2 shape uses the same canonical ownership groups as runner-stack. The schema can change while the provider model is being finalized. V1 and v2 maps can be used together when their lane keys do not overlap.

Each lane has:
- `runner`: runner identity, registration, runtime, and IAM configuration.
- `github`: GitHub registration scope for the lane.
- `scale_up`, `scale_down`, `pool`, and `job_retry`: control-plane behavior.
- `compute_provider`: backend discriminator plus typed provider configuration.
- `queue`: queue and event-source settings for the lane.
- `matcherConfig`: webhook routing labels and priority. |
map(object({
runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = number
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}), {})

scale_up = optional(object({
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
}), {})

scale_down = optional(object({
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
}), {})

pool = optional(object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
runner_owner = optional(string, null)
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

compute_provider = object({
type = string

ec2 = optional(object({
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)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), 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
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
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)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = 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
})), [])
use_dedicated_host = optional(bool, false)
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)
tags = optional(map(string), {})
}), null)

# Future provider references only. Do not uncomment until the Terraform
# resources for these lanes are implemented.
#
# microvm = optional(object({
# environment_variables = optional(map(string), {})
# }), null)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| `{}` | no | | [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | | [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | @@ -237,7 +237,7 @@ module "multi-runner" { ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | diff --git a/modules/multi-runner/multi-runner-config.tf b/modules/multi-runner/multi-runner-config.tf index 9e92ac70fc..b201e8d284 100644 --- a/modules/multi-runner/multi-runner-config.tf +++ b/modules/multi-runner/multi-runner-config.tf @@ -1,27 +1,26 @@ locals { + # Stable v1 remains an external flat contract. Normalize it once so common + # multi-runner consumers can use the same ownership model as experimental v2. multi_runner_config_v1_as_v2 = { for k, v in var.multi_runner_config : k => { runner = { - runner_os = v.runner_config.runner_os - runner_architecture = v.runner_config.runner_architecture - disable_runner_autoupdate = v.runner_config.disable_runner_autoupdate - enable_ephemeral_runners = v.runner_config.enable_ephemeral_runners - enable_job_queued_check = v.runner_config.enable_job_queued_check - enable_jit_config = v.runner_config.enable_jit_config - enable_organization_runners = v.runner_config.enable_organization_runners - idle_config = v.runner_config.idle_config - minimum_running_time_in_minutes = v.runner_config.minimum_running_time_in_minutes - pool_runner_owner = v.runner_config.pool_runner_owner - runner_as_root = v.runner_config.runner_as_root - runner_boot_time_in_minutes = v.runner_config.runner_boot_time_in_minutes - runner_disable_default_labels = v.runner_config.runner_disable_default_labels - runner_extra_labels = v.runner_config.runner_extra_labels - runner_group_name = v.runner_config.runner_group_name - runner_name_prefix = v.runner_config.runner_name_prefix - runner_run_as = v.runner_config.runner_run_as - runners_maximum_count = v.runner_config.runners_maximum_count - scale_down_schedule_expression = v.runner_config.scale_down_schedule_expression - scale_up_reserved_concurrent_executions = v.runner_config.scale_up_reserved_concurrent_executions + os = v.runner_config.runner_os + architecture = v.runner_config.runner_architecture + boot_time_in_minutes = v.runner_config.runner_boot_time_in_minutes + disable_default_labels = v.runner_config.runner_disable_default_labels + extra_labels = v.runner_config.runner_extra_labels + group_name = v.runner_config.runner_group_name + name_prefix = v.runner_config.runner_name_prefix + run_as_root = v.runner_config.runner_as_root + run_as = v.runner_config.runner_run_as + maximum_count = v.runner_config.runners_maximum_count + ephemeral = v.runner_config.enable_ephemeral_runners + jit_config_enabled = v.runner_config.enable_jit_config + auto_update_disabled = v.runner_config.disable_runner_autoupdate + hooks = { + job_started = v.runner_config.runner_hook_job_started + job_completed = v.runner_config.runner_hook_job_completed + } iam = { role = v.runner_config.iam_overrides.override_runner_role == true ? { arn = v.runner_config.iam_overrides.runner_role_arn @@ -30,37 +29,88 @@ locals { for policy_index, policy_arn in v.runner_config.runner_iam_role_managed_policy_arns : "legacy-${policy_index}" => policy_arn } + path = var.role_path + permissions_boundary = var.role_permissions_boundary + } + } + + github = { + organization_runners = v.runner_config.enable_organization_runners + } + + queue = { + delay_webhook_event = v.runner_config.delay_webhook_event + job_queue_retention_in_seconds = v.runner_config.job_queue_retention_in_seconds + event_source_mapping = { + batch_size = v.runner_config.lambda_event_source_mapping_batch_size + maximum_batching_window_in_seconds = v.runner_config.lambda_event_source_mapping_maximum_batching_window_in_seconds + } + redrive_build_queue = v.redrive_build_queue + } + + scale_up = { + reserved_concurrent_executions = v.runner_config.scale_up_reserved_concurrent_executions + job_queued_check_enabled = v.runner_config.enable_job_queued_check + } + + scale_down = { + schedule_expression = v.runner_config.scale_down_schedule_expression + minimum_running_time_in_minutes = v.runner_config.minimum_running_time_in_minutes + idle_config = v.runner_config.idle_config + } + + pool = { + config = v.runner_config.pool_config + runner_owner = v.runner_config.pool_runner_owner + } + + job_retry = { + enabled = v.runner_config.job_retry.enable + delay_in_seconds = v.runner_config.job_retry.delay_in_seconds + delay_backoff = v.runner_config.job_retry.delay_backoff + max_attempts = v.runner_config.job_retry.max_attempts + lambda = { + memory_size = v.runner_config.job_retry.lambda_memory_size + timeout = v.runner_config.job_retry.lambda_timeout + reserved_concurrent_executions = 1 } - pool_config = v.runner_config.pool_config - job_retry = v.runner_config.job_retry } - provider = { + compute_provider = { type = "ec2" ec2 = { - runner_metadata_options = v.runner_config.runner_metadata_options - ami = v.runner_config.ami - block_device_mappings = v.runner_config.block_device_mappings - cloudwatch_config = v.runner_config.cloudwatch_config - create_service_linked_role_spot = v.runner_config.create_service_linked_role_spot - credit_specification = v.runner_config.credit_specification - ebs_optimized = v.runner_config.ebs_optimized - enable_cloudwatch_agent = v.runner_config.enable_cloudwatch_agent - enable_runner_binaries_syncer = v.runner_config.enable_runner_binaries_syncer - enable_runner_detailed_monitoring = v.runner_config.enable_runner_detailed_monitoring - enable_ssm_on_runners = v.runner_config.enable_ssm_on_runners - enable_userdata = v.runner_config.enable_userdata - instance_allocation_strategy = v.runner_config.instance_allocation_strategy - instance_max_spot_price = v.runner_config.instance_max_spot_price - instance_target_capacity_type = v.runner_config.instance_target_capacity_type - instance_type_priorities = v.runner_config.instance_type_priorities - instance_types = v.runner_config.instance_types - runner_additional_security_group_ids = v.runner_config.runner_additional_security_group_ids + metadata_options = v.runner_config.runner_metadata_options + ami = v.runner_config.ami + block_device_mappings = v.runner_config.block_device_mappings + create_service_linked_role_spot = v.runner_config.create_service_linked_role_spot + credit_specification = v.runner_config.credit_specification + ebs_optimized = v.runner_config.ebs_optimized + cloudwatch_agent = { + enabled = v.runner_config.enable_cloudwatch_agent + config = v.runner_config.cloudwatch_config + } + binaries_syncer = { + enabled = v.runner_config.enable_runner_binaries_syncer + } + detailed_monitoring_enabled = v.runner_config.enable_runner_detailed_monitoring + ssm_enabled = v.runner_config.enable_ssm_on_runners + user_data = { + enabled = v.runner_config.enable_userdata + template = v.runner_config.userdata_template + content = v.runner_config.userdata_content + pre_install = v.runner_config.userdata_pre_install + post_install = v.runner_config.userdata_post_install + debug_logging_enabled = false + } + instance_allocation_strategy = v.runner_config.instance_allocation_strategy + instance_max_spot_price = v.runner_config.instance_max_spot_price + instance_target_capacity_type = v.runner_config.instance_target_capacity_type + instance_type_priorities = v.runner_config.instance_type_priorities + instance_types = v.runner_config.instance_types + additional_security_group_ids = v.runner_config.runner_additional_security_group_ids instance_profile = v.runner_config.iam_overrides.override_instance_profile == true ? { name = v.runner_config.iam_overrides.instance_profile_name } : null - runner_iam_role_managed_policy_arns = v.runner_config.runner_iam_role_managed_policy_arns - iam_overrides = v.runner_config.iam_overrides enable_on_demand_failover_for_errors = v.runner_config.enable_on_demand_failover_for_errors scale_errors = v.runner_config.scale_errors subnet_ids = v.runner_config.subnet_ids @@ -69,25 +119,11 @@ locals { placement = v.runner_config.placement license_specifications = v.runner_config.license_specifications use_dedicated_host = v.runner_config.use_dedicated_host - runner_log_files = v.runner_config.runner_log_files - runner_ec2_tags = v.runner_config.runner_ec2_tags - runner_hook_job_completed = v.runner_config.runner_hook_job_completed - runner_hook_job_started = v.runner_config.runner_hook_job_started - userdata_content = v.runner_config.userdata_content - userdata_post_install = v.runner_config.userdata_post_install - userdata_pre_install = v.runner_config.userdata_pre_install - userdata_template = v.runner_config.userdata_template + log_files = v.runner_config.runner_log_files + tags = v.runner_config.runner_ec2_tags } } - queue = { - delay_webhook_event = v.runner_config.delay_webhook_event - job_queue_retention_in_seconds = v.runner_config.job_queue_retention_in_seconds - lambda_event_source_mapping_batch_size = v.runner_config.lambda_event_source_mapping_batch_size - lambda_event_source_mapping_maximum_batching_window_in_seconds = v.runner_config.lambda_event_source_mapping_maximum_batching_window_in_seconds - redrive_build_queue = v.redrive_build_queue - } - matcherConfig = v.matcherConfig } } @@ -103,7 +139,7 @@ locals { multi_runner_config = merge(local.multi_runner_config_v1_as_v2, var.multi_runner_config_v2) runner_extra_labels = { - for k, v in local.multi_runner_config : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.runner_extra_labels))) + for k, v in local.multi_runner_config : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.extra_labels))) } runner_config = { @@ -111,35 +147,21 @@ locals { id = aws_sqs_queue.queued_builds[k].id arn = aws_sqs_queue.queued_builds[k].arn url = aws_sqs_queue.queued_builds[k].url - runnerProvider = lower(trimspace(v.provider.type)) - runner = merge(v.runner, { runner_extra_labels = local.runner_extra_labels[k] }) + runnerProvider = lower(trimspace(v.compute_provider.type)) + runner = merge(v.runner, { extra_labels = local.runner_extra_labels[k] }) }) } # Keep stable v1 lanes at their historical module.runners addresses while # v2 lanes opt into the provider-oriented runner stack. runner_config_v1 = { - for k, v in local.multi_runner_config_v1_as_v2 : k => merge(v, { - id = aws_sqs_queue.queued_builds[k].id - arn = aws_sqs_queue.queued_builds[k].arn - url = aws_sqs_queue.queued_builds[k].url - runnerProvider = lower(trimspace(v.provider.type)) - runner = merge(v.runner, { - runner_extra_labels = sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.runner_extra_labels))) - }) - }) + for k, v in local.runner_config : k => v + if contains(keys(var.multi_runner_config), k) } runner_config_v2 = { - for k, v in var.multi_runner_config_v2 : k => merge(v, { - id = aws_sqs_queue.queued_builds[k].id - arn = aws_sqs_queue.queued_builds[k].arn - url = aws_sqs_queue.queued_builds[k].url - runnerProvider = lower(trimspace(v.provider.type)) - runner = merge(v.runner, { - runner_extra_labels = sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.runner_extra_labels))) - }) - }) + for k, v in local.runner_config : k => v + if contains(keys(var.multi_runner_config_v2), k) } runner_matcher_config = { @@ -160,10 +182,10 @@ locals { tmp_distinct_list_unique_os_and_arch = distinct([ for _, config in local.runner_config_by_provider.ec2 : { - "os_type" : config.runner.runner_os, - "architecture" : config.runner.runner_architecture + "os_type" : config.runner.os, + "architecture" : config.runner.architecture } - if config.provider.ec2.enable_runner_binaries_syncer + if config.compute_provider.ec2.binaries_syncer.enabled ]) unique_os_and_arch = { for _, v in local.tmp_distinct_list_unique_os_and_arch : "${v.os_type}_${v.architecture}" => v } } diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index cfc9ed17e5..511b3eb876 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -4,14 +4,14 @@ module "runners" { aws_region = var.aws_region aws_partition = var.aws_partition - vpc_id = coalesce(each.value.provider.ec2.vpc_id, var.vpc_id) - subnet_ids = coalesce(each.value.provider.ec2.subnet_ids, var.subnet_ids) + vpc_id = coalesce(each.value.compute_provider.ec2.vpc_id, var.vpc_id) + subnet_ids = coalesce(each.value.compute_provider.ec2.subnet_ids, var.subnet_ids) prefix = "${var.prefix}-${each.key}" tags = merge(local.tags, { "ghr:environment" = "${var.prefix}-${each.key}" }) - s3_runner_binaries = each.value.provider.ec2.enable_runner_binaries_syncer ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.runner_os}_${each.value.runner.runner_architecture}"] : null + s3_runner_binaries = each.value.compute_provider.ec2.binaries_syncer.enabled ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.os}_${each.value.runner.architecture}"] : null ssm_paths = { root = "${local.ssm_root_path}/${each.key}" @@ -19,49 +19,49 @@ module "runners" { config = "${var.ssm_paths.runners}/config" } - runner_os = each.value.runner.runner_os - instance_types = each.value.provider.ec2.instance_types - instance_target_capacity_type = each.value.provider.ec2.instance_target_capacity_type - instance_allocation_strategy = each.value.provider.ec2.instance_allocation_strategy - instance_type_priorities = each.value.provider.ec2.instance_type_priorities - instance_max_spot_price = each.value.provider.ec2.instance_max_spot_price - block_device_mappings = each.value.provider.ec2.block_device_mappings + runner_os = each.value.runner.os + instance_types = each.value.compute_provider.ec2.instance_types + instance_target_capacity_type = each.value.compute_provider.ec2.instance_target_capacity_type + instance_allocation_strategy = each.value.compute_provider.ec2.instance_allocation_strategy + instance_type_priorities = each.value.compute_provider.ec2.instance_type_priorities + instance_max_spot_price = each.value.compute_provider.ec2.instance_max_spot_price + block_device_mappings = each.value.compute_provider.ec2.block_device_mappings - runner_architecture = each.value.runner.runner_architecture - ami = each.value.provider.ec2.ami + runner_architecture = each.value.runner.architecture + ami = each.value.compute_provider.ec2.ami sqs_build_queue = { "arn" : each.value.arn, "url" : each.value.url } github_app_parameters = local.github_app_parameters - ebs_optimized = each.value.provider.ec2.ebs_optimized - enable_on_demand_failover_for_errors = each.value.provider.ec2.enable_on_demand_failover_for_errors - scale_errors = each.value.provider.ec2.scale_errors - enable_organization_runners = each.value.runner.enable_organization_runners - enable_ephemeral_runners = each.value.runner.enable_ephemeral_runners - enable_jit_config = each.value.runner.enable_jit_config - enable_job_queued_check = each.value.runner.enable_job_queued_check - disable_runner_autoupdate = each.value.runner.disable_runner_autoupdate + ebs_optimized = each.value.compute_provider.ec2.ebs_optimized + enable_on_demand_failover_for_errors = each.value.compute_provider.ec2.enable_on_demand_failover_for_errors + scale_errors = each.value.compute_provider.ec2.scale_errors + enable_organization_runners = each.value.github.organization_runners + enable_ephemeral_runners = each.value.runner.ephemeral + enable_jit_config = each.value.runner.jit_config_enabled + enable_job_queued_check = each.value.scale_up.job_queued_check_enabled + disable_runner_autoupdate = each.value.runner.auto_update_disabled enable_managed_runner_security_group = var.enable_managed_runner_security_group - enable_runner_detailed_monitoring = each.value.provider.ec2.enable_runner_detailed_monitoring - scale_down_schedule_expression = each.value.runner.scale_down_schedule_expression - minimum_running_time_in_minutes = each.value.runner.minimum_running_time_in_minutes - runner_boot_time_in_minutes = each.value.runner.runner_boot_time_in_minutes - runner_disable_default_labels = each.value.runner.runner_disable_default_labels - runner_labels = each.value.runner.runner_disable_default_labels ? sort(distinct(each.value.runner.runner_extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.runner_os, each.value.runner.runner_architecture], each.value.runner.runner_extra_labels))) - runner_as_root = each.value.runner.runner_as_root - runner_run_as = each.value.runner.runner_run_as - runners_maximum_count = each.value.runner.runners_maximum_count - idle_config = each.value.runner.idle_config - enable_ssm_on_runners = each.value.provider.ec2.enable_ssm_on_runners + enable_runner_detailed_monitoring = each.value.compute_provider.ec2.detailed_monitoring_enabled + scale_down_schedule_expression = each.value.scale_down.schedule_expression + minimum_running_time_in_minutes = each.value.scale_down.minimum_running_time_in_minutes + runner_boot_time_in_minutes = each.value.runner.boot_time_in_minutes + runner_disable_default_labels = each.value.runner.disable_default_labels + runner_labels = each.value.runner.disable_default_labels ? sort(distinct(each.value.runner.extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.os, each.value.runner.architecture], each.value.runner.extra_labels))) + runner_as_root = each.value.runner.run_as_root + runner_run_as = each.value.runner.run_as + runners_maximum_count = each.value.runner.maximum_count + idle_config = each.value.scale_down.idle_config + enable_ssm_on_runners = each.value.compute_provider.ec2.ssm_enabled egress_rules = var.runner_egress_rules - runner_additional_security_group_ids = try(coalescelist(each.value.provider.ec2.runner_additional_security_group_ids, var.runner_additional_security_group_ids), []) - metadata_options = each.value.provider.ec2.runner_metadata_options - credit_specification = each.value.provider.ec2.credit_specification - cpu_options = each.value.provider.ec2.cpu_options - placement = each.value.provider.ec2.placement - license_specifications = each.value.provider.ec2.license_specifications - use_dedicated_host = each.value.provider.ec2.use_dedicated_host - - enable_runner_binaries_syncer = each.value.provider.ec2.enable_runner_binaries_syncer + runner_additional_security_group_ids = try(coalescelist(each.value.compute_provider.ec2.additional_security_group_ids, var.runner_additional_security_group_ids), []) + metadata_options = each.value.compute_provider.ec2.metadata_options + credit_specification = each.value.compute_provider.ec2.credit_specification + cpu_options = each.value.compute_provider.ec2.cpu_options + placement = each.value.compute_provider.ec2.placement + license_specifications = each.value.compute_provider.ec2.license_specifications + use_dedicated_host = each.value.compute_provider.ec2.use_dedicated_host + + enable_runner_binaries_syncer = each.value.compute_provider.ec2.binaries_syncer.enabled lambda_s3_bucket = var.lambda_s3_bucket runners_lambda_s3_key = var.runners_lambda_s3_key runners_lambda_s3_object_version = var.runners_lambda_s3_object_version @@ -69,8 +69,8 @@ module "runners" { lambda_architecture = var.lambda_architecture lambda_zip = var.runners_lambda_zip lambda_scale_up_memory_size = var.scale_up_lambda_memory_size - lambda_event_source_mapping_batch_size = coalesce(each.value.queue.lambda_event_source_mapping_batch_size, var.lambda_event_source_mapping_batch_size) - lambda_event_source_mapping_maximum_batching_window_in_seconds = coalesce(each.value.queue.lambda_event_source_mapping_maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) + lambda_event_source_mapping_batch_size = coalesce(each.value.queue.event_source_mapping.batch_size, var.lambda_event_source_mapping_batch_size) + lambda_event_source_mapping_maximum_batching_window_in_seconds = coalesce(each.value.queue.event_source_mapping.maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) lambda_timeout_scale_up = var.runners_scale_up_lambda_timeout lambda_scale_down_memory_size = var.scale_down_lambda_memory_size lambda_timeout_scale_down = var.runners_scale_down_lambda_timeout @@ -81,33 +81,35 @@ module "runners" { logging_retention_in_days = var.logging_retention_in_days logging_kms_key_id = var.logging_kms_key_id log_class = var.log_class - enable_cloudwatch_agent = each.value.provider.ec2.enable_cloudwatch_agent - cloudwatch_config = try(coalesce(each.value.provider.ec2.cloudwatch_config, var.cloudwatch_config), null) - runner_log_files = each.value.provider.ec2.runner_log_files - runner_group_name = each.value.runner.runner_group_name - runner_name_prefix = each.value.runner.runner_name_prefix + enable_cloudwatch_agent = each.value.compute_provider.ec2.cloudwatch_agent.enabled + cloudwatch_config = try(coalesce(each.value.compute_provider.ec2.cloudwatch_agent.config, var.cloudwatch_config), null) + runner_log_files = each.value.compute_provider.ec2.log_files + runner_group_name = each.value.runner.group_name + runner_name_prefix = each.value.runner.name_prefix parameter_store_tags = var.parameter_store_tags - scale_up_reserved_concurrent_executions = each.value.runner.scale_up_reserved_concurrent_executions + scale_up_reserved_concurrent_executions = each.value.scale_up.reserved_concurrent_executions instance_profile_path = var.instance_profile_path role_path = var.role_path role_permissions_boundary = var.role_permissions_boundary - enable_userdata = each.value.provider.ec2.enable_userdata - userdata_template = each.value.provider.ec2.userdata_template - userdata_content = each.value.provider.ec2.userdata_content - userdata_pre_install = each.value.provider.ec2.userdata_pre_install - userdata_post_install = each.value.provider.ec2.userdata_post_install - runner_hook_job_started = each.value.provider.ec2.runner_hook_job_started - runner_hook_job_completed = each.value.provider.ec2.runner_hook_job_completed + enable_userdata = each.value.compute_provider.ec2.user_data.enabled + userdata_template = each.value.compute_provider.ec2.user_data.template + userdata_content = each.value.compute_provider.ec2.user_data.content + userdata_pre_install = each.value.compute_provider.ec2.user_data.pre_install + userdata_post_install = each.value.compute_provider.ec2.user_data.post_install + runner_hook_job_started = each.value.runner.hooks.job_started + runner_hook_job_completed = each.value.runner.hooks.job_completed key_name = var.key_name - runner_ec2_tags = each.value.provider.ec2.runner_ec2_tags + runner_ec2_tags = each.value.compute_provider.ec2.tags - create_service_linked_role_spot = each.value.provider.ec2.create_service_linked_role_spot + create_service_linked_role_spot = each.value.compute_provider.ec2.create_service_linked_role_spot - runner_iam_role_managed_policy_arns = each.value.provider.ec2.runner_iam_role_managed_policy_arns - iam_overrides = each.value.provider.ec2.iam_overrides + # Preserve stable v1 values verbatim rather than reconstructing legacy IAM + # inputs from the canonical normalized representation. + runner_iam_role_managed_policy_arns = var.multi_runner_config[each.key].runner_config.runner_iam_role_managed_policy_arns + iam_overrides = var.multi_runner_config[each.key].runner_config.iam_overrides ghes_url = var.ghes_url ghes_ssl_verify = var.ghes_ssl_verify @@ -117,15 +119,15 @@ module "runners" { log_level = var.log_level - pool_config = each.value.runner.pool_config + pool_config = each.value.pool.config pool_lambda_timeout = var.pool_lambda_timeout - pool_runner_owner = each.value.runner.pool_runner_owner + pool_runner_owner = each.value.pool.runner_owner pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions associate_public_ipv4_address = var.associate_public_ipv4_address ssm_housekeeper = var.runners_ssm_housekeeper - job_retry = each.value.runner.job_retry + job_retry = var.multi_runner_config[each.key].runner_config.job_retry metrics = var.metrics } @@ -141,128 +143,168 @@ module "runner_stacks" { "ghr:environment" = "${var.prefix}-${each.key}" }) - compute_provider = { - type = each.value.runnerProvider - ec2 = { - ami = each.value.provider.ec2.ami - vpc_id = coalesce(each.value.provider.ec2.vpc_id, var.vpc_id) - subnet_ids = coalesce(each.value.provider.ec2.subnet_ids, var.subnet_ids) - instance_types = each.value.provider.ec2.instance_types - instance_target_capacity_type = each.value.provider.ec2.instance_target_capacity_type - instance_allocation_strategy = each.value.provider.ec2.instance_allocation_strategy - instance_type_priorities = each.value.provider.ec2.instance_type_priorities - instance_max_spot_price = each.value.provider.ec2.instance_max_spot_price - block_device_mappings = each.value.provider.ec2.block_device_mappings - ebs_optimized = each.value.provider.ec2.ebs_optimized - instance_profile = each.value.provider.ec2.instance_profile - instance_profile_path = var.instance_profile_path - enable_on_demand_failover_for_errors = each.value.provider.ec2.enable_on_demand_failover_for_errors - scale_errors = each.value.provider.ec2.scale_errors - enable_managed_runner_security_group = var.enable_managed_runner_security_group - enable_runner_detailed_monitoring = each.value.provider.ec2.enable_runner_detailed_monitoring - enable_ssm_on_runners = each.value.provider.ec2.enable_ssm_on_runners - egress_rules = var.runner_egress_rules - runner_additional_security_group_ids = try(coalescelist(each.value.provider.ec2.runner_additional_security_group_ids, var.runner_additional_security_group_ids), []) - metadata_options = each.value.provider.ec2.runner_metadata_options - credit_specification = each.value.provider.ec2.credit_specification - cpu_options = each.value.provider.ec2.cpu_options - placement = each.value.provider.ec2.placement - license_specifications = each.value.provider.ec2.license_specifications - use_dedicated_host = each.value.provider.ec2.use_dedicated_host - enable_runner_binaries_syncer = each.value.provider.ec2.enable_runner_binaries_syncer - s3_runner_binaries = each.value.provider.ec2.enable_runner_binaries_syncer ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.runner_os}_${each.value.runner.runner_architecture}"] : null - enable_cloudwatch_agent = each.value.provider.ec2.enable_cloudwatch_agent - cloudwatch_config = try(coalesce(each.value.provider.ec2.cloudwatch_config, var.cloudwatch_config), null) - runner_log_files = each.value.provider.ec2.runner_log_files - enable_userdata = each.value.provider.ec2.enable_userdata - userdata_template = each.value.provider.ec2.userdata_template - userdata_content = each.value.provider.ec2.userdata_content - userdata_pre_install = each.value.provider.ec2.userdata_pre_install - userdata_post_install = each.value.provider.ec2.userdata_post_install - runner_hook_job_started = each.value.provider.ec2.runner_hook_job_started - runner_hook_job_completed = each.value.provider.ec2.runner_hook_job_completed - key_name = var.key_name - runner_ec2_tags = each.value.provider.ec2.runner_ec2_tags - create_service_linked_role_spot = each.value.provider.ec2.create_service_linked_role_spot - associate_public_ipv4_address = var.associate_public_ipv4_address + runner = { + os = each.value.runner.os + architecture = each.value.runner.architecture + boot_time_in_minutes = each.value.runner.boot_time_in_minutes + disable_default_labels = each.value.runner.disable_default_labels + labels = each.value.runner.disable_default_labels ? sort(distinct(each.value.runner.extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.os, each.value.runner.architecture], each.value.runner.extra_labels))) + group_name = each.value.runner.group_name + name_prefix = each.value.runner.name_prefix + run_as_root = each.value.runner.run_as_root + run_as = each.value.runner.run_as + maximum_count = each.value.runner.maximum_count + ephemeral = each.value.runner.ephemeral + jit_config_enabled = each.value.runner.jit_config_enabled + auto_update_disabled = each.value.runner.auto_update_disabled + hooks = each.value.runner.hooks + iam = { + role = each.value.runner.iam.role + managed_policy_arns = each.value.runner.iam.managed_policy_arns + path = each.value.runner.iam.path != null ? each.value.runner.iam.path : var.role_path + permissions_boundary = each.value.runner.iam.permissions_boundary != null ? each.value.runner.iam.permissions_boundary : var.role_permissions_boundary } } - runner_iam = { - role = each.value.runner.iam.role - managed_policy_arns = each.value.runner.iam.managed_policy_arns + github = { + app_parameters = local.github_app_parameters + organization_runners = each.value.github.organization_runners + enterprise_server = { + url = var.ghes_url + ssl_verify = var.ghes_ssl_verify + } + user_agent = var.user_agent } - ssm_paths = { - root = "${local.ssm_root_path}/${each.key}" - tokens = "${var.ssm_paths.runners}/tokens" - config = "${var.ssm_paths.runners}/config" + queue = { + build = { + arn = each.value.arn + url = each.value.url + } + event_source_mapping = { + batch_size = coalesce(each.value.queue.event_source_mapping.batch_size, var.lambda_event_source_mapping_batch_size) + maximum_batching_window_in_seconds = coalesce(each.value.queue.event_source_mapping.maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) + } } - runner_os = each.value.runner.runner_os - runner_architecture = each.value.runner.runner_architecture - - sqs_build_queue = { "arn" : each.value.arn, "url" : each.value.url } - github_app_parameters = local.github_app_parameters - enable_organization_runners = each.value.runner.enable_organization_runners - enable_ephemeral_runners = each.value.runner.enable_ephemeral_runners - enable_jit_config = each.value.runner.enable_jit_config - enable_job_queued_check = each.value.runner.enable_job_queued_check - disable_runner_autoupdate = each.value.runner.disable_runner_autoupdate - scale_down_schedule_expression = each.value.runner.scale_down_schedule_expression - minimum_running_time_in_minutes = each.value.runner.minimum_running_time_in_minutes - runner_boot_time_in_minutes = each.value.runner.runner_boot_time_in_minutes - runner_disable_default_labels = each.value.runner.runner_disable_default_labels - runner_labels = each.value.runner.runner_disable_default_labels ? sort(distinct(each.value.runner.runner_extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.runner_os, each.value.runner.runner_architecture], each.value.runner.runner_extra_labels))) - runner_as_root = each.value.runner.runner_as_root - runner_run_as = each.value.runner.runner_run_as - runners_maximum_count = each.value.runner.runners_maximum_count - idle_config = each.value.runner.idle_config - - lambda_s3_bucket = var.lambda_s3_bucket - runners_lambda_s3_key = var.runners_lambda_s3_key - runners_lambda_s3_object_version = var.runners_lambda_s3_object_version - lambda_runtime = var.lambda_runtime - lambda_architecture = var.lambda_architecture - lambda_zip = var.runners_lambda_zip - lambda_scale_up_memory_size = var.scale_up_lambda_memory_size - lambda_event_source_mapping_batch_size = coalesce(each.value.queue.lambda_event_source_mapping_batch_size, var.lambda_event_source_mapping_batch_size) - lambda_event_source_mapping_maximum_batching_window_in_seconds = coalesce(each.value.queue.lambda_event_source_mapping_maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) - lambda_timeout_scale_up = var.runners_scale_up_lambda_timeout - lambda_scale_down_memory_size = var.scale_down_lambda_memory_size - lambda_timeout_scale_down = var.runners_scale_down_lambda_timeout - lambda_subnet_ids = var.lambda_subnet_ids - lambda_security_group_ids = var.lambda_security_group_ids - lambda_tags = var.lambda_tags - tracing_config = var.tracing_config - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id - log_class = var.log_class - runner_group_name = each.value.runner.runner_group_name - runner_name_prefix = each.value.runner.runner_name_prefix - parameter_store_tags = var.parameter_store_tags - - scale_up_reserved_concurrent_executions = each.value.runner.scale_up_reserved_concurrent_executions + lambda = { + zip = var.runners_lambda_zip + s3 = { + bucket = var.lambda_s3_bucket + key = var.runners_lambda_s3_key + object_version = var.runners_lambda_s3_object_version + } + runtime = var.lambda_runtime + architecture = var.lambda_architecture + subnet_ids = var.lambda_subnet_ids + security_group_ids = var.lambda_security_group_ids + tags = var.lambda_tags + role = { + path = var.role_path + permissions_boundary = var.role_permissions_boundary + } + } - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary + scale_up = { + memory_size = var.scale_up_lambda_memory_size + timeout = var.runners_scale_up_lambda_timeout + reserved_concurrent_executions = each.value.scale_up.reserved_concurrent_executions + job_queued_check_enabled = each.value.scale_up.job_queued_check_enabled + } - ghes_url = var.ghes_url - ghes_ssl_verify = var.ghes_ssl_verify - user_agent = var.user_agent + scale_down = { + memory_size = var.scale_down_lambda_memory_size + timeout = var.runners_scale_down_lambda_timeout + schedule_expression = each.value.scale_down.schedule_expression + minimum_running_time_in_minutes = each.value.scale_down.minimum_running_time_in_minutes + idle_config = each.value.scale_down.idle_config + } - kms_key_arn = var.kms_key_arn + pool = { + config = each.value.pool.config + include_busy_runners = false + runner_owner = each.value.pool.runner_owner + lambda = { + timeout = var.pool_lambda_timeout + reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions + } + } - log_level = var.log_level + job_retry = each.value.job_retry - pool_config = each.value.runner.pool_config - pool_lambda_timeout = var.pool_lambda_timeout - pool_runner_owner = each.value.runner.pool_runner_owner - pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions - - ssm_housekeeper = var.runners_ssm_housekeeper + ssm = { + paths = { + root = "${local.ssm_root_path}/${each.key}" + tokens = "${var.ssm_paths.runners}/tokens" + config = "${var.ssm_paths.runners}/config" + } + kms_key_arn = var.kms_key_arn + parameter_tags = var.parameter_store_tags + housekeeper = { + schedule_expression = var.runners_ssm_housekeeper.schedule_expression + state = var.runners_ssm_housekeeper.enabled ? "ENABLED" : "DISABLED" + lambda = { + memory_size = var.runners_ssm_housekeeper.lambda_memory_size + timeout = var.runners_ssm_housekeeper.lambda_timeout + } + config = var.runners_ssm_housekeeper.config + } + } - job_retry = each.value.runner.job_retry + observability = { + log_level = var.log_level + logs = { + retention_in_days = var.logging_retention_in_days + kms_key_id = var.logging_kms_key_id + class = var.log_class + } + tracing = var.tracing_config + metrics = var.metrics + } - metrics = var.metrics + compute_provider = { + type = each.value.runnerProvider + ec2 = { + ami = each.value.compute_provider.ec2.ami + vpc_id = coalesce(each.value.compute_provider.ec2.vpc_id, var.vpc_id) + subnet_ids = coalesce(each.value.compute_provider.ec2.subnet_ids, var.subnet_ids) + instance_types = each.value.compute_provider.ec2.instance_types + instance_target_capacity_type = each.value.compute_provider.ec2.instance_target_capacity_type + instance_allocation_strategy = each.value.compute_provider.ec2.instance_allocation_strategy + instance_type_priorities = each.value.compute_provider.ec2.instance_type_priorities + instance_max_spot_price = each.value.compute_provider.ec2.instance_max_spot_price + block_device_mappings = each.value.compute_provider.ec2.block_device_mappings + ebs_optimized = each.value.compute_provider.ec2.ebs_optimized + instance_profile = each.value.compute_provider.ec2.instance_profile + instance_profile_path = var.instance_profile_path + enable_on_demand_failover_for_errors = each.value.compute_provider.ec2.enable_on_demand_failover_for_errors + scale_errors = each.value.compute_provider.ec2.scale_errors + managed_security_group_enabled = var.enable_managed_runner_security_group + detailed_monitoring_enabled = each.value.compute_provider.ec2.detailed_monitoring_enabled + ssm_enabled = each.value.compute_provider.ec2.ssm_enabled + egress_rules = var.runner_egress_rules + additional_security_group_ids = try(coalescelist(each.value.compute_provider.ec2.additional_security_group_ids, var.runner_additional_security_group_ids), []) + metadata_options = each.value.compute_provider.ec2.metadata_options + credit_specification = each.value.compute_provider.ec2.credit_specification + cpu_options = each.value.compute_provider.ec2.cpu_options + placement = each.value.compute_provider.ec2.placement + license_specifications = each.value.compute_provider.ec2.license_specifications + use_dedicated_host = each.value.compute_provider.ec2.use_dedicated_host + binaries_syncer = { + enabled = each.value.compute_provider.ec2.binaries_syncer.enabled + s3 = each.value.compute_provider.ec2.binaries_syncer.enabled ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.os}_${each.value.runner.architecture}"] : null + } + cloudwatch_agent = { + enabled = each.value.compute_provider.ec2.cloudwatch_agent.enabled + config = try(coalesce(each.value.compute_provider.ec2.cloudwatch_agent.config, var.cloudwatch_config), null) + } + log_files = each.value.compute_provider.ec2.log_files + user_data = each.value.compute_provider.ec2.user_data + key_name = var.key_name + tags = each.value.compute_provider.ec2.tags + + create_service_linked_role_spot = each.value.compute_provider.ec2.create_service_linked_role_spot + associate_public_ipv4_address = var.associate_public_ipv4_address + } + } } diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 24dae5e7bd..69e954ac1d 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -110,30 +110,41 @@ run "experimental_v2_routes_through_provider_stack" { multi_runner_config_v2 = { linux = { runner = { - runner_os = "linux" - runner_architecture = "x64" - runners_maximum_count = 2 - enable_organization_runners = true + os = "linux" + architecture = "x64" + maximum_count = 2 + hooks = { + job_started = "/opt/actions/job-started.sh" + } + iam = { + managed_policy_arns = { + readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } + } + } + github = { + organization_runners = true + } + scale_down = { idle_config = [{ cron = "* * * * *" timeZone = "UTC" idleCount = 1 }] - pool_config = [{ + } + pool = { + config = [{ schedule_expression = "cron(0 8 * * ? *)" size = 1 }] - iam = { - managed_policy_arns = { - readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" - } - } } - provider = { + compute_provider = { type = "ec2" ec2 = { - instance_types = ["m5.large"] - enable_runner_binaries_syncer = false + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } matcherConfig = { @@ -211,7 +222,7 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = local.runner_config_by_provider.ec2["linux"].runner.idle_config[0].idleCount == 1 + condition = local.runner_config_by_provider.ec2["linux"].scale_down.idle_config[0].idleCount == 1 error_message = "Provider-neutral idle configuration must remain in the common runner contract." } @@ -219,6 +230,14 @@ run "experimental_v2_routes_through_provider_stack" { condition = local.runner_config_by_provider.ec2["linux"].runner.iam.managed_policy_arns.readonly == "arn:aws:iam::aws:policy/ReadOnlyAccess" error_message = "Runner-role policies must remain in the common runner contract." } + + assert { + condition = ( + local.runner_config_by_provider.ec2["linux"].runner.hooks.job_started == "/opt/actions/job-started.sh" + && !contains(keys(local.runner_config_by_provider.ec2["linux"].compute_provider.ec2), "hooks") + ) + error_message = "Runner lifecycle hooks must remain in the common runner contract." + } } run "stable_v1_and_experimental_v2_coexist" { @@ -244,16 +263,20 @@ run "stable_v1_and_experimental_v2_coexist" { multi_runner_config_v2 = { experimental = { runner = { - runner_os = "linux" - runner_architecture = "arm64" - runners_maximum_count = 2 - enable_organization_runners = true + os = "linux" + architecture = "arm64" + maximum_count = 2 + } + github = { + organization_runners = true } - provider = { + compute_provider = { type = "ec2" ec2 = { - instance_types = ["m7g.large"] - enable_runner_binaries_syncer = true + instance_types = ["m7g.large"] + binaries_syncer = { + enabled = true + } } } matcherConfig = { @@ -343,15 +366,17 @@ run "duplicate_lane_keys_are_rejected" { multi_runner_config_v2 = { duplicate = { runner = { - runner_os = "linux" - runner_architecture = "x64" - runners_maximum_count = 2 + os = "linux" + architecture = "x64" + maximum_count = 2 } - provider = { + compute_provider = { type = "ec2" ec2 = { - instance_types = ["m5.large"] - enable_runner_binaries_syncer = false + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } matcherConfig = { @@ -371,11 +396,11 @@ run "experimental_v2_rejects_future_providers" { multi_runner_config_v2 = { microvm = { runner = { - runner_os = "linux" - runner_architecture = "x64" - runners_maximum_count = 2 + os = "linux" + architecture = "x64" + maximum_count = 2 } - provider = { + compute_provider = { type = "microvm" } matcherConfig = { @@ -395,11 +420,11 @@ run "experimental_v2_rejects_profile_without_role" { multi_runner_config_v2 = { invalid_profile = { runner = { - runner_os = "linux" - runner_architecture = "x64" - runners_maximum_count = 2 + os = "linux" + architecture = "x64" + maximum_count = 2 } - provider = { + compute_provider = { type = "ec2" ec2 = { instance_types = ["m5.large"] diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index f577501707..b0bb34512a 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -1,66 +1,107 @@ variable "multi_runner_config_v2" { description = < [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [ec2\_runner\_role](#module\_ec2\_runner\_role) | ../compute-providers/ec2/runner-role | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | @@ -75,7 +75,7 @@ yarn run dist ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | @@ -131,76 +131,27 @@ yarn run dist ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | +| ---- | ----------- | ---- | ------- | :------: | +| [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | -| [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings must remain inside the selected provider block. |
object({
type = string

ec2 = optional(object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(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)
s3_runner_binaries = 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)
enable_userdata = optional(bool, true)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
enable_ssm_on_runners = optional(bool, false)
create_service_linked_role_spot = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
enable_managed_runner_security_group = optional(bool, true)
cloudwatch_config = optional(string, null)
runner_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)
runner_additional_security_group_ids = optional(list(string), [])
enable_runner_detailed_monitoring = 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
}])
runner_ec2_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)
}), {})
enable_runner_binaries_syncer = optional(bool, true)
enable_user_data_debug_logging = optional(bool, false)
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)
enable_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)
}), null)
})
| n/a | yes | -| [disable\_runner\_autoupdate](#input\_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/) | `bool` | `false` | no | -| [enable\_ephemeral\_runners](#input\_enable\_ephemeral\_runners) | Enable ephemeral runners, runners will only be used once. | `bool` | `false` | no | -| [enable\_jit\_config](#input\_enable\_jit\_config) | Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI. | `bool` | `null` | no | -| [enable\_job\_queued\_check](#input\_enable\_job\_queued\_check) | Only scale if the job event received by the scale up lambda is is in the state queued. By default enabled for non ephemeral runners and disabled for ephemeral. Set this variable to overwrite the default behavior. | `bool` | `null` | no | -| [enable\_organization\_runners](#input\_enable\_organization\_runners) | Register runners to organization, instead of repo level | `bool` | n/a | yes | -| [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | -| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | -| [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters. |
object({
key_base64 = map(string)
id = map(string)
})
| n/a | yes | -| [idle\_config](#input\_idle\_config) | List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle. |
list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
}))
| `[]` | no | -| [job\_retry](#input\_job\_retry) | 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\_reserved\_concurrent\_executions': Amount of reserved concurrent executions for the job retry check lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations.
`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_reserved_concurrent_executions = optional(number, 1)

lambda_timeout = optional(number, 30)

max_attempts = optional(number, 1)
})
| `{}` | no | -| [kms\_key\_arn](#input\_kms\_key\_arn) | Optional CMK Key ARN to be used for Parameter Store. | `string` | `null` | no | -| [lambda\_architecture](#input\_lambda\_architecture) | AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions. | `string` | `"arm64"` | no | -| [lambda\_event\_source\_mapping\_batch\_size](#input\_lambda\_event\_source\_mapping\_batch\_size) | Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default of 10 events will be used. | `number` | `10` | no | -| [lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds](#input\_lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds) | Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10. Defaults to 0. | `number` | `0` | no | -| [lambda\_runtime](#input\_lambda\_runtime) | AWS Lambda runtime. | `string` | `"nodejs24.x"` | no | -| [lambda\_s3\_bucket](#input\_lambda\_s3\_bucket) | S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly. | `string` | `null` | no | -| [lambda\_scale\_down\_memory\_size](#input\_lambda\_scale\_down\_memory\_size) | Memory size limit in MB for scale down lambda. | `number` | `512` | no | -| [lambda\_scale\_up\_memory\_size](#input\_lambda\_scale\_up\_memory\_size) | Memory size limit in MB for scale-up lambda. | `number` | `512` | no | -| [lambda\_security\_group\_ids](#input\_lambda\_security\_group\_ids) | List of security group IDs associated with the Lambda function. | `list(string)` | `[]` | no | -| [lambda\_subnet\_ids](#input\_lambda\_subnet\_ids) | List of subnets in which the lambda will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | `[]` | no | -| [lambda\_tags](#input\_lambda\_tags) | Map of tags that will be added to all the lambda function resources. Note these are additional tags to the default tags. | `map(string)` | `{}` | no | -| [lambda\_timeout\_scale\_down](#input\_lambda\_timeout\_scale\_down) | Time out for the scale down lambda in seconds. | `number` | `60` | no | -| [lambda\_timeout\_scale\_up](#input\_lambda\_timeout\_scale\_up) | Time out for the scale up lambda in seconds. | `number` | `60` | no | -| [lambda\_zip](#input\_lambda\_zip) | File location of the lambda zip file. | `string` | `null` | no | -| [log\_class](#input\_log\_class) | The log class of the CloudWatch log groups for the lambda functions. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. | `string` | `"STANDARD"` | no | -| [log\_level](#input\_log\_level) | Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. | `string` | `"info"` | no | -| [logging\_kms\_key\_id](#input\_logging\_kms\_key\_id) | Specifies the kms key id to encrypt the logs with | `string` | `null` | no | -| [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 | -| [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 | -| [minimum\_running\_time\_in\_minutes](#input\_minimum\_running\_time\_in\_minutes) | Minimum time a runner should remain active before it can be terminated while idle. If unset, the default is calculated from runner\_os. | `number` | `null` | no | -| [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | -| [pool\_config](#input\_pool\_config) | The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone ` to override the schedule time zone (defaults to UTC). |
list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
| `[]` | no | -| [pool\_include\_busy\_runners](#input\_pool\_include\_busy\_runners) | Include busy runners in the pool calculation. By default busy runners are not included in the pool. | `bool` | `false` | no | -| [pool\_lambda\_memory\_size](#input\_pool\_lambda\_memory\_size) | Lambda Memory size limit in MB for pool lambda | `number` | `512` | no | -| [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | -| [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | -| [pool\_runner\_owner](#input\_pool\_runner\_owner) | The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported. | `string` | `null` | no | -| [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no | -| [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the prefix will be used. | `string` | `null` | no | -| [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | -| [runner\_architecture](#input\_runner\_architecture) | Platform architecture used for runner labels and provider bootstrap. | `string` | `"x64"` | no | -| [runner\_as\_root](#input\_runner\_as\_root) | Run the action runner under the root user. Variable `runner_run_as` will be ignored. | `bool` | `false` | no | -| [runner\_boot\_time\_in\_minutes](#input\_runner\_boot\_time\_in\_minutes) | Minimum time for a compute runner to boot and register. | `number` | `5` | 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`. | `bool` | `false` | no | -| [runner\_group\_name](#input\_runner\_group\_name) | Name of the runner group. | `string` | `"Default"` | no | -| [runner\_iam](#input\_runner\_iam) | Common runner-role configuration. Provider and user-managed policies are attached only when the runner stack creates the role; an external role must already contain all required policies. |
object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
})
| `{}` | no | -| [runner\_labels](#input\_runner\_labels) | All the labels for the runners (GitHub) including the default one's(e.g: self-hosted, linux, x64, label1, label2). Separate each label by a comma | `list(string)` | n/a | yes | -| [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) | Operating system used for runner labels and provider bootstrap (linux, osx, windows). | `string` | `"linux"` | no | -| [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | -| [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for runners lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | -| [runners\_lambda\_s3\_object\_version](#input\_runners\_lambda\_s3\_object\_version) | S3 object version for runners lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no | -| [runners\_maximum\_count](#input\_runners\_maximum\_count) | The maximum number of runners that will be created. Setting the variable to `-1` desiables the maximum check. | `number` | `3` | no | -| [scale\_down\_schedule\_expression](#input\_scale\_down\_schedule\_expression) | Scheduler expression to check every x for scale down. | `string` | `"cron(*/5 * * * ? *)"` | no | -| [scale\_up\_reserved\_concurrent\_executions](#input\_scale\_up\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | -| [sqs\_build\_queue](#input\_sqs\_build\_queue) | SQS queue to consume accepted build events. |
object({
arn = string
url = string
})
| n/a | yes | -| [ssm\_housekeeper](#input\_ssm\_housekeeper) | Configuration for the SSM housekeeper lambda. This lambda deletes token / JIT config from SSM.

`schedule_expression`: is used to configure the schedule for the lambda.
`state`: state of the cloudwatch event rule. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
`lambda_memory_size`: lambda memory size limit.
`lambda_timeout`: timeout for the lambda in seconds.
`config`: configuration for the lambda function. Token path will be read by default from the module. |
object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
lambda_memory_size = optional(number, 512)
lambda_timeout = optional(number, 60)
config = object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
})
})
|
{
"config": {}
}
| no | -| [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
tokens = string
config = string
})
| n/a | yes | -| [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name. | `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 | -| [user\_agent](#input\_user\_agent) | User agent used for API calls. | `string` | `null` | no | +| [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings must remain inside the selected provider block. |
object({
type = string

ec2 = optional(object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(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)
enable_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)
}), null)
})
| n/a | yes | +| [github](#input\_github) | GitHub API and registration configuration. |
object({
app_parameters = object({
key_base64 = map(string)
id = map(string)
})
organization_runners = bool
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | +| [job\_retry](#input\_job\_retry) | Job-retry queue and Lambda configuration. |
object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
})
| `{}` | no | +| [lambda](#input\_lambda) | Configuration shared by the control-plane Lambda functions. |
object({
zip = optional(string, null)
s3 = optional(object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| `{}` | no | +| [observability](#input\_observability) | Logging, tracing, and metrics configuration. |
object({
log_level = optional(string, "info")
logs = optional(object({
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(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 | +| [pool](#input\_pool) | Scheduled runner-pool configuration. |
object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
lambda = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
}), {})
})
| `{}` | no | +| [prefix](#input\_prefix) | The prefix used for naming resources. | `string` | `"github-actions"` | no | +| [queue](#input\_queue) | Build queue and Lambda event-source configuration. |
object({
build = object({
arn = string
url = string
})
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
})
| n/a | yes | +| [runner](#input\_runner) | Provider-neutral GitHub runner configuration. |
object({
os = optional(string, "linux")
architecture = optional(string, "x64")
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
labels = list(string)
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = optional(number, 3)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| n/a | yes | +| [scale\_down](#input\_scale\_down) | Scale-down Lambda and idle-runner configuration. |
object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
})
| `{}` | no | +| [scale\_up](#input\_scale\_up) | Scale-up Lambda configuration. |
object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
})
| `{}` | no | +| [ssm](#input\_ssm) | Parameter Store paths, encryption, tags, and housekeeper configuration. |
object({
paths = object({
root = string
tokens = string
config = string
})
kms_key_arn = optional(string, null)
parameter_tags = optional(map(string), {})
housekeeper = optional(object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
lambda = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
})
| n/a | yes | +| [tags](#input\_tags) | Map of tags added to created resources. | `map(string)` | `{}` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [lambda\_pool](#output\_lambda\_pool) | n/a | | [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a | | [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a | diff --git a/modules/runner-stack/common-config.tf b/modules/runner-stack/common-config.tf index 837059a944..919848c6df 100644 --- a/modules/runner-stack/common-config.tf +++ b/modules/runner-stack/common-config.tf @@ -5,20 +5,21 @@ locals { "Name" = format("%s-action-runner", var.prefix) }, { - "ghr:ssm_config_path" = "${var.ssm_paths.root}/${var.ssm_paths.config}" + "ghr:ssm_config_path" = "${var.ssm.paths.root}/${var.ssm.paths.config}" }, var.tags, ) - role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path - lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/control-plane/runners.zip" : var.lambda_zip - kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" - enable_job_queued_check = var.enable_job_queued_check == null ? !var.enable_ephemeral_runners : var.enable_job_queued_check - token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" - arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" + lambda_role_path = var.lambda.role.path == null ? "/${var.prefix}/" : var.lambda.role.path + runner_role_path = var.runner.iam.path == null ? "/${var.prefix}/" : var.runner.iam.path + lambda_zip = var.lambda.zip == null ? "${path.module}/../../lambdas/functions/control-plane/runners.zip" : var.lambda.zip + kms_key_arn = var.ssm.kms_key_arn != null ? var.ssm.kms_key_arn : "" + enable_job_queued_check = var.scale_up.job_queued_check_enabled == null ? !var.runner.ephemeral : var.scale_up.job_queued_check_enabled + token_path = "${var.ssm.paths.root}/${var.ssm.paths.tokens}" + arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm.paths.root}/${var.ssm.paths.config}" parameter_store_tags = jsonencode([ - for key, value in merge(var.tags, var.parameter_store_tags) : { + for key, value in merge(var.tags, var.ssm.parameter_tags) : { Key = key Value = value } diff --git a/modules/runner-stack/compute-provider-variables.tf b/modules/runner-stack/compute-provider-variables.tf index d045021ab3..02e0db8801 100644 --- a/modules/runner-stack/compute-provider-variables.tf +++ b/modules/runner-stack/compute-provider-variables.tf @@ -22,11 +22,14 @@ variable "compute_provider" { name = string }), null) instance_profile_path = optional(string, null) - s3_runner_binaries = optional(object({ - arn = string - id = string - key = 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") @@ -39,34 +42,37 @@ variable "compute_provider" { 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) - enable_userdata = optional(bool, true) - userdata_template = optional(string, null) - userdata_content = optional(string, null) - userdata_pre_install = optional(string, "") - userdata_post_install = optional(string, "") - runner_hook_job_started = optional(string, "") - runner_hook_job_completed = optional(string, "") - enable_ssm_on_runners = optional(bool, false) - create_service_linked_role_spot = optional(bool, false) - enable_cloudwatch_agent = optional(bool, true) - enable_managed_runner_security_group = optional(bool, true) - cloudwatch_config = optional(string, null) - runner_log_files = optional(list(object({ + 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) - runner_additional_security_group_ids = optional(list(string), []) - enable_runner_detailed_monitoring = optional(bool, false) + 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) @@ -88,16 +94,14 @@ variable "compute_provider" { to_port = 0 description = null }]) - runner_ec2_tags = optional(map(string), {}) + 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) }), {}) - enable_runner_binaries_syncer = optional(bool, true) - enable_user_data_debug_logging = optional(bool, false) - credit_specification = optional(string, null) + credit_specification = optional(string, null) cpu_options = optional(object({ core_count = optional(number) threads_per_core = optional(number) @@ -183,29 +187,8 @@ variable "compute_provider" { validation { condition = var.compute_provider.ec2 == null ? true : ( - !var.compute_provider.ec2.enable_runner_binaries_syncer || var.compute_provider.ec2.s3_runner_binaries != null + !var.compute_provider.ec2.binaries_syncer.enabled || var.compute_provider.ec2.binaries_syncer.s3 != null ) - error_message = "compute_provider.ec2.s3_runner_binaries must be set when enable_runner_binaries_syncer is true." - } -} - -variable "runner_iam" { - description = "Common runner-role configuration. Provider and user-managed policies are attached only when the runner stack creates the role; an external role must already contain all required policies." - type = object({ - role = optional(object({ - arn = string - }), null) - managed_policy_arns = optional(map(string), {}) - }) - default = {} - - validation { - condition = var.runner_iam.role == null ? true : trimspace(var.runner_iam.role.arn) != "" - error_message = "runner_iam.role.arn must be a non-empty ARN when set." - } - - validation { - condition = var.runner_iam.role == null || length(var.runner_iam.managed_policy_arns) == 0 - error_message = "runner_iam.managed_policy_arns cannot be set with an external runner_iam.role because external roles are not managed by this module." + error_message = "compute_provider.ec2.binaries_syncer.s3 must be set when compute_provider.ec2.binaries_syncer.enabled is true." } } diff --git a/modules/runner-stack/ec2.tf b/modules/runner-stack/ec2.tf index e8d167f0d5..421640df05 100644 --- a/modules/runner-stack/ec2.tf +++ b/modules/runner-stack/ec2.tf @@ -20,48 +20,48 @@ module "ec2" { runner_role = local.runner_role tags = var.tags prefix = var.prefix - s3_runner_binaries = local.ec2.s3_runner_binaries + s3_runner_binaries = local.ec2.binaries_syncer.s3 block_device_mappings = local.ec2.block_device_mappings ebs_optimized = local.ec2.ebs_optimized instance_target_capacity_type = local.ec2.instance_target_capacity_type instance_allocation_strategy = local.ec2.instance_allocation_strategy instance_type_priorities = local.ec2.instance_type_priorities instance_max_spot_price = local.ec2.instance_max_spot_price - runner_os = var.runner_os + runner_os = var.runner.os instance_types = local.ec2.instance_types - enable_userdata = local.ec2.enable_userdata - userdata_template = local.ec2.userdata_template - userdata_content = local.ec2.userdata_content - userdata_pre_install = local.ec2.userdata_pre_install - userdata_post_install = local.ec2.userdata_post_install - runner_hook_job_started = local.ec2.runner_hook_job_started - runner_hook_job_completed = local.ec2.runner_hook_job_completed - runner_boot_time_in_minutes = var.runner_boot_time_in_minutes - role_path = var.role_path + enable_userdata = local.ec2.user_data.enabled + userdata_template = local.ec2.user_data.template + userdata_content = local.ec2.user_data.content + userdata_pre_install = local.ec2.user_data.pre_install + userdata_post_install = local.ec2.user_data.post_install + enable_user_data_debug_logging = local.ec2.user_data.debug_logging_enabled + runner_hook_job_started = var.runner.hooks.job_started + runner_hook_job_completed = var.runner.hooks.job_completed + runner_boot_time_in_minutes = var.runner.boot_time_in_minutes + role_path = var.runner.iam.path instance_profile_path = local.ec2.instance_profile_path - runner_as_root = var.runner_as_root - runner_run_as = var.runner_run_as - runner_architecture = var.runner_architecture - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id + runner_as_root = var.runner.run_as_root + runner_run_as = var.runner.run_as + runner_architecture = var.runner.architecture + logging_retention_in_days = var.observability.logs.retention_in_days + logging_kms_key_id = var.observability.logs.kms_key_id create_service_linked_role_spot = local.ec2.create_service_linked_role_spot aws_partition = var.aws_partition - enable_cloudwatch_agent = local.ec2.enable_cloudwatch_agent - enable_managed_runner_security_group = local.ec2.enable_managed_runner_security_group - cloudwatch_config = local.ec2.cloudwatch_config - runner_log_files = local.ec2.runner_log_files - ghes_url = var.ghes_url - ghes_ssl_verify = var.ghes_ssl_verify + enable_cloudwatch_agent = local.ec2.cloudwatch_agent.enabled + enable_managed_runner_security_group = local.ec2.managed_security_group_enabled + cloudwatch_config = local.ec2.cloudwatch_agent.config + runner_log_files = local.ec2.log_files + ghes_url = var.github.enterprise_server.url + ghes_ssl_verify = var.github.enterprise_server.ssl_verify key_name = local.ec2.key_name - runner_additional_security_group_ids = local.ec2.runner_additional_security_group_ids - enable_runner_detailed_monitoring = local.ec2.enable_runner_detailed_monitoring + runner_additional_security_group_ids = local.ec2.additional_security_group_ids + enable_runner_detailed_monitoring = local.ec2.detailed_monitoring_enabled egress_rules = local.ec2.egress_rules - runner_ec2_tags = local.ec2.runner_ec2_tags + runner_ec2_tags = local.ec2.tags metadata_options = local.ec2.metadata_options - enable_runner_binaries_syncer = local.ec2.enable_runner_binaries_syncer - enable_user_data_debug_logging = local.ec2.enable_user_data_debug_logging - ssm_paths = var.ssm_paths - runner_name_prefix = var.runner_name_prefix + enable_runner_binaries_syncer = local.ec2.binaries_syncer.enabled + ssm_paths = var.ssm.paths + runner_name_prefix = var.runner.name_prefix credit_specification = local.ec2.credit_specification cpu_options = local.ec2.cpu_options placement = local.ec2.placement diff --git a/modules/runner-stack/job-retry.tf b/modules/runner-stack/job-retry.tf index 4adb27ad1a..9245696463 100644 --- a/modules/runner-stack/job-retry.tf +++ b/modules/runner-stack/job-retry.tf @@ -1,38 +1,40 @@ locals { - job_retry_enabled = var.job_retry != null && var.job_retry.enable ? true : false + job_retry_enabled = var.job_retry.enabled job_retry = { prefix = var.prefix tags = local.tags aws_partition = var.aws_partition - architecture = var.lambda_architecture - runtime = var.lambda_runtime - security_group_ids = var.lambda_security_group_ids - subnet_ids = var.lambda_subnet_ids - kms_key_arn = var.kms_key_arn - lambda_tags = var.lambda_tags - log_level = var.log_level - log_class = var.log_class - logging_kms_key_id = var.logging_kms_key_id - logging_retention_in_days = var.logging_retention_in_days - metrics = var.metrics - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary - s3_bucket = var.lambda_s3_bucket - s3_key = var.runners_lambda_s3_key - s3_object_version = var.runners_lambda_s3_object_version - zip = var.lambda_zip - tracing_config = var.tracing_config - github_app_parameters = var.github_app_parameters - enable_organization_runners = var.enable_organization_runners - runner_name_prefix = var.runner_name_prefix - sqs_build_queue = var.sqs_build_queue - ghes_url = var.ghes_url - lambda_event_source_mapping_batch_size = var.lambda_event_source_mapping_batch_size - lambda_event_source_mapping_maximum_batching_window_in_seconds = var.lambda_event_source_mapping_maximum_batching_window_in_seconds - memory_size = var.job_retry.lambda_memory_size - timeout = var.job_retry.lambda_timeout + architecture = var.lambda.architecture + runtime = var.lambda.runtime + security_group_ids = var.lambda.security_group_ids + subnet_ids = var.lambda.subnet_ids + kms_key_arn = var.ssm.kms_key_arn + lambda_tags = var.lambda.tags + log_level = var.observability.log_level + log_class = var.observability.logs.class + logging_kms_key_id = var.observability.logs.kms_key_id + logging_retention_in_days = var.observability.logs.retention_in_days + metrics = var.observability.metrics + role_path = var.lambda.role.path + role_permissions_boundary = var.lambda.role.permissions_boundary + s3_bucket = var.lambda.s3.bucket + s3_key = var.lambda.s3.key + s3_object_version = var.lambda.s3.object_version + zip = var.lambda.zip + tracing_config = var.observability.tracing + github_app_parameters = var.github.app_parameters + enable_organization_runners = var.github.organization_runners + runner_name_prefix = var.runner.name_prefix + sqs_build_queue = var.queue.build + ghes_url = var.github.enterprise_server.url + user_agent = var.github.user_agent + lambda_event_source_mapping_batch_size = var.queue.event_source_mapping.batch_size + lambda_event_source_mapping_maximum_batching_window_in_seconds = var.queue.event_source_mapping.maximum_batching_window_in_seconds + memory_size = var.job_retry.lambda.memory_size + reserved_concurrent_executions = var.job_retry.lambda.reserved_concurrent_executions + timeout = var.job_retry.lambda.timeout } } diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md index 1f76daaee4..a7f20c4527 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/runner-stack/job-retry/README.md @@ -11,26 +11,26 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.21 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.58.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [job\_retry](#module\_job\_retry) | ../../lambda | n/a | ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_iam_role_policy.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_lambda_event_source_mapping.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | | [aws_lambda_permission.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | @@ -42,13 +42,13 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runner_name_prefix`: Prefix used to identify runners belonging to this lane.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | +| ---- | ----------- | ---- | ------- | :------: | +| [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runner_name_prefix`: Prefix used to identify runners belonging to this lane.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
reserved_concurrent_executions = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | n/a | | [lambda](#output\_lambda) | n/a | diff --git a/modules/runner-stack/job-retry/variables.tf b/modules/runner-stack/job-retry/variables.tf index e71712d7a2..7493544cbd 100644 --- a/modules/runner-stack/job-retry/variables.tf +++ b/modules/runner-stack/job-retry/variables.tf @@ -19,6 +19,7 @@ variable "config" { `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with `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. `memory_size`: Memory size limit in MB of the lambda. + `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation. `metrics`: Configuration to enable metrics creation by the lambda. `prefix`: The prefix used for naming resources. `role_path`: The path that will be added to the role, if not set the environment name will be used. @@ -56,6 +57,7 @@ variable "config" { logging_kms_key_id = optional(string, null) logging_retention_in_days = optional(number, null) memory_size = optional(number, null) + reserved_concurrent_executions = optional(number, null) metrics = optional(object({ enable = optional(bool, false) namespace = optional(string, null) diff --git a/modules/runner-stack/lambda-common-iam-policies.tf b/modules/runner-stack/lambda-common-iam-policies.tf index 9313557c98..d8d0f5968a 100644 --- a/modules/runner-stack/lambda-common-iam-policies.tf +++ b/modules/runner-stack/lambda-common-iam-policies.tf @@ -11,7 +11,7 @@ data "aws_iam_policy_document" "lambda_assume_role_policy" { } data "aws_iam_policy_document" "lambda_xray" { - count = var.tracing_config.mode != null ? 1 : 0 + count = var.observability.tracing.mode != null ? 1 : 0 statement { actions = [ "xray:BatchGetTraces", diff --git a/modules/runner-stack/pool.tf b/modules/runner-stack/pool.tf index 79c56aeae6..9a4f42340b 100644 --- a/modules/runner-stack/pool.tf +++ b/modules/runner-stack/pool.tf @@ -1,58 +1,58 @@ module "pool" { - count = length(var.pool_config) == 0 ? 0 : 1 + count = length(var.pool.config) == 0 ? 0 : 1 source = "./pool" config = { prefix = var.prefix ghes = { - ssl_verify = var.ghes_ssl_verify - url = var.ghes_url + ssl_verify = var.github.enterprise_server.ssl_verify + url = var.github.enterprise_server.url } - user_agent = var.user_agent - github_app_parameters = var.github_app_parameters - runners_maximum_count = var.runners_maximum_count + user_agent = var.github.user_agent + github_app_parameters = var.github.app_parameters + runners_maximum_count = var.runner.maximum_count kms_key_arn = local.kms_key_arn lambda = { - log_level = var.log_level - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id - log_class = var.log_class - reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions - s3_bucket = var.lambda_s3_bucket - s3_key = var.runners_lambda_s3_key - s3_object_version = var.runners_lambda_s3_object_version - security_group_ids = var.lambda_security_group_ids - subnet_ids = var.lambda_subnet_ids - architecture = var.lambda_architecture - memory_size = var.pool_lambda_memory_size - runtime = var.lambda_runtime - timeout = var.pool_lambda_timeout + log_level = var.observability.log_level + logging_retention_in_days = var.observability.logs.retention_in_days + logging_kms_key_id = var.observability.logs.kms_key_id + log_class = var.observability.logs.class + reserved_concurrent_executions = var.pool.lambda.reserved_concurrent_executions + s3_bucket = var.lambda.s3.bucket + s3_key = var.lambda.s3.key + s3_object_version = var.lambda.s3.object_version + security_group_ids = var.lambda.security_group_ids + subnet_ids = var.lambda.subnet_ids + architecture = var.lambda.architecture + memory_size = var.pool.lambda.memory_size + runtime = var.lambda.runtime + timeout = var.pool.lambda.timeout zip = local.lambda_zip parameter_store_tags = local.parameter_store_tags } - pool = var.pool_config - include_busy_runners = var.pool_include_busy_runners - role_path = local.role_path - role_permissions_boundary = var.role_permissions_boundary + pool = var.pool.config + include_busy_runners = var.pool.include_busy_runners + role_path = local.lambda_role_path + role_permissions_boundary = var.lambda.role.permissions_boundary runner = { - disable_runner_autoupdate = var.disable_runner_autoupdate - ephemeral = var.enable_ephemeral_runners - enable_jit_config = var.enable_jit_config - labels = var.runner_labels - group_name = var.runner_group_name - name_prefix = var.runner_name_prefix - pool_owner = var.pool_runner_owner + disable_runner_autoupdate = var.runner.auto_update_disabled + ephemeral = var.runner.ephemeral + enable_jit_config = var.runner.jit_config_enabled + labels = var.runner.labels + group_name = var.runner.group_name + name_prefix = var.runner.name_prefix + pool_owner = var.pool.runner_owner } - ssm_token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" - ssm_config_path = "${var.ssm_paths.root}/${var.ssm_paths.config}" + ssm_token_path = "${var.ssm.paths.root}/${var.ssm.paths.tokens}" + ssm_config_path = "${var.ssm.paths.root}/${var.ssm.paths.config}" tags = local.tags - lambda_tags = var.lambda_tags + lambda_tags = var.lambda.tags arn_ssm_parameters_path_config = local.arn_ssm_parameters_path_config } aws_partition = var.aws_partition - tracing_config = var.tracing_config + tracing_config = var.observability.tracing runner_provider = { type = local.provider.type environment_variables = local.provider.pool.environment_variables diff --git a/modules/runner-stack/runner-role.tf b/modules/runner-stack/runner-role.tf index cf0a93ca51..276ead2437 100644 --- a/modules/runner-stack/runner-role.tf +++ b/modules/runner-stack/runner-role.tf @@ -6,28 +6,28 @@ module "ec2_runner_role" { aws_partition = var.aws_partition aws_region = var.aws_region - enable_cloudwatch_agent = local.ec2.enable_cloudwatch_agent - enable_runner_binaries_syncer = local.ec2.enable_runner_binaries_syncer - enable_ssm_on_runners = local.ec2.enable_ssm_on_runners - s3_runner_binaries = local.ec2.s3_runner_binaries - ssm_paths = var.ssm_paths + enable_cloudwatch_agent = local.ec2.cloudwatch_agent.enabled + enable_runner_binaries_syncer = local.ec2.binaries_syncer.enabled + enable_ssm_on_runners = local.ec2.ssm_enabled + s3_runner_binaries = local.ec2.binaries_syncer.s3 + ssm_paths = var.ssm.paths } locals { provider_runner_role = one(module.ec2_runner_role[*]) - create_runner_role = local.provider_type == "ec2" && var.runner_iam.role == null + create_runner_role = local.provider_type == "ec2" && var.runner.iam.role == null runner_role = { - arn = local.create_runner_role ? one(aws_iam_role.runner[*].arn) : var.runner_iam.role.arn - name = local.create_runner_role ? one(aws_iam_role.runner[*].name) : basename(var.runner_iam.role.arn) + arn = local.create_runner_role ? one(aws_iam_role.runner[*].arn) : var.runner.iam.role.arn + name = local.create_runner_role ? one(aws_iam_role.runner[*].name) : basename(var.runner.iam.role.arn) } runner_managed_policy_arns = merge( { - for policy_name, policy_arn in var.runner_iam.managed_policy_arns : + for policy_name, policy_arn in var.runner.iam.managed_policy_arns : "user-${policy_name}" => policy_arn }, - var.tracing_config.mode != null ? { + var.observability.tracing.mode != null ? { xray = "arn:${var.aws_partition}:iam::aws:policy/AWSXRayDaemonWriteAccess" } : {}, { @@ -41,14 +41,14 @@ resource "aws_iam_role" "runner" { count = local.create_runner_role ? 1 : 0 name = "${substr("${var.prefix}-runner", 0, 54)}-${substr(md5("${var.prefix}-runner"), 0, 8)}" assume_role_policy = local.provider_runner_role.assume_role_policy_json - path = local.role_path - permissions_boundary = var.role_permissions_boundary + path = local.runner_role_path + permissions_boundary = var.runner.iam.permissions_boundary tags = local.tags lifecycle { precondition { - condition = local.ec2.instance_profile == null || var.runner_iam.role != null - error_message = "runner_iam.role must be set when compute_provider.ec2.instance_profile selects an external instance profile." + condition = local.ec2.instance_profile == null || var.runner.iam.role != null + error_message = "runner.iam.role must be set when compute_provider.ec2.instance_profile selects an external instance profile." } } } diff --git a/modules/runner-stack/runner-ssm-parameters.tf b/modules/runner-stack/runner-ssm-parameters.tf index fbbafcf7cc..e1b56b2412 100644 --- a/modules/runner-stack/runner-ssm-parameters.tf +++ b/modules/runner-stack/runner-ssm-parameters.tf @@ -1,28 +1,28 @@ # Shared runner configuration stored in SSM Parameter Store. resource "aws_ssm_parameter" "runner_agent_mode" { - name = "${var.ssm_paths.root}/${var.ssm_paths.config}/agent_mode" + name = "${var.ssm.paths.root}/${var.ssm.paths.config}/agent_mode" type = "String" - value = var.enable_ephemeral_runners ? "ephemeral" : "persistent" + value = var.runner.ephemeral ? "ephemeral" : "persistent" tags = local.tags } resource "aws_ssm_parameter" "disable_default_labels" { - name = "${var.ssm_paths.root}/${var.ssm_paths.config}/disable_default_labels" + name = "${var.ssm.paths.root}/${var.ssm.paths.config}/disable_default_labels" type = "String" - value = var.runner_disable_default_labels + value = var.runner.disable_default_labels tags = local.tags } resource "aws_ssm_parameter" "jit_config_enabled" { - name = "${var.ssm_paths.root}/${var.ssm_paths.config}/enable_jit_config" + name = "${var.ssm.paths.root}/${var.ssm.paths.config}/enable_jit_config" type = "String" - value = var.enable_jit_config == null ? var.enable_ephemeral_runners : var.enable_jit_config + value = var.runner.jit_config_enabled == null ? var.runner.ephemeral : var.runner.jit_config_enabled tags = local.tags } resource "aws_ssm_parameter" "token_path" { - name = "${var.ssm_paths.root}/${var.ssm_paths.config}/token_path" + name = "${var.ssm.paths.root}/${var.ssm.paths.config}/token_path" type = "String" - value = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" + value = "${var.ssm.paths.root}/${var.ssm.paths.tokens}" tags = local.tags } diff --git a/modules/runner-stack/scale-down-iam-policies.tf b/modules/runner-stack/scale-down-iam-policies.tf index b00432f9ef..8f2650d431 100644 --- a/modules/runner-stack/scale-down-iam-policies.tf +++ b/modules/runner-stack/scale-down-iam-policies.tf @@ -7,8 +7,8 @@ data "aws_iam_policy_document" "scale_down_common" { "ssm:GetParameters", ] resources = [ - var.github_app_parameters.key_base64.arn, - var.github_app_parameters.id.arn, + var.github.app_parameters.key_base64.arn, + var.github.app_parameters.id.arn, ] } diff --git a/modules/runner-stack/scale-down.tf b/modules/runner-stack/scale-down.tf index 67bf9a4825..93093544c7 100644 --- a/modules/runner-stack/scale-down.tf +++ b/modules/runner-stack/scale-down.tf @@ -9,69 +9,69 @@ locals { } } resource "aws_lambda_function" "scale_down" { - s3_bucket = var.lambda_s3_bucket != null ? var.lambda_s3_bucket : null - s3_key = var.runners_lambda_s3_key != null ? var.runners_lambda_s3_key : null - s3_object_version = var.runners_lambda_s3_object_version != null ? var.runners_lambda_s3_object_version : null - filename = var.lambda_s3_bucket == null ? local.lambda_zip : null - source_code_hash = var.lambda_s3_bucket == null ? filebase64sha256(local.lambda_zip) : null + s3_bucket = var.lambda.s3.bucket != null ? var.lambda.s3.bucket : null + s3_key = var.lambda.s3.key != null ? var.lambda.s3.key : null + s3_object_version = var.lambda.s3.object_version != null ? var.lambda.s3.object_version : null + filename = var.lambda.s3.bucket == null ? local.lambda_zip : null + source_code_hash = var.lambda.s3.bucket == null ? filebase64sha256(local.lambda_zip) : null function_name = "${var.prefix}-scale-down" role = aws_iam_role.scale_down.arn handler = "index.scaleDownHandler" - runtime = var.lambda_runtime - timeout = var.lambda_timeout_scale_down - tags = merge(local.tags, var.lambda_tags) - memory_size = var.lambda_scale_down_memory_size - architectures = [var.lambda_architecture] + runtime = var.lambda.runtime + timeout = var.scale_down.timeout + tags = merge(local.tags, var.lambda.tags) + memory_size = var.scale_down.memory_size + architectures = [var.lambda.architecture] environment { variables = merge(local.provider.scale_down.environment_variables, { ENVIRONMENT = var.prefix - ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.metrics.enable && var.metrics.metric.enable_github_app_rate_limit - GHES_URL = var.ghes_url - USER_AGENT = var.user_agent - LOG_LEVEL = upper(var.log_level) - MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner_os]) - NODE_TLS_REJECT_UNAUTHORIZED = var.ghes_url != null && !var.ghes_ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = var.github_app_parameters.id.name - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github_app_parameters.key_base64.name - POWERTOOLS_LOGGER_LOG_EVENT = var.log_level == "debug" ? "true" : "false" - SCALE_DOWN_CONFIG = jsonencode(var.idle_config) + ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.observability.metrics.enable && var.observability.metrics.metric.enable_github_app_rate_limit + GHES_URL = var.github.enterprise_server.url + USER_AGENT = var.github.user_agent + LOG_LEVEL = upper(var.observability.log_level) + MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner.os]) + NODE_TLS_REJECT_UNAUTHORIZED = var.github.enterprise_server.url != null && !var.github.enterprise_server.ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = var.github.app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github.app_parameters.key_base64.name + POWERTOOLS_LOGGER_LOG_EVENT = var.observability.log_level == "debug" ? "true" : "false" + SCALE_DOWN_CONFIG = jsonencode(var.scale_down.idle_config) POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-down" - POWERTOOLS_METRICS_NAMESPACE = var.metrics.namespace - POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + POWERTOOLS_METRICS_NAMESPACE = var.observability.metrics.namespace + POWERTOOLS_TRACE_ENABLED = var.observability.tracing.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.observability.tracing.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.observability.tracing.capture_error RUNNER_PROVIDER_TYPE = local.provider.type }) } dynamic "vpc_config" { - for_each = var.lambda_subnet_ids != null && var.lambda_security_group_ids != null ? [true] : [] + for_each = var.lambda.subnet_ids != null && var.lambda.security_group_ids != null ? [true] : [] content { - security_group_ids = var.lambda_security_group_ids - subnet_ids = var.lambda_subnet_ids + security_group_ids = var.lambda.security_group_ids + subnet_ids = var.lambda.subnet_ids } } dynamic "tracing_config" { - for_each = var.tracing_config.mode != null ? [true] : [] + for_each = var.observability.tracing.mode != null ? [true] : [] content { - mode = var.tracing_config.mode + mode = var.observability.tracing.mode } } } resource "aws_cloudwatch_log_group" "scale_down" { name = "/aws/lambda/${aws_lambda_function.scale_down.function_name}" - retention_in_days = var.logging_retention_in_days - kms_key_id = var.logging_kms_key_id - log_group_class = var.log_class + retention_in_days = var.observability.logs.retention_in_days + kms_key_id = var.observability.logs.kms_key_id + log_group_class = var.observability.logs.class tags = var.tags } resource "aws_cloudwatch_event_rule" "scale_down" { name = "${var.prefix}-scale-down-rule" - schedule_expression = var.scale_down_schedule_expression + schedule_expression = var.scale_down.schedule_expression tags = var.tags } @@ -91,8 +91,8 @@ resource "aws_lambda_permission" "scale_down" { resource "aws_iam_role" "scale_down" { name = "${substr("${var.prefix}-scale-down-lambda", 0, 54)}-${substr(md5("${var.prefix}-scale-down-lambda"), 0, 8)}" assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json - path = local.role_path - permissions_boundary = var.role_permissions_boundary + path = local.lambda_role_path + permissions_boundary = var.lambda.role.permissions_boundary tags = local.tags } @@ -109,13 +109,13 @@ resource "aws_iam_role_policy" "scale_down_logging" { } resource "aws_iam_role_policy_attachment" "scale_down_vpc_execution_role" { - count = length(var.lambda_subnet_ids) > 0 ? 1 : 0 + count = length(var.lambda.subnet_ids) > 0 ? 1 : 0 role = aws_iam_role.scale_down.name policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" } resource "aws_iam_role_policy" "scale_down_xray" { - count = var.tracing_config.mode != null ? 1 : 0 + count = var.observability.tracing.mode != null ? 1 : 0 name = "xray-policy" policy = data.aws_iam_policy_document.lambda_xray[0].json role = aws_iam_role.scale_down.name diff --git a/modules/runner-stack/scale-up-iam-policies.tf b/modules/runner-stack/scale-up-iam-policies.tf index e2329e8eeb..29cc34b946 100644 --- a/modules/runner-stack/scale-up-iam-policies.tf +++ b/modules/runner-stack/scale-up-iam-policies.tf @@ -16,9 +16,9 @@ data "aws_iam_policy_document" "scale_up_common" { "ssm:GetParameters", ] resources = [ - var.github_app_parameters.key_base64.arn, - var.github_app_parameters.id.arn, - "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}/*", + var.github.app_parameters.key_base64.arn, + var.github.app_parameters.id.arn, + "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm.paths.root}/${var.ssm.paths.config}/*", ] } @@ -29,7 +29,7 @@ data "aws_iam_policy_document" "scale_up_common" { "sqs:GetQueueAttributes", "sqs:DeleteMessage", ] - resources = [var.sqs_build_queue.arn] + resources = [var.queue.build.arn] } dynamic "statement" { diff --git a/modules/runner-stack/scale-up.tf b/modules/runner-stack/scale-up.tf index 7ef3c3d019..1f21503186 100644 --- a/modules/runner-stack/scale-up.tf +++ b/modules/runner-stack/scale-up.tf @@ -1,6 +1,6 @@ locals { job_retry_config = local.job_retry_enabled ? { - enable = var.job_retry.enable + enable = var.job_retry.enabled maxAttempts = var.job_retry.max_attempts delayInSeconds = var.job_retry.delay_in_seconds delayBackoff = var.job_retry.delay_backoff @@ -9,84 +9,84 @@ locals { } resource "aws_lambda_function" "scale_up" { - s3_bucket = var.lambda_s3_bucket != null ? var.lambda_s3_bucket : null - s3_key = var.runners_lambda_s3_key != null ? var.runners_lambda_s3_key : null - s3_object_version = var.runners_lambda_s3_object_version != null ? var.runners_lambda_s3_object_version : null - filename = var.lambda_s3_bucket == null ? local.lambda_zip : null - source_code_hash = var.lambda_s3_bucket == null ? filebase64sha256(local.lambda_zip) : null + s3_bucket = var.lambda.s3.bucket != null ? var.lambda.s3.bucket : null + s3_key = var.lambda.s3.key != null ? var.lambda.s3.key : null + s3_object_version = var.lambda.s3.object_version != null ? var.lambda.s3.object_version : null + filename = var.lambda.s3.bucket == null ? local.lambda_zip : null + source_code_hash = var.lambda.s3.bucket == null ? filebase64sha256(local.lambda_zip) : null function_name = "${var.prefix}-scale-up" role = aws_iam_role.scale_up.arn handler = "index.scaleUpHandler" - runtime = var.lambda_runtime - timeout = var.lambda_timeout_scale_up - reserved_concurrent_executions = var.scale_up_reserved_concurrent_executions - memory_size = var.lambda_scale_up_memory_size - tags = merge(local.tags, var.lambda_tags) - architectures = [var.lambda_architecture] + runtime = var.lambda.runtime + timeout = var.scale_up.timeout + reserved_concurrent_executions = var.scale_up.reserved_concurrent_executions + memory_size = var.scale_up.memory_size + tags = merge(local.tags, var.lambda.tags) + architectures = [var.lambda.architecture] environment { variables = merge(local.provider.scale_up.environment_variables, { - DISABLE_RUNNER_AUTOUPDATE = var.disable_runner_autoupdate - ENABLE_EPHEMERAL_RUNNERS = var.enable_ephemeral_runners - ENABLE_JIT_CONFIG = var.enable_jit_config + DISABLE_RUNNER_AUTOUPDATE = var.runner.auto_update_disabled + ENABLE_EPHEMERAL_RUNNERS = var.runner.ephemeral + ENABLE_JIT_CONFIG = var.runner.jit_config_enabled ENABLE_JOB_QUEUED_CHECK = local.enable_job_queued_check - ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.metrics.enable && var.metrics.metric.enable_github_app_rate_limit - ENABLE_ORGANIZATION_RUNNERS = var.enable_organization_runners + ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.observability.metrics.enable && var.observability.metrics.metric.enable_github_app_rate_limit + ENABLE_ORGANIZATION_RUNNERS = var.github.organization_runners ENVIRONMENT = var.prefix - GHES_URL = var.ghes_url - USER_AGENT = var.user_agent - LOG_LEVEL = upper(var.log_level) - MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner_os]) - NODE_TLS_REJECT_UNAUTHORIZED = var.ghes_url != null && !var.ghes_ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = var.github_app_parameters.id.name - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github_app_parameters.key_base64.name - POWERTOOLS_LOGGER_LOG_EVENT = var.log_level == "debug" ? "true" : "false" - POWERTOOLS_METRICS_NAMESPACE = var.metrics.namespace - POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error - RUNNER_LABELS = lower(join(",", var.runner_labels)) - RUNNER_GROUP_NAME = var.runner_group_name - RUNNER_NAME_PREFIX = var.runner_name_prefix + GHES_URL = var.github.enterprise_server.url + USER_AGENT = var.github.user_agent + LOG_LEVEL = upper(var.observability.log_level) + MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner.os]) + NODE_TLS_REJECT_UNAUTHORIZED = var.github.enterprise_server.url != null && !var.github.enterprise_server.ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = var.github.app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github.app_parameters.key_base64.name + POWERTOOLS_LOGGER_LOG_EVENT = var.observability.log_level == "debug" ? "true" : "false" + POWERTOOLS_METRICS_NAMESPACE = var.observability.metrics.namespace + POWERTOOLS_TRACE_ENABLED = var.observability.tracing.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.observability.tracing.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.observability.tracing.capture_error + RUNNER_LABELS = lower(join(",", var.runner.labels)) + RUNNER_GROUP_NAME = var.runner.group_name + RUNNER_NAME_PREFIX = var.runner.name_prefix RUNNER_PROVIDER_TYPE = local.provider.type - RUNNERS_MAXIMUM_COUNT = var.runners_maximum_count + RUNNERS_MAXIMUM_COUNT = var.runner.maximum_count POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-up" SSM_TOKEN_PATH = local.token_path - SSM_CONFIG_PATH = "${var.ssm_paths.root}/${var.ssm_paths.config}" + SSM_CONFIG_PATH = "${var.ssm.paths.root}/${var.ssm.paths.config}" SSM_PARAMETER_STORE_TAGS = local.parameter_store_tags JOB_RETRY_CONFIG = jsonencode(local.job_retry_config) }) } dynamic "vpc_config" { - for_each = var.lambda_subnet_ids != null && var.lambda_security_group_ids != null ? [true] : [] + for_each = var.lambda.subnet_ids != null && var.lambda.security_group_ids != null ? [true] : [] content { - security_group_ids = var.lambda_security_group_ids - subnet_ids = var.lambda_subnet_ids + security_group_ids = var.lambda.security_group_ids + subnet_ids = var.lambda.subnet_ids } } dynamic "tracing_config" { - for_each = var.tracing_config.mode != null ? [true] : [] + for_each = var.observability.tracing.mode != null ? [true] : [] content { - mode = var.tracing_config.mode + mode = var.observability.tracing.mode } } } resource "aws_cloudwatch_log_group" "scale_up" { name = "/aws/lambda/${aws_lambda_function.scale_up.function_name}" - retention_in_days = var.logging_retention_in_days - kms_key_id = var.logging_kms_key_id - log_group_class = var.log_class + retention_in_days = var.observability.logs.retention_in_days + kms_key_id = var.observability.logs.kms_key_id + log_group_class = var.observability.logs.class tags = var.tags } resource "aws_lambda_event_source_mapping" "scale_up" { - event_source_arn = var.sqs_build_queue.arn + event_source_arn = var.queue.build.arn function_name = aws_lambda_function.scale_up.arn function_response_types = ["ReportBatchItemFailures"] - batch_size = var.lambda_event_source_mapping_batch_size - maximum_batching_window_in_seconds = var.lambda_event_source_mapping_maximum_batching_window_in_seconds + batch_size = var.queue.event_source_mapping.batch_size + maximum_batching_window_in_seconds = var.queue.event_source_mapping.maximum_batching_window_in_seconds tags = var.tags } @@ -95,14 +95,14 @@ resource "aws_lambda_permission" "scale_runners_lambda" { action = "lambda:InvokeFunction" function_name = aws_lambda_function.scale_up.function_name principal = "sqs.amazonaws.com" - source_arn = var.sqs_build_queue.arn + source_arn = var.queue.build.arn } resource "aws_iam_role" "scale_up" { name = "${substr("${var.prefix}-scale-up-lambda", 0, 54)}-${substr(md5("${var.prefix}-scale-up-lambda"), 0, 8)}" assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json - path = local.role_path - permissions_boundary = var.role_permissions_boundary + path = local.lambda_role_path + permissions_boundary = var.lambda.role.permissions_boundary tags = local.tags } @@ -126,7 +126,7 @@ resource "aws_iam_role_policy" "service_linked_role" { } resource "aws_iam_role_policy_attachment" "scale_up_vpc_execution_role" { - count = length(var.lambda_subnet_ids) > 0 ? 1 : 0 + count = length(var.lambda.subnet_ids) > 0 ? 1 : 0 role = aws_iam_role.scale_up.name policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" } @@ -138,7 +138,7 @@ resource "aws_iam_role_policy_attachment" "ami_id_ssm_parameter_read" { } resource "aws_iam_role_policy" "scale_up_xray" { - count = var.tracing_config.mode != null ? 1 : 0 + count = var.observability.tracing.mode != null ? 1 : 0 name = "xray-policy" policy = data.aws_iam_policy_document.lambda_xray[0].json role = aws_iam_role.scale_up.name diff --git a/modules/runner-stack/ssm-housekeeper.tf b/modules/runner-stack/ssm-housekeeper.tf index 5b53e8ffe6..c5fa641b9c 100644 --- a/modules/runner-stack/ssm-housekeeper.tf +++ b/modules/runner-stack/ssm-housekeeper.tf @@ -1,65 +1,65 @@ locals { ssm_housekeeper = { - schedule_expression = var.ssm_housekeeper.schedule_expression - state = var.ssm_housekeeper.state - lambda_timeout = var.ssm_housekeeper.lambda_timeout - lambda_memory_size = var.ssm_housekeeper.lambda_memory_size + schedule_expression = var.ssm.housekeeper.schedule_expression + state = var.ssm.housekeeper.state + lambda_timeout = var.ssm.housekeeper.lambda.timeout + lambda_memory_size = var.ssm.housekeeper.lambda.memory_size config = { - tokenPath = var.ssm_housekeeper.config.tokenPath == null ? local.token_path : var.ssm_housekeeper.config.tokenPath - minimumDaysOld = var.ssm_housekeeper.config.minimumDaysOld - dryRun = var.ssm_housekeeper.config.dryRun + tokenPath = var.ssm.housekeeper.config.tokenPath == null ? local.token_path : var.ssm.housekeeper.config.tokenPath + minimumDaysOld = var.ssm.housekeeper.config.minimumDaysOld + dryRun = var.ssm.housekeeper.config.dryRun } } } resource "aws_lambda_function" "ssm_housekeeper" { - s3_bucket = var.lambda_s3_bucket != null ? var.lambda_s3_bucket : null - s3_key = var.runners_lambda_s3_key != null ? var.runners_lambda_s3_key : null - s3_object_version = var.runners_lambda_s3_object_version != null ? var.runners_lambda_s3_object_version : null - filename = var.lambda_s3_bucket == null ? local.lambda_zip : null - source_code_hash = var.lambda_s3_bucket == null ? filebase64sha256(local.lambda_zip) : null + s3_bucket = var.lambda.s3.bucket != null ? var.lambda.s3.bucket : null + s3_key = var.lambda.s3.key != null ? var.lambda.s3.key : null + s3_object_version = var.lambda.s3.object_version != null ? var.lambda.s3.object_version : null + filename = var.lambda.s3.bucket == null ? local.lambda_zip : null + source_code_hash = var.lambda.s3.bucket == null ? filebase64sha256(local.lambda_zip) : null function_name = "${var.prefix}-ssm-housekeeper" role = aws_iam_role.ssm_housekeeper.arn handler = "index.ssmHousekeeper" - runtime = var.lambda_runtime + runtime = var.lambda.runtime timeout = local.ssm_housekeeper.lambda_timeout - tags = merge(local.tags, var.lambda_tags) + tags = merge(local.tags, var.lambda.tags) memory_size = local.ssm_housekeeper.lambda_memory_size - architectures = [var.lambda_architecture] + architectures = [var.lambda.architecture] environment { variables = { ENVIRONMENT = var.prefix - LOG_LEVEL = upper(var.log_level) + LOG_LEVEL = upper(var.observability.log_level) SSM_CLEANUP_CONFIG = jsonencode(local.ssm_housekeeper.config) POWERTOOLS_SERVICE_NAME = "${var.prefix}-ssm-housekeeper" - POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + POWERTOOLS_TRACE_ENABLED = var.observability.tracing.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.observability.tracing.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.observability.tracing.capture_error } } dynamic "vpc_config" { - for_each = var.lambda_subnet_ids != null && var.lambda_security_group_ids != null ? [true] : [] + for_each = var.lambda.subnet_ids != null && var.lambda.security_group_ids != null ? [true] : [] content { - security_group_ids = var.lambda_security_group_ids - subnet_ids = var.lambda_subnet_ids + security_group_ids = var.lambda.security_group_ids + subnet_ids = var.lambda.subnet_ids } } dynamic "tracing_config" { - for_each = var.tracing_config.mode != null ? [true] : [] + for_each = var.observability.tracing.mode != null ? [true] : [] content { - mode = var.tracing_config.mode + mode = var.observability.tracing.mode } } } resource "aws_cloudwatch_log_group" "ssm_housekeeper" { name = "/aws/lambda/${aws_lambda_function.ssm_housekeeper.function_name}" - retention_in_days = var.logging_retention_in_days - kms_key_id = var.logging_kms_key_id - log_group_class = var.log_class + retention_in_days = var.observability.logs.retention_in_days + kms_key_id = var.observability.logs.kms_key_id + log_group_class = var.observability.logs.class tags = var.tags } @@ -87,8 +87,8 @@ resource "aws_iam_role" "ssm_housekeeper" { name = "${substr("${var.prefix}-ssm-hk-lambda", 0, 54)}-${substr(md5("${var.prefix}-ssm-hk-lambda"), 0, 8)}" description = "Lambda role for SSM Housekeeper (${var.prefix})" assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json - path = local.role_path - permissions_boundary = var.role_permissions_boundary + path = local.lambda_role_path + permissions_boundary = var.lambda.role.permissions_boundary tags = local.tags } @@ -105,13 +105,13 @@ resource "aws_iam_role_policy" "ssm_housekeeper_logging" { } resource "aws_iam_role_policy_attachment" "ssm_housekeeper_vpc_execution_role" { - count = length(var.lambda_subnet_ids) > 0 ? 1 : 0 + count = length(var.lambda.subnet_ids) > 0 ? 1 : 0 role = aws_iam_role.ssm_housekeeper.name policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" } resource "aws_iam_role_policy" "ssm_housekeeper_xray" { - count = var.tracing_config.mode != null ? 1 : 0 + count = var.observability.tracing.mode != null ? 1 : 0 name = "xray-policy" policy = data.aws_iam_policy_document.lambda_xray[0].json role = aws_iam_role.ssm_housekeeper.name diff --git a/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf b/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf index 91414be955..7abef22746 100644 --- a/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf +++ b/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -22,43 +22,58 @@ module "external_iam" { instance_profile = { name = "external-runner-${random_id.external.hex}" } - enable_cloudwatch_agent = false - enable_runner_binaries_syncer = false + cloudwatch_agent = { + enabled = false + } + binaries_syncer = { + enabled = false + } } } - runner_iam = { - role = { - arn = "arn:aws:iam::123456789012:role/external-runner-${random_id.external.hex}" + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external-runner-${random_id.external.hex}" + } } } - sqs_build_queue = { - arn = "arn:aws:sqs:eu-west-1:123456789012:computed-external" - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-external" + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:computed-external" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-external" + } } - enable_organization_runners = true - runner_labels = ["self-hosted", "linux", "x64"] - - lambda_s3_bucket = "lambda-artifacts" - runners_lambda_s3_key = "runners.zip" - - github_app_parameters = { - key_base64 = { - name = "/github-runner/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + lambda = { + s3 = { + bucket = "lambda-artifacts" + key = "runners.zip" } - id = { - name = "/github-runner/app-id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } + + github = { + organization_runners = true + app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } } } - ssm_paths = { - root = "/github-runner/computed-external" - tokens = "tokens" - config = "config" + ssm = { + paths = { + root = "/github-runner/computed-external" + tokens = "tokens" + config = "config" + } } } @@ -71,46 +86,61 @@ module "generated_policy" { compute_provider = { type = "ec2" ec2 = { - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - instance_types = ["m5.large"] - enable_cloudwatch_agent = false - enable_runner_binaries_syncer = false + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + cloudwatch_agent = { + enabled = false + } + binaries_syncer = { + enabled = false + } } } - runner_iam = { - managed_policy_arns = { - generated = "arn:aws:iam::123456789012:policy/generated-runner-${random_id.generated_policy.hex}" + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + managed_policy_arns = { + generated = "arn:aws:iam::123456789012:policy/generated-runner-${random_id.generated_policy.hex}" + } } } - sqs_build_queue = { - arn = "arn:aws:sqs:eu-west-1:123456789012:computed-policy" - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-policy" + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:computed-policy" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-policy" + } } - enable_organization_runners = true - runner_labels = ["self-hosted", "linux", "x64"] - - lambda_s3_bucket = "lambda-artifacts" - runners_lambda_s3_key = "runners.zip" - - github_app_parameters = { - key_base64 = { - name = "/github-runner/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + lambda = { + s3 = { + bucket = "lambda-artifacts" + key = "runners.zip" } - id = { - name = "/github-runner/app-id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } + + github = { + organization_runners = true + app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } } } - ssm_paths = { - root = "/github-runner/computed-policy" - tokens = "tokens" - config = "config" + ssm = { + paths = { + root = "/github-runner/computed-policy" + tokens = "tokens" + config = "config" + } } } diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl index ad7a7ddd11..4c65d1db75 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -33,43 +33,59 @@ variables { id_ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id" kms_key_arn = null } - s3_runner_binaries = { - arn = "arn:aws:s3:::my-bucket" - id = "my-bucket" - key = "runners/linux/actions-runner.tar.gz" + binaries_syncer = { + s3 = { + arn = "arn:aws:s3:::my-bucket" + id = "my-bucket" + key = "runners/linux/actions-runner.tar.gz" + } } - enable_ssm_on_runners = true + ssm_enabled = true } } - sqs_build_queue = { - arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + runner = { + labels = ["self-hosted", "linux", "x64"] } - enable_organization_runners = true - runner_labels = ["self-hosted", "linux", "x64"] + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + } + } # Use S3 bucket to avoid filebase64sha256 needing local zip files - lambda_s3_bucket = "my-lambda-bucket" - runners_lambda_s3_key = "runners.zip" + lambda = { + s3 = { + bucket = "my-lambda-bucket" + key = "runners.zip" + } + } - github_app_parameters = { - key_base64 = { name = "/github-runner/key-base64", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" } - id = { name = "/github-runner/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" } + github = { + organization_runners = true + app_parameters = { + key_base64 = { name = "/github-runner/key-base64", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" } + id = { name = "/github-runner/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" } + } } - ssm_paths = { - root = "/github-runner" - tokens = "tokens" - config = "config" + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } } # Enable pool to exercise the pool module and its role type - pool_config = [{ - schedule_expression = "cron(0 8 * * ? *)" - size = 1 - }] + pool = { + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } } run "plan_with_pool_enabled" { @@ -77,7 +93,7 @@ run "plan_with_pool_enabled" { assert { condition = length(module.pool) == 1 - error_message = "Pool module should be enabled when pool_config is non-empty" + error_message = "Pool module should be enabled when pool.config is non-empty" } assert { @@ -139,9 +155,12 @@ run "external_runner_role_is_not_managed_by_common" { command = plan variables { - runner_iam = { - role = { - arn = "arn:aws:iam::123456789012:role/external/runner-role" + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" + } } } } @@ -167,9 +186,12 @@ run "external_runner_role_and_profile_remain_external" { command = plan variables { - runner_iam = { - role = { - arn = "arn:aws:iam::123456789012:role/external/runner-role" + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" + } } } compute_provider = { @@ -181,7 +203,9 @@ run "external_runner_role_and_profile_remain_external" { instance_profile = { name = "external-runner-profile" } - enable_runner_binaries_syncer = false + binaries_syncer = { + enabled = false + } } } } @@ -210,7 +234,9 @@ run "external_profile_requires_external_role" { instance_profile = { name = "external-runner-profile" } - enable_runner_binaries_syncer = false + binaries_syncer = { + enabled = false + } } } } @@ -222,12 +248,15 @@ run "empty_runner_iam_uses_common_role" { command = plan variables { - runner_iam = {} + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = {} + } } assert { condition = length(aws_iam_role.runner) == 1 - error_message = "An empty runner_iam object must use common role ownership." + error_message = "An empty runner.iam object must use common role ownership." } } @@ -235,17 +264,20 @@ run "external_role_rejects_managed_policy_attachments" { command = plan variables { - runner_iam = { - role = { - arn = "arn:aws:iam::123456789012:role/external/runner-role" - } - managed_policy_arns = { - readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" + } + managed_policy_arns = { + readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } } } } - expect_failures = [var.runner_iam] + expect_failures = [var.runner] } run "requires_distribution_object_when_sync_is_enabled" { @@ -255,11 +287,13 @@ run "requires_distribution_object_when_sync_is_enabled" { compute_provider = { type = "ec2" ec2 = { - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - instance_types = ["m5.large"] - enable_runner_binaries_syncer = true - s3_runner_binaries = null + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + binaries_syncer = { + enabled = true + s3 = null + } } } } @@ -283,9 +317,15 @@ run "job_retry_uses_common_lane_identity" { command = plan variables { - runner_name_prefix = "provider-neutral-" + runner = { + labels = ["self-hosted", "linux", "x64"] + name_prefix = "provider-neutral-" + } job_retry = { - enable = true + enabled = true + lambda = { + reserved_concurrent_executions = 2 + } } } @@ -293,4 +333,9 @@ run "job_retry_uses_common_lane_identity" { condition = module.job_retry[0].lambda.function.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "provider-neutral-" error_message = "Job retry must receive the common lane runner-name prefix." } + + assert { + condition = module.job_retry[0].lambda.function.function.reserved_concurrent_executions == 2 + error_message = "Job retry must apply its configured Lambda reserved concurrency." + } } diff --git a/modules/runner-stack/variables.tf b/modules/runner-stack/variables.tf index 8c744b4d51..e4fb4cf6ea 100644 --- a/modules/runner-stack/variables.tf +++ b/modules/runner-stack/variables.tf @@ -3,476 +3,277 @@ variable "aws_region" { type = string } -variable "tags" { - description = "Map of tags that will be added to created resources. By default resources will be tagged with name." - type = map(string) - default = {} +variable "aws_partition" { + description = "AWS partition used to construct ARNs." + type = string + default = "aws" } variable "prefix" { - description = "The prefix used for naming resources" + description = "The prefix used for naming resources." type = string default = "github-actions" } -variable "runner_os" { - description = "Operating system used for runner labels and provider bootstrap (linux, osx, windows)." - type = string - default = "linux" - - validation { - condition = contains(["linux", "osx", "windows"], var.runner_os) - error_message = "Valid values for runner_os are (linux, osx, windows)." - } -} - -variable "sqs_build_queue" { - description = "SQS queue to consume accepted build events." - type = object({ - arn = string - url = string - }) -} - -variable "enable_organization_runners" { - description = "Register runners to organization, instead of repo level" - type = bool +variable "tags" { + description = "Map of tags added to created resources." + type = map(string) + default = {} } -variable "github_app_parameters" { - description = "Parameter Store for GitHub App Parameters." +variable "runner" { + description = "Provider-neutral GitHub runner configuration." type = object({ - key_base64 = map(string) - id = map(string) + os = optional(string, "linux") + architecture = optional(string, "x64") + boot_time_in_minutes = optional(number, 5) + disable_default_labels = optional(bool, false) + labels = list(string) + group_name = optional(string, "Default") + name_prefix = optional(string, "") + run_as_root = optional(bool, false) + run_as = optional(string, "ec2-user") + maximum_count = optional(number, 3) + ephemeral = optional(bool, false) + jit_config_enabled = optional(bool, null) + auto_update_disabled = optional(bool, false) + hooks = optional(object({ + job_started = optional(string, "") + job_completed = optional(string, "") + }), {}) + iam = optional(object({ + role = optional(object({ + arn = string + }), null) + managed_policy_arns = optional(map(string), {}) + path = optional(string, null) + permissions_boundary = optional(string, null) + }), {}) }) -} - -variable "lambda_scale_down_memory_size" { - description = "Memory size limit in MB for scale down lambda." - type = number - default = 512 -} - -variable "scale_down_schedule_expression" { - description = "Scheduler expression to check every x for scale down." - type = string - default = "cron(*/5 * * * ? *)" -} - -variable "minimum_running_time_in_minutes" { - description = "Minimum time a runner should remain active before it can be terminated while idle. If unset, the default is calculated from runner_os." - type = number - default = null -} - -variable "runner_boot_time_in_minutes" { - description = "Minimum time for a compute runner to boot and register." - type = number - default = 5 -} - -variable "runner_disable_default_labels" { - description = "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`." - type = bool - default = false -} - -variable "runner_labels" { - description = "All the labels for the runners (GitHub) including the default one's(e.g: self-hosted, linux, x64, label1, label2). Separate each label by a comma" - type = list(string) -} - -variable "runner_group_name" { - description = "Name of the runner group." - type = string - default = "Default" -} - -variable "lambda_zip" { - description = "File location of the lambda zip file." - type = string - default = null -} - -variable "lambda_timeout_scale_down" { - description = "Time out for the scale down lambda in seconds." - type = number - default = 60 -} - -variable "scale_up_reserved_concurrent_executions" { - description = "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations." - type = number - default = 1 -} - -variable "lambda_scale_up_memory_size" { - description = "Memory size limit in MB for scale-up lambda." - type = number - default = 512 -} - -variable "lambda_timeout_scale_up" { - description = "Time out for the scale up lambda in seconds." - type = number - default = 60 -} - -variable "role_permissions_boundary" { - description = "Permissions boundary that will be added to the created role for the lambda." - type = string - default = null -} - -variable "role_path" { - description = "The path that will be added to the role; if not set, the prefix will be used." - type = string - default = null -} - -variable "runner_as_root" { - description = "Run the action runner under the root user. Variable `runner_run_as` will be ignored." - type = bool - default = false -} - -variable "runner_run_as" { - description = "Run the GitHub actions agent as user." - type = string - default = "ec2-user" -} - -variable "runners_maximum_count" { - description = "The maximum number of runners that will be created. Setting the variable to `-1` desiables the maximum check." - type = number - default = 3 -} - -variable "runner_architecture" { - description = "Platform architecture used for runner labels and provider bootstrap." - type = string - default = "x64" -} - -variable "idle_config" { - description = "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle." - type = list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })) - default = [] -} - -variable "logging_retention_in_days" { - description = "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." - type = number - default = 180 -} - -variable "logging_kms_key_id" { - description = "Specifies the kms key id to encrypt the logs with" - type = string - default = null -} - -variable "log_class" { - description = "The log class of the CloudWatch log groups for the lambda functions. Valid values are `STANDARD` or `INFREQUENT_ACCESS`." - type = string - default = "STANDARD" validation { - condition = contains(["STANDARD", "INFREQUENT_ACCESS"], var.log_class) - error_message = "`log_class` must be either `STANDARD` or `INFREQUENT_ACCESS`." + condition = contains(["linux", "osx", "windows"], var.runner.os) + error_message = "Valid values for runner.os are linux, osx, and windows." } -} -variable "lambda_s3_bucket" { - description = "S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly." - type = string - default = null -} - -variable "runners_lambda_s3_key" { - description = "S3 key for runners lambda function. Required if using S3 bucket to specify lambdas." - type = string - default = null -} - -variable "runners_lambda_s3_object_version" { - description = "S3 object version for runners lambda function. Useful if S3 versioning is enabled on source bucket." - type = string - default = null -} - -variable "aws_partition" { - description = "(optional) partition for the base arn if not 'aws'" - type = string - default = "aws" -} - -variable "ghes_url" { - description = "GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com|" - type = string - default = null -} - -variable "ghes_ssl_verify" { - description = "GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure)." - type = bool - default = true -} - -variable "lambda_subnet_ids" { - description = "List of subnets in which the lambda will be launched, the subnets needs to be subnets in the `vpc_id`." - type = list(string) - default = [] -} - -variable "lambda_security_group_ids" { - description = "List of security group IDs associated with the Lambda function." - type = list(string) - default = [] -} - -variable "kms_key_arn" { - description = "Optional CMK Key ARN to be used for Parameter Store." - type = string - default = null -} - -variable "log_level" { - description = "Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'." - type = string - default = "info" validation { - condition = anytrue([ - var.log_level == "silly", - var.log_level == "trace", - var.log_level == "debug", - var.log_level == "info", - var.log_level == "warn", - var.log_level == "error", - var.log_level == "fatal", - ]) - error_message = "`log_level` value not valid. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'." + condition = length(var.runner.name_prefix) <= 45 + error_message = "runner.name_prefix must be at most 45 characters." } -} -variable "enable_ephemeral_runners" { - description = "Enable ephemeral runners, runners will only be used once." - type = bool - default = false -} - -variable "enable_job_queued_check" { - description = "Only scale if the job event received by the scale up lambda is is in the state queued. By default enabled for non ephemeral runners and disabled for ephemeral. Set this variable to overwrite the default behavior." - type = bool - default = null -} - -variable "pool_lambda_timeout" { - description = "Time out for the pool lambda in seconds." - type = number - default = 60 -} - -variable "pool_lambda_memory_size" { - description = "Lambda Memory size limit in MB for pool lambda" - type = number - default = 512 -} - -variable "pool_runner_owner" { - description = "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported." - type = string - default = null -} - -variable "pool_lambda_reserved_concurrent_executions" { - description = "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations." - type = number - default = 1 -} + validation { + condition = var.runner.iam.role == null ? true : trimspace(var.runner.iam.role.arn) != "" + error_message = "runner.iam.role.arn must be a non-empty ARN when set." + } -variable "pool_config" { - description = "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone ` to override the schedule time zone (defaults to UTC)." - type = list(object({ - schedule_expression = string - schedule_expression_timezone = optional(string) - size = number - })) - default = [] + validation { + condition = var.runner.iam.role == null || length(var.runner.iam.managed_policy_arns) == 0 + error_message = "runner.iam.managed_policy_arns cannot be set with an external runner.iam.role because external roles are not managed by this module." + } } -variable "pool_include_busy_runners" { - description = "Include busy runners in the pool calculation. By default busy runners are not included in the pool." - type = bool - default = false +variable "github" { + description = "GitHub API and registration configuration." + type = object({ + app_parameters = object({ + key_base64 = map(string) + id = map(string) + }) + organization_runners = bool + enterprise_server = optional(object({ + url = optional(string, null) + ssl_verify = optional(bool, true) + }), {}) + user_agent = optional(string, null) + }) } -variable "disable_runner_autoupdate" { - description = "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/)" - type = bool - default = false -} +variable "queue" { + description = "Build queue and Lambda event-source configuration." + type = object({ + build = object({ + arn = string + url = string + }) + event_source_mapping = optional(object({ + batch_size = optional(number, 10) + maximum_batching_window_in_seconds = optional(number, 0) + }), {}) + }) -variable "lambda_runtime" { - description = "AWS Lambda runtime." - type = string - default = "nodejs24.x" -} + validation { + condition = var.queue.event_source_mapping.batch_size >= 1 && var.queue.event_source_mapping.batch_size <= 1000 + error_message = "queue.event_source_mapping.batch_size must be between 1 and 1000." + } -variable "lambda_architecture" { - description = "AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86_64' functions. " - type = string - default = "arm64" validation { - condition = contains(["arm64", "x86_64"], var.lambda_architecture) - error_message = "`lambda_architecture` value is not valid, valid values are: `arm64` and `x86_64`." + condition = var.queue.event_source_mapping.maximum_batching_window_in_seconds >= 0 && var.queue.event_source_mapping.maximum_batching_window_in_seconds <= 300 + error_message = "queue.event_source_mapping.maximum_batching_window_in_seconds must be between 0 and 300." } } -variable "ssm_paths" { - description = "The root path used in SSM to store configuration and secrets." + +variable "lambda" { + description = "Configuration shared by the control-plane Lambda functions." type = object({ - root = string - tokens = string - config = string + zip = optional(string, null) + s3 = optional(object({ + bucket = optional(string, null) + key = optional(string, null) + object_version = optional(string, null) + }), {}) + runtime = optional(string, "nodejs24.x") + architecture = optional(string, "arm64") + subnet_ids = optional(list(string), []) + security_group_ids = optional(list(string), []) + tags = optional(map(string), {}) + role = optional(object({ + path = optional(string, null) + permissions_boundary = optional(string, null) + }), {}) }) -} + default = {} -variable "runner_name_prefix" { - description = "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'." - type = string - default = "" validation { - condition = length(var.runner_name_prefix) <= 45 - error_message = "The prefix used for the GitHub runner name must be less than 32 characters. AWS instances id are 17 chars, https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/resource-ids.html" + condition = contains(["arm64", "x86_64"], var.lambda.architecture) + error_message = "lambda.architecture must be arm64 or x86_64." } } -variable "tracing_config" { - description = "Configuration for lambda tracing." +variable "scale_up" { + description = "Scale-up Lambda configuration." type = object({ - mode = optional(string, null) - capture_http_requests = optional(bool, false) - capture_error = optional(bool, false) + memory_size = optional(number, 512) + timeout = optional(number, 60) + reserved_concurrent_executions = optional(number, 1) + job_queued_check_enabled = optional(bool, null) }) default = {} } - -variable "enable_jit_config" { - description = "Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI." - type = bool - default = null -} - -variable "ssm_housekeeper" { - description = <= 1 && var.lambda_event_source_mapping_batch_size <= 1000 - error_message = "The batch size for the lambda event source mapping must be between 1 and 1000." + condition = contains(["STANDARD", "INFREQUENT_ACCESS"], var.observability.logs.class) + error_message = "observability.logs.class must be STANDARD or INFREQUENT_ACCESS." } -} -variable "lambda_event_source_mapping_maximum_batching_window_in_seconds" { - description = "Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch_size is greater than 10. Defaults to 0." - type = number - default = 0 validation { - condition = var.lambda_event_source_mapping_maximum_batching_window_in_seconds >= 0 && var.lambda_event_source_mapping_maximum_batching_window_in_seconds <= 300 - error_message = "Maximum batching window must be between 0 and 300 seconds." + condition = contains([ + "silly", + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + ], var.observability.log_level) + error_message = "observability.log_level must be one of silly, trace, debug, info, warn, error, or fatal." } } - -variable "parameter_store_tags" { - description = "Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function." - type = map(string) - default = {} -} From 3b3bea26851d446675f5caa0ed75a237ae2e6403 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:18:49 +0000 Subject: [PATCH 15/27] docs: auto update terraform docs --- modules/lambda/README.md | 10 +++++----- modules/multi-runner/README.md | 16 ++++++++-------- modules/runner-stack/README.md | 14 +++++++------- modules/runner-stack/job-retry/README.md | 14 +++++++------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/modules/lambda/README.md b/modules/lambda/README.md index 99266e9b63..6001519724 100644 --- a/modules/lambda/README.md +++ b/modules/lambda/README.md @@ -8,14 +8,14 @@ Generic module to create lambda functions ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | ## Modules @@ -25,7 +25,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_log_group.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.lambda_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -38,12 +38,12 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [lambda](#input\_lambda) | Configuration for the lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`handler`: The entrypoint for the lambda.
`principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`.
`memory_size`: Memory size limit in MB of the lambda.
`reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
`metrics_namespace`: Namespace for the metrics emitted by the lambda.
`name`: The name of the lambda function.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, "aws")
architecture = optional(string, "arm64")
environment_variables = optional(map(string), {})
handler = string
lambda_tags = optional(map(string), {})
log_level = optional(string, "info")
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, 180)
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, null)
metrics_namespace = optional(string, "GitHub Runners")
name = string
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, "nodejs24.x")
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
tags = optional(map(string), {})
timeout = optional(number, 60)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [lambda](#output\_lambda) | n/a | diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index d2440f5a4a..ce5492600c 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -115,7 +115,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -123,14 +123,14 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | -| [random](#provider\_random) | 3.9.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | +| [random](#provider\_random) | ~> 3.0 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -142,7 +142,7 @@ module "multi-runner" { ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -153,7 +153,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | | [ami\_housekeeper\_lambda\_s3\_key](#input\_ami\_housekeeper\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | @@ -237,7 +237,7 @@ module "multi-runner" { ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index da22135640..f607160a2d 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -53,20 +53,20 @@ yarn run dist ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [ec2\_runner\_role](#module\_ec2\_runner\_role) | ../compute-providers/ec2/runner-role | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | @@ -75,7 +75,7 @@ yarn run dist ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | @@ -131,7 +131,7 @@ yarn run dist ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | | [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings must remain inside the selected provider block. |
object({
type = string

ec2 = optional(object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(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)
enable_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)
}), null)
})
| n/a | yes | @@ -151,7 +151,7 @@ yarn run dist ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [lambda\_pool](#output\_lambda\_pool) | n/a | | [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a | | [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a | diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md index a7f20c4527..85ccad7693 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/runner-stack/job-retry/README.md @@ -11,26 +11,26 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.21 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [job\_retry](#module\_job\_retry) | ../../lambda | n/a | ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_iam_role_policy.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_lambda_event_source_mapping.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | | [aws_lambda_permission.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | @@ -42,13 +42,13 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runner_name_prefix`: Prefix used to identify runners belonging to this lane.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
reserved_concurrent_executions = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | n/a | | [lambda](#output\_lambda) | n/a | From be5a2134a9a04652e2901d7ea9d2a6da9a5bc970 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 14:21:15 +0200 Subject: [PATCH 16/27] refactor(runner-stack): make runner role provider neutral --- modules/runner-stack/runner-role.tf | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/modules/runner-stack/runner-role.tf b/modules/runner-stack/runner-role.tf index 276ead2437..6afeae5867 100644 --- a/modules/runner-stack/runner-role.tf +++ b/modules/runner-stack/runner-role.tf @@ -15,7 +15,11 @@ module "ec2_runner_role" { locals { provider_runner_role = one(module.ec2_runner_role[*]) - create_runner_role = local.provider_type == "ec2" && var.runner.iam.role == null + + # Role ownership belongs to the common stack. The selected compute provider + # contributes its trust and permission documents, but does not decide whether + # the role is created. + create_runner_role = var.runner.iam.role == null runner_role = { arn = local.create_runner_role ? one(aws_iam_role.runner[*].arn) : var.runner.iam.role.arn @@ -47,7 +51,7 @@ resource "aws_iam_role" "runner" { lifecycle { precondition { - condition = local.ec2.instance_profile == null || var.runner.iam.role != null + condition = try(local.ec2.instance_profile, null) == null || var.runner.iam.role != null error_message = "runner.iam.role must be set when compute_provider.ec2.instance_profile selects an external instance profile." } } From b087a5459b00b74fa2159a7901b6b4eada11c3e5 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 14:30:09 +0200 Subject: [PATCH 17/27] fix(runner-stack): include common tags in SSM parameters --- modules/runner-stack/common-config.tf | 2 +- modules/runner-stack/tests/pool.tftest.hcl | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/modules/runner-stack/common-config.tf b/modules/runner-stack/common-config.tf index 919848c6df..031cefa3a5 100644 --- a/modules/runner-stack/common-config.tf +++ b/modules/runner-stack/common-config.tf @@ -19,7 +19,7 @@ locals { arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm.paths.root}/${var.ssm.paths.config}" parameter_store_tags = jsonencode([ - for key, value in merge(var.tags, var.ssm.parameter_tags) : { + for key, value in merge(local.tags, var.ssm.parameter_tags) : { Key = key Value = value } diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl index 4c65d1db75..749aab40cc 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -111,6 +111,17 @@ run "plan_with_pool_enabled" { error_message = "The common runner stack must create and expose the runner role." } + assert { + condition = tomap({ + for tag in jsondecode(aws_lambda_function.scale_up.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + tag.Key => tag.Value + }) == tomap({ + Name = "github-actions-action-runner" + "ghr:ssm_config_path" = "/github-runner/config" + }) + error_message = "Parameter Store tags must include the normalized common stack tags." + } + assert { condition = !contains(keys(output.provider.ec2), "role_runner") error_message = "The common runner role must not be duplicated in the EC2 resource output." From ec49f9bcb5188ce8007a9f24c13bb454eb95ffe7 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 16:22:16 +0200 Subject: [PATCH 18/27] refactor(multi-runner): finalize compute provider boundary --- docs/compute-provider-refactor.md | 147 +++++ docs/index.md | 2 +- .../src/pool/pool-contract.test.ts | 4 +- .../scale-runners/scale-down-contract.test.ts | 4 +- .../scale-runners/scale-up-contract.test.ts | 6 +- .../test/runner-provider-contracts/pool.ts | 8 +- .../runner-provider-contracts/scale-down.ts | 8 +- .../runner-provider-contracts/scale-up.ts | 18 +- .../templates/provider/README.md | 8 +- .../templates/provider/provider.test.ts | 2 +- mkdocs.yaml | 1 + modules/compute-providers/ec2/README.md | 48 +- .../compute-providers/ec2/control-plane.tf | 26 +- .../compute-providers/ec2/instance-profile.tf | 2 +- modules/compute-providers/ec2/logging.tf | 4 +- modules/compute-providers/ec2/outputs.tf | 8 +- .../compute-providers/ec2/runner-config.tf | 4 +- .../compute-providers/ec2/runner-instances.tf | 90 +-- .../ec2/tests/provider.tftest.hcl | 148 ++++- modules/compute-providers/ec2/variables.tf | 165 +++-- modules/lambda/README.md | 12 +- modules/lambda/main.tf | 2 +- modules/lambda/variables.tf | 82 +-- modules/multi-runner/README.md | 34 +- modules/multi-runner/main.tf | 2 +- modules/multi-runner/multi-runner-config.tf | 78 ++- modules/multi-runner/outputs.tf | 8 +- modules/multi-runner/queues.tf | 12 +- modules/multi-runner/runners.tf | 148 ++--- .../tests/provider-routing.tftest.hcl | 454 ++++++++++---- .../multi-runner/variables.experimental.tf | 589 +++++++++++------- modules/multi-runner/variables.tf | 2 +- modules/runner-stack/README.md | 44 +- modules/runner-stack/common-config.tf | 42 +- .../compute-provider-variables.tf | 117 +++- modules/runner-stack/ec2.tf | 3 +- modules/runner-stack/job-retry.tf | 10 +- modules/runner-stack/job-retry/README.md | 14 +- .../runner-stack/job-retry/iam-policies.tf | 4 +- modules/runner-stack/job-retry/job-retry.tf | 5 +- .../job-retry/tests/job-retry.tftest.hcl | 77 +++ modules/runner-stack/job-retry/variables.tf | 107 ++-- modules/runner-stack/pool.tf | 9 +- modules/runner-stack/pool/README.md | 16 +- modules/runner-stack/pool/iam-policies.tf | 4 +- modules/runner-stack/pool/pool.tf | 2 +- .../pool/tests/provider.tftest.hcl | 13 +- modules/runner-stack/pool/variables.tf | 85 ++- modules/runner-stack/runner-role.tf | 2 +- modules/runner-stack/runner-ssm-parameters.tf | 8 +- .../runner-stack/scale-down-iam-policies.tf | 4 +- modules/runner-stack/scale-down.tf | 12 +- modules/runner-stack/scale-up-iam-policies.tf | 4 +- modules/runner-stack/scale-up.tf | 12 +- modules/runner-stack/ssm-housekeeper.tf | 10 +- .../tests/computed-iam-inputs.tftest.hcl | 4 +- .../computed-iam-inputs.tf | 28 +- modules/runner-stack/tests/pool.tftest.hcl | 24 +- modules/runner-stack/tests/tags.tftest.hcl | 311 +++++++++ modules/runner-stack/variables.tf | 182 +++++- 60 files changed, 2456 insertions(+), 823 deletions(-) create mode 100644 docs/compute-provider-refactor.md create mode 100644 modules/runner-stack/job-retry/tests/job-retry.tftest.hcl create mode 100644 modules/runner-stack/tests/tags.tftest.hcl diff --git a/docs/compute-provider-refactor.md b/docs/compute-provider-refactor.md new file mode 100644 index 0000000000..4e46c69984 --- /dev/null +++ b/docs/compute-provider-refactor.md @@ -0,0 +1,147 @@ +# Experimental compute-provider refactor + +!!! warning "Experimental opt-in" + + The provider-oriented Terraform interface is experimental. It is enabled only by setting `experimental.multi_runner_config_v2`. Its schema can change before it becomes stable. Existing `multi_runner_config` deployments do not opt in and continue to use the legacy implementation. + +## Why this refactor exists + +The scale-up, scale-down, pool, job-retry, queue, SSM housekeeping, and GitHub registration workflows are not inherently EC2-specific. The legacy `runners` module combines that common control plane with EC2 launch templates, instance profiles, bootstrap parameters, log groups, IAM permissions, and Lambda environment variables. Adding another compute provider in that structure would require copying common behavior or adding provider conditionals throughout the module. + +The refactor introduces a provider boundary so a future microVM or other backend can reuse the control plane. Only the policy statements, environment variables, and resources required by the selected compute provider should change. + +## Ownership model + +The implementation is split into three layers: + +| Layer | Owns | +| --- | --- | +| `multi-runner` | Stable-to-canonical normalization, configuration keys, build queues, webhook matching, and runner-binary discovery. | +| `runner-stack` | Scale-up, scale-down, pool, job retry, SSM housekeeper, common Lambda roles and policies, the runner role, and provider dispatch. | +| `compute-providers/` | Provider-specific resources, runner-role policy requirements, and the IAM and environment-variable fragments consumed by the common control plane. | + +The EC2 provider currently owns the instance profile, launch template, security group, AMI and bootstrap parameters, runner log groups, EC2 policy statements, and EC2 Lambda environment variables. EC2 is the only implemented Terraform compute provider today. + +The common stack creates or selects the runner IAM role. A provider supplies the trust policy, inline policy documents, and optional managed-policy requirements; the common stack attaches them. This keeps role ownership provider-neutral while allowing each compute provider to define its permissions. + +## Phase 1 dispatch and compatibility + +Phase 1 accepts stable and experimental configurations together, provided their keys do not overlap. + +```mermaid +flowchart TD + Stable["multi_runner_config"] --> Normalize["Canonical internal configuration"] + Experimental["experimental.multi_runner_config_v2"] --> Normalize + Normalize --> Shared["Queues, webhook matching, binary discovery"] + Stable --> Legacy["module.runners[configuration]"] + Experimental --> Stack["module.runner_stacks[configuration]"] + Stack --> Provider["compute-providers/ec2"] +``` + +Stable input is translated once into the canonical internal shape so shared resources can consume one representation. That translation does not change stable runner dispatch: + +- A key present in `multi_runner_config` continues to call `modules/runners` at its historical `module.runners["configuration"]` address. +- The stable module call receives the original v1 values for compatibility-sensitive inputs. +- Stable queue tagging and the flat `runners_map` output remain unchanged. +- A key present in `experimental.multi_runner_config_v2` calls `modules/runner-stack` at `module.runner_stacks["configuration"]`. +- Duplicate keys are rejected instead of silently changing a module address or output shape. + +No state move is included in phase 1. Moving an existing key from the stable map to the experimental map changes its implementation address and must wait for the documented state-migration phase. + +## Opting in + +Only configurations inside the nested experimental object use the provider-oriented stack: + +```hcl +module "multi_runner" { + source = "github-aws-runners/github-runner/aws//modules/multi-runner" + + # Existing configurations remain on modules/runners. + multi_runner_config = { + existing = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 2 + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + + # Setting this nested map is the explicit experimental opt-in. + experimental = { + multi_runner_config_v2 = { + arm = { + runner = { + os = "linux" + architecture = "arm64" + maximum_count = 2 + } + + compute_provider = { + type = "ec2" + ec2 = { + instance_types = ["m7g.large"] + } + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "arm64"]] + } + } + } + } +} +``` + +## Inputs, tags, and outputs + +The v2 object groups provider-neutral settings by owner: `runner`, `github`, `queue`, `lambda`, `scale_up`, `scale_down`, `pool`, `job_retry`, `ssm`, and `observability`. Backend settings live only under `compute_provider.`. + +Tags follow the same ownership model. Module tags are defaults; shared Lambda, queue, and log-group tags override those defaults; component and subcomponent tags are applied last. EC2 runtime tags belong under `compute_provider.ec2.tags`. The EC2 bootstrap tags required by the runner are protected inside the provider and are not propagated to common resources. + +Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and shared log-group tags. + +Stable `runners_map` entries retain their flat output fields. Experimental entries expose common resources at the entry level and provider-specific resources under `provider.`. For EC2, launch-template and runner-log artifacts are under `runners_map["configuration"].provider.ec2`. + +## Plan-time ownership wrappers + +Terraform must know resource and dynamic-block shape during planning, even when an ARN is produced by another resource and remains unknown until apply. Optional inputs that enable IAM policies therefore use a caller-known object as the discriminator and keep the computed value in an `arn` leaf. The relevant configuration fragments are: + +```hcl +ssm = { + kms_key = { + arn = aws_kms_key.runner_parameters.arn + } +} + +compute_provider = { + type = "ec2" + ec2 = { + ami = { + id_ssm_parameter = { + arn = aws_ssm_parameter.runner_ami.arn + } + kms_key = { + arn = aws_kms_key.runner_ami.arn + } + } + } +} +``` + +The object literal tells Terraform that the corresponding policy exists; its `arn` may safely be computed. Values such as `observability.logs.kms_key_id`, which configure an existing resource without changing graph shape, remain nullable scalar inputs. + +For experimental multi-runner entries, set `ssm.kms_key` to the key that encrypts the shared GitHub App and runner parameters. The stable root `kms_key_arn` input continues to serve v1 and is not used as a graph-shape discriminator for v2. + +## Migration phases + +1. **Phase 1 — experimental opt-in:** Run stable and experimental configurations side by side. Stable resources and addresses do not move. +2. **Phase 2 — translate and migrate:** Deprecate the stable input, dispatch its translated representation through `runner-stack`, and provide tested `moved` blocks plus commands for addresses Terraform cannot move declaratively. +3. **Phase 3 — remove v1:** After a release window in which phase 2 is available, remove the stable input and flat output adapter in a breaking release. +4. **Future — retire `modules/runners`:** Handle direct consumers of the legacy module in a separate deprecation and migration effort. + +A future compute provider must implement the same control-plane and runner-role contracts before it can be selected in Terraform. Adding a discriminator value without those resources is intentionally rejected. diff --git a/docs/index.md b/docs/index.md index d1d0772985..bc356a217d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,7 +101,7 @@ Besides these permissions, the lambdas also need permission to CloudWatch (for l ## Terraform main modules -Currently we support two main modules. The existing `runners` module remains the stable EC2 implementation, and the `multi-runner` module creates multiple runner lanes in one deployment. Stable `multi_runner_config` lanes continue to use the unchanged `runners` module. Experimental `multi_runner_config_v2` lanes use the new provider-oriented `runner-stack`, where the common control plane owns scale-up, scale-down, pool, retry, housekeeper, Lambda roles, and the runner role and attachments. The EC2 provider supplies EC2-specific policy requirements and owns the instance profile, launch template, bootstrap resources, and runner log groups. Phase 1 supports non-overlapping v1 and v2 lanes together without moving legacy state; later releases will translate v1, ship state migration, and only then remove the v1 interface. See the [multi-runner v2 migration roadmap](modules/public/multi-runner.md#multi-runner-v2-migration-roadmap). EC2 is the only active Terraform-managed provider; microVM, CodeBuild, and other provider modules are future work. +Currently we support two main modules. The existing `runners` module remains the stable EC2 implementation, and the `multi-runner` module creates multiple runner configurations in one deployment. Stable `multi_runner_config` entries continue to use the unchanged `runners` module. Entries under `experimental.multi_runner_config_v2` use the new provider-oriented `runner-stack`, where the common control plane owns scale-up, scale-down, pool, retry, housekeeper, Lambda roles, and the runner role and attachments. The EC2 provider supplies EC2-specific policy requirements and owns the instance profile, launch template, bootstrap resources, and runner log groups. Phase 1 supports non-overlapping v1 and v2 configurations together without moving legacy state; later releases will translate v1, ship state migration, and only then remove the v1 interface. See the [experimental compute-provider refactor](compute-provider-refactor.md) and [multi-runner v2 migration roadmap](modules/public/multi-runner.md#multi-runner-v2-migration-roadmap). EC2 is the only active Terraform-managed provider; microVM, CodeBuild, and other provider modules are future work. Both modules are built on top of the same base modules. When using the multi-runner module you can deploy different runners with only one deployment. diff --git a/lambdas/functions/control-plane/src/pool/pool-contract.test.ts b/lambdas/functions/control-plane/src/pool/pool-contract.test.ts index e7c5eee21a..ec0c803839 100644 --- a/lambdas/functions/control-plane/src/pool/pool-contract.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool-contract.test.ts @@ -35,7 +35,7 @@ const githubClient = { const cleanEnv = process.env; -const lanes = providerTypes.map((type) => ({ +const computeProviders = providerTypes.map((type) => ({ provider: { type, listRunners: vi.fn(), @@ -68,7 +68,7 @@ beforeEach(() => { definePoolContractTests({ adjust, + computeProviders, githubInstallationClient: githubClient, - lanes, resolveCapability: mockedResolveCapability, }); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down-contract.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down-contract.test.ts index ade06747e4..66b56c8087 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down-contract.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down-contract.test.ts @@ -11,7 +11,7 @@ const mockedResolveCapability = vi.spyOn(controlPlaneProviderRegistry, 'capabili const cleanEnv = process.env; -const lanes = providerTypes.map((type) => ({ +const computeProviders = providerTypes.map((type) => ({ provider: { type, list: vi.fn(), @@ -28,7 +28,7 @@ beforeEach(() => { }); defineScaleDownContractTests({ - lanes, + computeProviders, resolveCapability: mockedResolveCapability, scaleDown, }); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts index 257f9907ca..5c5bfa2a1d 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts @@ -43,14 +43,14 @@ const payloads: ActionRequestMessageSQS[] = [ const cleanEnv = process.env; -const lanes = providerTypes.map((type) => ({ +const computeProviders = providerTypes.map((type) => ({ provider: { type, resolveLabelsForRunners: vi.fn(), getCurrentRunners: vi.fn(), createRunners: vi.fn(), } satisfies ScaleUpRunnerProvider, - state: { lane: type }, + state: { computeProvider: type }, })); beforeEach(() => { @@ -75,9 +75,9 @@ beforeEach(() => { }); defineScaleUpContractTests({ + computeProviders, createPayloads: () => structuredClone(payloads), githubInstallationClient: githubClient, - lanes, resolveCapability: mockedResolveCapability, scaleUp, }); diff --git a/lambdas/functions/control-plane/src/test/runner-provider-contracts/pool.ts b/lambdas/functions/control-plane/src/test/runner-provider-contracts/pool.ts index 9997e51aba..5caa39b240 100644 --- a/lambdas/functions/control-plane/src/test/runner-provider-contracts/pool.ts +++ b/lambdas/functions/control-plane/src/test/runner-provider-contracts/pool.ts @@ -6,24 +6,24 @@ import type { PoolRunnerProvider } from '../../pool/pool-provider'; type TestPoolProvider = Omit & { type: TType }; -export interface PoolContractLane { +export interface PoolContractProvider { provider: TestPoolProvider; } interface PoolContractOptions { adjust: (event: PoolEvent) => Promise; githubInstallationClient: Octokit; - lanes: readonly PoolContractLane[]; + computeProviders: readonly PoolContractProvider[]; resolveCapability: MockInstance<(type: TType, capability: 'pool') => () => Omit, 'type'>>; } export function definePoolContractTests({ adjust, + computeProviders, githubInstallationClient, - lanes, resolveCapability, }: PoolContractOptions): void { - describe.each(lanes.map((lane) => [lane.provider.type, lane] as const))( + describe.each(computeProviders.map((computeProvider) => [computeProvider.provider.type, computeProvider] as const))( '%s pool orchestration contract', (_, { provider }) => { beforeEach(() => { diff --git a/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-down.ts b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-down.ts index 451e33f9e1..8d76a755fd 100644 --- a/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-down.ts +++ b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-down.ts @@ -4,12 +4,12 @@ import type { ScaleDownRunnerProvider } from '../../scale-runners/types'; type TestScaleDownProvider = Omit & { type: TType }; -export interface ScaleDownContractLane { +export interface ScaleDownContractProvider { provider: TestScaleDownProvider; } interface ScaleDownContractOptions { - lanes: readonly ScaleDownContractLane[]; + computeProviders: readonly ScaleDownContractProvider[]; resolveCapability: MockInstance< (type: TType, capability: 'scaleDown') => () => Omit, 'type'> >; @@ -17,11 +17,11 @@ interface ScaleDownContractOptions { } export function defineScaleDownContractTests({ - lanes, + computeProviders, resolveCapability, scaleDown, }: ScaleDownContractOptions): void { - describe.each(lanes.map((lane) => [lane.provider.type, lane] as const))( + describe.each(computeProviders.map((computeProvider) => [computeProvider.provider.type, computeProvider] as const))( '%s scale-down orchestration contract', (_, { provider }) => { beforeEach(() => { diff --git a/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-up.ts b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-up.ts index 804c8a2fbb..ac45d49e10 100644 --- a/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-up.ts +++ b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-up.ts @@ -5,15 +5,15 @@ import type { ActionRequestMessageSQS, ScaleUpRunnerProvider } from '../../scale type TestScaleUpProvider = Omit & { type: TType }; -export interface ScaleUpContractLane { +export interface ScaleUpContractProvider { provider: TestScaleUpProvider; state: unknown; } interface ScaleUpContractOptions { createPayloads: () => ActionRequestMessageSQS[]; + computeProviders: readonly ScaleUpContractProvider[]; githubInstallationClient: Octokit; - lanes: readonly ScaleUpContractLane[]; resolveCapability: MockInstance< (type: TType, capability: 'scaleUp') => () => Omit, 'type'> >; @@ -27,13 +27,13 @@ const createResult = { }; export function defineScaleUpContractTests({ + computeProviders, createPayloads, githubInstallationClient, - lanes, resolveCapability, scaleUp, }: ScaleUpContractOptions): void { - describe.each(lanes.map((lane) => [lane.provider.type, lane] as const))( + describe.each(computeProviders.map((computeProvider) => [computeProvider.provider.type, computeProvider] as const))( '%s scale-up orchestration contract', (_, { provider, state }) => { beforeEach(() => { @@ -47,14 +47,14 @@ export function defineScaleUpContractTests({ vi.mocked(provider.createRunners).mockResolvedValue(createResult); }); - it('forwards the prepared lane state through runner lookup and creation', async () => { + it('forwards the prepared compute-provider state through runner lookup and creation', async () => { const payloads = createPayloads(); - payloads[0].labels = ['lane-label']; + payloads[0].labels = ['compute-provider-label']; await scaleUp(payloads); expect(resolveCapability).toHaveBeenCalledWith(provider.type, 'scaleUp'); - expect(provider.resolveLabelsForRunners).toHaveBeenCalledWith(['lane-label']); + expect(provider.resolveLabelsForRunners).toHaveBeenCalledWith(['compute-provider-label']); expect(provider.getCurrentRunners).toHaveBeenCalledWith(state, { runnerOwner: payloads[0].repositoryOwner, runnerType: 'Org', @@ -68,7 +68,7 @@ export function defineScaleUpContractTests({ ); }); - it('does not query current runners when the lane has unlimited capacity', async () => { + it('does not query current runners when the compute provider has unlimited capacity', async () => { process.env.RUNNERS_MAXIMUM_COUNT = '-1'; const payloads = createPayloads(); payloads.push({ ...payloads[0], id: 2, messageId: 'message-2' }); @@ -79,7 +79,7 @@ export function defineScaleUpContractTests({ expect(provider.createRunners).toHaveBeenCalledWith(expect.objectContaining({ numberOfRunners: 2 })); }); - it('does not create runners when the lane has reached maximum capacity', async () => { + it('does not create runners when the compute provider has reached maximum capacity', async () => { process.env.RUNNERS_MAXIMUM_COUNT = '1'; vi.mocked(provider.getCurrentRunners).mockResolvedValue(1); diff --git a/lambdas/libs/runner-providers/templates/provider/README.md b/lambdas/libs/runner-providers/templates/provider/README.md index 27fbc09086..dce666020a 100644 --- a/lambdas/libs/runner-providers/templates/provider/README.md +++ b/lambdas/libs/runner-providers/templates/provider/README.md @@ -1,17 +1,17 @@ # Runner provider template Copy this directory to the appropriate provider namespace, for example -`aws/codebuild`, and replace `template` with the new lane type. +`aws/codebuild`, and replace `template` with the new compute-provider type. The template is compile-checked but intentionally not registered. A provider has separate webhook and control-plane entry points so each Lambda bundles only -the code it uses. To enable a completed provider, add its lane type to +the code it uses. To enable a completed provider, add its compute-provider type to `provider-types.ts`, then register each entry point in its matching file: - `providers.config.webhook.ts` - `providers.config.control-plane.ts` -Each entry point exports its module as `provider`. Alias that export to the lane +Each entry point exports its module as `provider`. Alias that export to the compute-provider name when enabling it, for example: ```ts @@ -21,7 +21,7 @@ import { provider as codebuild } from './aws/codebuild/webhook'; Implement every capability before registering the provider: - `pool`: list managed runners, count available runners, and create runners. -- `scaleUp`: prepare lane state, count current runners, and create runners. +- `scaleUp`: prepare compute-provider state, count current runners, and create runners. - `scaleDown`: list, inspect, mark, unmark, and terminate runners. - `dynamicLabels`: select a webhook dispatch target for supported labels. diff --git a/lambdas/libs/runner-providers/templates/provider/provider.test.ts b/lambdas/libs/runner-providers/templates/provider/provider.test.ts index d449de3947..4c1f099701 100644 --- a/lambdas/libs/runner-providers/templates/provider/provider.test.ts +++ b/lambdas/libs/runner-providers/templates/provider/provider.test.ts @@ -3,7 +3,7 @@ import { expect, it, vi } from 'vitest'; import { provider as controlPlaneProvider } from './control-plane'; import { provider as webhookProvider } from './webhook'; -it('exposes every runner provider capability from its lane entry point', () => { +it('exposes every runner provider capability from its compute-provider entry point', () => { const controlPlanePlugin = controlPlaneProvider.createPlugin(vi.fn(async () => [])); const pool = controlPlanePlugin.capabilities.pool(); const scaleUp = controlPlanePlugin.capabilities.scaleUp(); diff --git a/mkdocs.yaml b/mkdocs.yaml index 9b98e84a36..ae558a120c 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -61,6 +61,7 @@ nav: - Runners (main): modules/runners.md - Submodules (public): - Multi Runners: modules/public/multi-runner.md + - Compute provider refactor (experimental): compute-provider-refactor.md - AMI Housekeeper: modules/public/ami-housekeeper.md - Lambda Downloader: modules/public/download-lambda.md - Setup IAM permissions: modules/public/setup-iam-permissions.md diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index 7cb4f698df..f632554bda 100644 --- a/modules/compute-providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -10,14 +10,14 @@ EC2 is the only active compute provider. A future provider must implement the sa ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -27,7 +27,7 @@ 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 | @@ -38,7 +38,6 @@ No modules. | [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 | | [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.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 | @@ -48,71 +47,72 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.

Parameters:
- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })
- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs
- `id_ssm_parameter_name`: Name of an SSM parameter containing the AMI ID. If specified, this overrides the AMI filter
- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name
- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key

Defaults to null, in which case the module falls back to individual AMI variables (deprecated). |
object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
})
| `null` | no | +| ---- | ----------- | ---- | ------- | :------: | +| [ami](#input\_ami) | AMI selection and encryption configuration for runner instances. Null selects the default AMI configuration for `runner_os`.

- `filter`: AMI filter names mapped to accepted values. These values are merged over the default filter for `runner_os`.
- `owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter from the selected AMI. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource.
- `id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `kms_key`: Optional customer-managed KMS key required to launch an encrypted AMI or snapshot. The wrapper's presence is the plan-time policy discriminator.
- `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply. |
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` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | -| [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | -| [block\_device\_mappings](#input\_block\_device\_mappings) | The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`. |
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
}
]
| no | +| [block\_device\_mappings](#input\_block\_device\_mappings) | EBS block-device mappings added to the runner launch template.

- `delete_on_termination`: Deletes the EBS volume when its runner instance terminates.
- `device_name`: Device name exposed to the runner instance.
- `encrypted`: Enables encryption for the EBS volume.
- `iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `kms_key_id`: KMS key ID or ARN used to encrypt the EBS volume.
- `snapshot_id`: Snapshot used to initialize the EBS volume.
- `throughput`: Provisioned throughput in MiB/s for volume types that support configurable throughput.
- `volume_initialization_rate`: Fixed volume initialization rate in MiB/s for supported snapshot-backed volumes.
- `volume_size`: EBS volume size in GiB.
- `volume_type`: EBS volume type. |
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
}
]
| no | | [cloudwatch\_config](#input\_cloudwatch\_config) | (optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details. | `string` | `null` | no | -| [cpu\_options](#input\_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 | +| [cpu\_options](#input\_cpu\_options) | CPU topology and processor-feature configuration for runner instances. Not all instance types support these options.

- `core_count`: Number of CPU cores exposed to the runner instance.
- `threads_per_core`: Number of hardware threads exposed per CPU core.
- `amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `nested_virtualization`: Enables or disables nested virtualization on supported instance types. |
object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
})
| `null` | no | | [create\_service\_linked\_role\_spot](#input\_create\_service\_linked\_role\_spot) | (optional) create the service linked role for spot instances that is required by the scale-up lambda. | `bool` | `false` | no | | [credit\_specification](#input\_credit\_specification) | The credit option for CPU usage of a T instance. Can be unset, "standard" or "unlimited". | `string` | `null` | no | | [ebs\_optimized](#input\_ebs\_optimized) | The EC2 EBS optimized configuration. | `bool` | `false` | no | -| [egress\_rules](#input\_egress\_rules) | List of egress rules for the GitHub runner instances. |
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"
],
"description": null,
"from_port": 0,
"ipv6_cidr_blocks": [
"::/0"
],
"prefix_list_ids": null,
"protocol": "-1",
"security_groups": null,
"self": null,
"to_port": 0
}
]
| no | +| [egress\_rules](#input\_egress\_rules) | Egress rules created on the provider-managed runner security group.

- `cidr_blocks`: IPv4 CIDR destinations allowed by the rule.
- `ipv6_cidr_blocks`: IPv6 CIDR destinations allowed by the rule.
- `prefix_list_ids`: AWS prefix-list destinations allowed by the rule.
- `from_port`: First destination port in the permitted range.
- `protocol`: IP protocol name or number. Use `-1` for all protocols.
- `security_groups`: Destination security-group IDs allowed by the rule.
- `self`: Allows traffic to the managed runner security group itself when true.
- `to_port`: Last destination port in the permitted range.
- `description`: Optional description assigned to the security-group rule. |
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"
],
"description": null,
"from_port": 0,
"ipv6_cidr_blocks": [
"::/0"
],
"prefix_list_ids": null,
"protocol": "-1",
"security_groups": null,
"self": null,
"to_port": 0
}
]
| no | | [enable\_cloudwatch\_agent](#input\_enable\_cloudwatch\_agent) | Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`. | `bool` | `true` | no | | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | | [enable\_on\_demand\_failover\_for\_errors](#input\_enable\_on\_demand\_failover\_for\_errors) | Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later. | `list(string)` | `[]` | no | -| [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI. | `bool` | `true` | no | +| [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Uses a synchronized GitHub runner distribution from `s3_runner_binaries` during bootstrap. Disable this when the runner distribution is already present in a prebuilt AMI. This module does not create the synchronization Lambda. | `bool` | `true` | no | | [enable\_runner\_detailed\_monitoring](#input\_enable\_runner\_detailed\_monitoring) | Enable detailed monitoring for runners | `bool` | `false` | no | | [enable\_user\_data\_debug\_logging](#input\_enable\_user\_data\_debug\_logging) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no | | [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI | `bool` | `true` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | -| [iam\_overrides](#input\_iam\_overrides) | Overrides for the EC2 instance profile used by the launch template. |
object({
override_instance_profile = optional(bool, false)
instance_profile_name = optional(string, null)
})
|
{
"instance_profile_name": null,
"override_instance_profile": false
}
| no | +| [iam\_overrides](#input\_iam\_overrides) | EC2 instance-profile ownership and selection.

- `override_instance_profile`: Uses an externally managed instance profile when true; otherwise this module creates an instance profile for `runner_role`.
- `instance_profile_name`: Name of the externally managed instance profile used by the launch template. Required when `override_instance_profile` is true. |
object({
override_instance_profile = optional(bool, false)
instance_profile_name = optional(string, null)
})
|
{
"instance_profile_name": null,
"override_instance_profile": false
}
| no | | [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | | [instance\_max\_spot\_price](#input\_instance\_max\_spot\_price) | Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet. | `string` | `null` | no | | [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the prefix will be used. | `string` | `null` | no | | [instance\_target\_capacity\_type](#input\_instance\_target\_capacity\_type) | Default lifecycle used runner instances, can be either `spot` or `on-demand`. | `string` | `"spot"` | no | | [instance\_type\_priorities](#input\_instance\_type\_priorities) | A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`. | `map(number)` | `null` | 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)` | `null` | no | +| [instance\_types](#input\_instance\_types) | List of EC2 instance types available when launching runner capacity. | `list(string)` | `null` | no | | [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no | -| [license\_specifications](#input\_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 | +| [license\_specifications](#input\_license\_specifications) | License Manager configurations added to the runner launch template. These may be required for macOS dedicated-host runners when the host resource group uses a Mac dedicated-host license configuration.

- `license_configuration_arn`: ARN of an AWS License Manager license configuration. |
list(object({
license_configuration_arn = string
}))
| `[]` | no | +| [log\_group\_tags](#input\_log\_group\_tags) | Map of tags that will be added to CloudWatch log groups created by the EC2 provider. These tags override provider tags with the same key. | `map(string)` | `{}` | no | | [logging\_kms\_key\_id](#input\_logging\_kms\_key\_id) | Specifies the kms key id to encrypt the logs with | `string` | `null` | no | -| [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 | -| [metadata\_options](#input\_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. |
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)
})
| `{}` | no | -| [overrides](#input\_overrides) | This map provides the possibility to override some defaults. The following attributes are supported: `name_sg` overrides the `Name` tag for all security groups created by this module. `name_runner_agent_instance` overrides the `Name` tag for the ec2 instance defined in the auto launch configuration. `name_docker_machine_runners` overrides the `Name` tag spot instances created by the runner agent. |
object({
name_runner = optional(string, "")
name_sg = optional(string, "")
})
| `{}` | no | -| [placement](#input\_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 | +| [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Number of days to retain events in the EC2 runner log groups. 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 | +| [metadata\_options](#input\_metadata\_options) | Instance Metadata Service configuration in the runner launch template. The default bootstrap flow reads runner configuration from instance tags, so disable metadata tags only when supplying a custom startup flow.

- `instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when set to `enabled`.
- `http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses. |
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)
})
| `{}` | no | +| [overrides](#input\_overrides) | Optional resource-name overrides.

- `name_runner`: Name tag assigned to runner compute resources. An empty value uses the generated provider name.
- `name_sg`: Name tag assigned to the managed runner security group. An empty value uses the generated provider name. |
object({
name_runner = optional(string, "")
name_sg = optional(string, "")
})
| `{}` | no | +| [placement](#input\_placement) | EC2 placement configuration for runner instances.

- `affinity`: Dedicated Host affinity setting.
- `availability_zone`: Availability Zone in which runner instances are placed.
- `group_id`: Placement-group ID.
- `group_name`: Placement-group name.
- `host_id`: Dedicated Host ID.
- `host_resource_group_arn`: ARN of the host resource group used for placement.
- `spread_domain`: Spread-domain placement value.
- `tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `partition_number`: Placement-group partition number. |
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 | | [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the prefix will be used. | `string` | `null` | no | | [runner\_additional\_security\_group\_ids](#input\_runner\_additional\_security\_group\_ids) | (optional) List of additional security groups IDs to apply to the runner | `list(string)` | `[]` | no | | [runner\_architecture](#input\_runner\_architecture) | The platform architecture of the runner instance\_type. | `string` | `"x64"` | no | | [runner\_as\_root](#input\_runner\_as\_root) | Run the action runner under the root user. Variable `runner_run_as` will be ignored. | `bool` | `false` | 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\_ec2\_tags](#input\_runner\_ec2\_tags) | Map of tags that will be added to the launch template instance tag specifications. | `map(string)` | `{}` | no | +| [runner\_ec2\_tags](#input\_runner\_ec2\_tags) | Tags added to runner instance, volume, network-interface, and eligible Spot-request tag specifications. These override module tags and the generated runner `Name`; provider-required `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` tags take final precedence. | `map(string)` | `{}` | no | | [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\_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\_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, osx, windows). | `string` | `"linux"` | no | -| [runner\_role](#input\_runner\_role) | Runner IAM role created or selected by the common runner stack. |
object({
arn = string
name = string
})
| n/a | yes | +| [runner\_role](#input\_runner\_role) | Runner IAM role created or selected by the common runner stack.

- `arn`: Role ARN referenced by the EC2 control-plane policies.
- `name`: Role name associated with the provider-managed EC2 instance profile. |
object({
arn = string
name = string
})
| n/a | yes | | [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | -| [s3\_runner\_binaries](#input\_s3\_runner\_binaries) | Bucket details for cached GitHub binary. |
object({
arn = string
id = string
key = string
})
| n/a | yes | +| [s3\_runner\_binaries](#input\_s3\_runner\_binaries) | S3 location of the synchronized GitHub runner distribution.

- `arn`: Bucket ARN referenced by the runner IAM policy.
- `id`: Bucket name used to construct the runner-distribution S3 URI.
- `key`: Object key of the synchronized runner distribution. |
object({
arn = string
id = string
key = string
})
| n/a | yes | | [scale\_errors](#input\_scale\_errors) | List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors | `list(string)` |
[
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]
| no | -| [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
tokens = string
config = string
})
| n/a | yes | +| [ssm\_parameter\_tags](#input\_ssm\_parameter\_tags) | Map of tags that will be added to SSM parameters created by the EC2 provider. These tags override provider tags with the same key. | `map(string)` | `{}` | no | +| [ssm\_paths](#input\_ssm\_paths) | Parameter Store paths used by the EC2 provider and runner bootstrap flow.

- `root`: Root Parameter Store path for this runner stack.
- `tokens`: Path segment under `root` used for registration tokens and just-in-time configuration.
- `config`: Path segment under `root` used for persistent runner and provider configuration. |
object({
root = string
tokens = string
config = string
})
| n/a | yes | | [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes | | [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name. | `map(string)` | `{}` | no | | [use\_dedicated\_host](#input\_use\_dedicated\_host) | Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly. | `bool` | `false` | 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) | User-data script snippet to insert after GitHub action runner install | `string` | `""` | no | | [userdata\_pre\_install](#input\_userdata\_pre\_install) | User-data script snippet to insert before GitHub action runner install | `string` | `""` | no | -| [userdata\_template](#input\_userdata\_template) | Alternative user-data template file path, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored. | `string` | `null` | no | +| [userdata\_template](#input\_userdata\_template) | Alternative user-data template file path replacing the default template. The template receives the standard bootstrap values, including `pre_install` and `post_install`; a custom template decides how to use them and must install the required runner software. | `string` | `null` | no | | [vpc\_id](#input\_vpc\_id) | The VPC for the security groups. | `string` | n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [control\_plane](#output\_control\_plane) | Provider-neutral control-plane contract containing the EC2 environment and IAM fragments. | | [resources](#output\_resources) | EC2-specific resources and bootstrap logging details. | diff --git a/modules/compute-providers/ec2/control-plane.tf b/modules/compute-providers/ec2/control-plane.tf index 9b2bdd4d09..8a965ea83b 100644 --- a/modules/compute-providers/ec2/control-plane.tf +++ b/modules/compute-providers/ec2/control-plane.tf @@ -1,23 +1,21 @@ # EC2-specific IAM and environment fragments consumed by the common control # plane in runner-stack. -data "aws_caller_identity" "current" {} - data "aws_iam_policy_document" "ami_id_ssm_parameter_read" { - count = local.ami_id_ssm_parameter_name != null ? 1 : 0 + count = local.ami_id_ssm_external ? 1 : 0 statement { effect = "Allow" actions = ["ssm:GetParameter"] - resources = ["arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${trimprefix(local.ami_id_ssm_parameter_name, "/")}"] + resources = [local.ami_id_ssm_parameter_arn] } } resource "aws_iam_policy" "ami_id_ssm_parameter_read" { - count = local.ami_id_ssm_parameter_name != null ? 1 : 0 + 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.tags + tags = local.provider_tags policy = data.aws_iam_policy_document.ami_id_ssm_parameter_read[0].json } @@ -54,7 +52,7 @@ data "aws_iam_policy_document" "scale_up" { condition { test = "StringEquals" - variable = "ec2:ResourceTag/gh:environment" + variable = "ec2:ResourceTag/ghr:environment" values = [var.prefix] } } @@ -68,11 +66,11 @@ data "aws_iam_policy_document" "scale_up" { statement { effect = "Allow" actions = ["ssm:GetParameter", "ssm:GetParameters"] - resources = [local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn] + 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_arn != "" ? [local.ami_kms_key_arn] : [] + for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : [] content { effect = "Allow" @@ -82,7 +80,7 @@ data "aws_iam_policy_document" "scale_up" { } dynamic "statement" { - for_each = local.ami_kms_key_arn != "" ? [local.ami_kms_key_arn] : [] + for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : [] content { effect = "Allow" @@ -124,7 +122,7 @@ data "aws_iam_policy_document" "scale_down" { condition { test = "StringEquals" - variable = "ec2:ResourceTag/gh:environment" + variable = "ec2:ResourceTag/ghr:environment" values = [var.prefix] } } @@ -152,11 +150,11 @@ data "aws_iam_policy_document" "pool" { statement { effect = "Allow" actions = ["ssm:GetParameters"] - resources = [local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn] + 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_arn != "" ? [local.ami_kms_key_arn] : [] + for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : [] content { effect = "Allow" @@ -166,7 +164,7 @@ data "aws_iam_policy_document" "pool" { } dynamic "statement" { - for_each = local.ami_kms_key_arn != "" ? [local.ami_kms_key_arn] : [] + for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : [] content { effect = "Allow" diff --git a/modules/compute-providers/ec2/instance-profile.tf b/modules/compute-providers/ec2/instance-profile.tf index fcf270cf50..2af14a3cde 100644 --- a/modules/compute-providers/ec2/instance-profile.tf +++ b/modules/compute-providers/ec2/instance-profile.tf @@ -5,5 +5,5 @@ resource "aws_iam_instance_profile" "runner" { name = "${var.prefix}-runner-profile" role = var.runner_role.name path = local.instance_profile_path - tags = local.tags + tags = local.provider_tags } diff --git a/modules/compute-providers/ec2/logging.tf b/modules/compute-providers/ec2/logging.tf index 9cedfedbce..5c67ef053d 100644 --- a/modules/compute-providers/ec2/logging.tf +++ b/modules/compute-providers/ec2/logging.tf @@ -62,7 +62,7 @@ resource "aws_ssm_parameter" "cloudwatch_agent_config_runner" { value = var.cloudwatch_config != null ? var.cloudwatch_config : templatefile("${path.module}/templates/cloudwatch_config.json", { logfiles = jsonencode(local.logfiles) }) - tags = local.tags + tags = merge(local.provider_tags, var.ssm_parameter_tags) } resource "aws_cloudwatch_log_group" "gh_runners" { @@ -71,5 +71,5 @@ resource "aws_cloudwatch_log_group" "gh_runners" { retention_in_days = var.logging_retention_in_days kms_key_id = var.logging_kms_key_id log_group_class = local.loggroups_classes[count.index] - tags = local.tags + tags = merge(local.provider_tags, var.log_group_tags) } diff --git a/modules/compute-providers/ec2/outputs.tf b/modules/compute-providers/ec2/outputs.tf index 22a9350b5f..d19c3ad7a2 100644 --- a/modules/compute-providers/ec2/outputs.tf +++ b/modules/compute-providers/ec2/outputs.tf @@ -6,8 +6,8 @@ output "control_plane" { environment_variables = local.scale_up_environment_variables 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_parameter_name != null - managed_policy_arn = local.ami_id_ssm_parameter_name != null ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null + 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 = { environment_variables = local.scale_down_environment_variables @@ -16,8 +16,8 @@ output "control_plane" { pool = { environment_variables = local.pool_environment_variables iam_policy_json = local.pool_iam_policy_json - managed_policy_enabled = local.ami_id_ssm_parameter_name != null - managed_policy_arn = local.ami_id_ssm_parameter_name != null ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null + 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 } } } diff --git a/modules/compute-providers/ec2/runner-config.tf b/modules/compute-providers/ec2/runner-config.tf index 989612748a..0425052890 100644 --- a/modules/compute-providers/ec2/runner-config.tf +++ b/modules/compute-providers/ec2/runner-config.tf @@ -2,12 +2,12 @@ resource "aws_ssm_parameter" "runner_config_run_as" { name = "${var.ssm_paths.root}/${var.ssm_paths.config}/run_as" type = "String" value = var.runner_as_root ? "root" : var.runner_run_as - tags = local.tags + tags = merge(local.provider_tags, var.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.enable_cloudwatch_agent - tags = local.tags + tags = merge(local.provider_tags, var.ssm_parameter_tags) } diff --git a/modules/compute-providers/ec2/runner-instances.tf b/modules/compute-providers/ec2/runner-instances.tf index a8e9386385..5eb64be0ea 100644 --- a/modules/compute-providers/ec2/runner-instances.tf +++ b/modules/compute-providers/ec2/runner-instances.tf @@ -1,18 +1,28 @@ # AMI selection, bootstrap rendering, launch template, and security group for # EC2 runner instances. locals { - tags = merge( + provider_tags = merge( { "Name" = format("%s-action-runner", var.prefix) }, + var.tags, + ) + + name_sg = var.overrides.name_sg == "" ? local.provider_tags["Name"] : var.overrides.name_sg + name_runner = var.overrides.name_runner == "" ? local.provider_tags["Name"] : var.overrides.name_runner + runner_tags = merge( + local.provider_tags, { - "ghr:ssm_config_path" = "${var.ssm_paths.root}/${var.ssm_paths.config}" + "Name" = local.name_runner + }, + var.runner_ec2_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 }, - var.tags, ) - name_sg = var.overrides.name_sg == "" ? local.tags["Name"] : var.overrides.name_sg - name_runner = var.overrides.name_runner == "" ? local.tags["Name"] : var.overrides.name_runner role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path instance_profile_path = var.instance_profile_path == null ? "/${var.prefix}/" : var.instance_profile_path userdata_template = var.userdata_template == null ? local.default_userdata_template[var.runner_os] : var.userdata_template @@ -43,16 +53,19 @@ locals { # Handle AMI configuration ami_config = var.ami != null ? var.ami : { - filter = local.default_ami[var.runner_os] - owners = ["amazon"] - id_ssm_parameter_arn = null - kms_key_arn = null + filter = local.default_ami[var.runner_os] + owners = ["amazon"] + id_ssm_parameter = null + kms_key = null } - ami_kms_key_arn = local.ami_config.kms_key_arn != null ? local.ami_config.kms_key_arn : "" + 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_module_managed = local.ami_config.id_ssm_parameter_arn == null + 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_module_managed ? null : try(regex("parameter(/.+)$", local.ami_config.id_ssm_parameter_arn)[0], null) + 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.enable_userdata ? (var.userdata_content == null ? templatefile(local.userdata_template, { enable_debug_logging = var.enable_user_data_debug_logging @@ -107,7 +120,8 @@ resource "aws_ssm_parameter" "runner_ami_id" { value = data.aws_ami.runner.id tags = merge( - local.tags, + local.provider_tags, + var.ssm_parameter_tags, { # Remove parentheses from AMI name to comply with AWS tag constraints "ghr:ami_name" = replace(data.aws_ami.runner.name, "/[()]/", "") @@ -217,7 +231,7 @@ resource "aws_launch_template" "runner" { } instance_initiated_shutdown_behavior = "terminate" - image_id = "resolve:ssm:${local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn}" + 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.key_name ebs_optimized = var.ebs_optimized @@ -228,30 +242,12 @@ resource "aws_launch_template" "runner" { tag_specifications { resource_type = "instance" - tags = merge( - local.tags, - { - "Name" = format("%s", local.name_runner) - }, - { - "ghr:runner_name_prefix" = var.runner_name_prefix - }, - var.runner_ec2_tags - ) + tags = local.runner_tags } tag_specifications { resource_type = "volume" - tags = merge( - local.tags, - { - "Name" = format("%s", local.name_runner) - }, - { - "ghr:runner_name_prefix" = var.runner_name_prefix - }, - var.runner_ec2_tags - ) + tags = local.runner_tags } # We avoid including the "spot-instances-request" tag_specifications block when on_demand_failover_for_errors is defined, @@ -262,36 +258,18 @@ resource "aws_launch_template" "runner" { for_each = var.instance_target_capacity_type == "spot" && length(var.enable_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 = merge( - local.tags, - { - "Name" = format("%s", local.name_runner) - }, - { - "ghr:runner_name_prefix" = var.runner_name_prefix - }, - var.runner_ec2_tags - ) + tags = local.runner_tags } } tag_specifications { resource_type = "network-interface" - tags = merge( - local.tags, - { - "Name" = format("%s", local.name_runner) - }, - { - "ghr:runner_name_prefix" = var.runner_name_prefix - }, - var.runner_ec2_tags - ) + tags = local.runner_tags } user_data = local.encoded_user_data - tags = local.tags + tags = local.provider_tags update_default_version = true @@ -335,7 +313,7 @@ resource "aws_security_group" "runner_sg" { } tags = merge( - local.tags, + local.provider_tags, { "Name" = format("%s", local.name_sg) }, diff --git a/modules/compute-providers/ec2/tests/provider.tftest.hcl b/modules/compute-providers/ec2/tests/provider.tftest.hcl index ef43508908..77c4d4549a 100644 --- a/modules/compute-providers/ec2/tests/provider.tftest.hcl +++ b/modules/compute-providers/ec2/tests/provider.tftest.hcl @@ -30,16 +30,17 @@ override_data { } variables { - aws_region = "eu-west-1" vpc_id = "vpc-12345678" subnet_ids = ["subnet-12345678"] prefix = "provider-test" ami = { - filter = { state = ["available"] } - owners = ["amazon"] - id_ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id" - kms_key_arn = null + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id" + } + kms_key = null } instance_types = ["m5.large"] @@ -114,6 +115,32 @@ run "separates_control_plane_contract_from_ec2_resources" { error_message = "An external AMI SSM parameter must enable the pool managed policy attachment at plan time." } + assert { + condition = ( + contains(flatten([ + for statement in data.aws_iam_policy_document.scale_up.statement : [ + for condition in statement.condition : condition.variable + ] + ]), "ec2:ResourceTag/ghr:environment") + && contains(flatten([ + for statement in data.aws_iam_policy_document.scale_down.statement : [ + for condition in statement.condition : condition.variable + ] + ]), "ec2:ResourceTag/ghr:environment") + && !contains(flatten([ + for statement in data.aws_iam_policy_document.scale_up.statement : [ + for condition in statement.condition : condition.variable + ] + ]), "ec2:ResourceTag/gh:environment") + && !contains(flatten([ + for statement in data.aws_iam_policy_document.scale_down.statement : [ + for condition in statement.condition : condition.variable + ] + ]), "ec2:ResourceTag/gh:environment") + ) + error_message = "EC2 scale policies must authorize resources by the protected ghr:environment tag." + } + assert { condition = !contains(keys(output.control_plane), "launch_template") error_message = "The common control-plane contract must not expose EC2 resources." @@ -164,6 +191,117 @@ run "accepts_partial_typed_compute_options" { } } +run "separates_provider_runner_and_ssm_tags" { + command = plan + + variables { + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter = null + kms_key = null + } + tags = { + Name = "provider-name" + Scope = "provider" + } + runner_ec2_tags = { + Name = "runner-name" + Scope = "runner" + RunnerOnly = "runner" + "ghr:environment" = "runner-override" + "ghr:ssm_config_path" = "/runner/override" + "ghr:runner_name_prefix" = "runner-override" + } + runner_name_prefix = "required-prefix" + ssm_parameter_tags = { + Name = "ssm-name" + Scope = "ssm" + SsmOnly = "ssm" + "ghr:ami_name" = "ssm-override" + "ghr:ami_creation_date" = "ssm-override" + "ghr:ami_deprecation_time" = "ssm-override" + } + enable_cloudwatch_agent = true + log_group_tags = { + Name = "log-name" + Scope = "log" + LogOnly = "log" + } + } + + assert { + condition = ( + aws_launch_template.runner.tags["Name"] == "provider-name" + && aws_launch_template.runner.tags["Scope"] == "provider" + && !contains(keys(aws_launch_template.runner.tags), "RunnerOnly") + && !contains(keys(aws_launch_template.runner.tags), "SsmOnly") + && !contains(keys(aws_launch_template.runner.tags), "ghr:environment") + && !contains(keys(aws_launch_template.runner.tags), "ghr:ssm_config_path") + && !contains(keys(aws_launch_template.runner.tags), "ghr:runner_name_prefix") + ) + error_message = "Non-runner EC2 resources must use provider tags without runner or SSM component tags." + } + + assert { + condition = toset([ + for tag_specification in aws_launch_template.runner.tag_specifications : tag_specification.resource_type + ]) == toset(["instance", "volume", "network-interface", "spot-instances-request"]) + error_message = "The launch template must define runner tags for every supported runner resource type." + } + + assert { + condition = alltrue([ + for tag_specification in aws_launch_template.runner.tag_specifications : ( + tag_specification.tags["Name"] == "runner-name" + && tag_specification.tags["Scope"] == "runner" + && tag_specification.tags["RunnerOnly"] == "runner" + && !contains(keys(tag_specification.tags), "SsmOnly") + && tag_specification.tags["ghr:environment"] == "provider-test" + && tag_specification.tags["ghr:ssm_config_path"] == "/github-runner/provider-test/config" + && tag_specification.tags["ghr:runner_name_prefix"] == "required-prefix" + ) + ]) + error_message = "Runner resource tags must apply runner overrides while protecting mandatory bootstrap tags." + } + + assert { + condition = ( + aws_ssm_parameter.runner_config_run_as.tags["Name"] == "ssm-name" + && aws_ssm_parameter.runner_config_run_as.tags["Scope"] == "ssm" + && aws_ssm_parameter.runner_config_run_as.tags["SsmOnly"] == "ssm" + && !contains(keys(aws_ssm_parameter.runner_config_run_as.tags), "RunnerOnly") + && !contains(keys(aws_ssm_parameter.runner_config_run_as.tags), "ghr:environment") + ) + error_message = "EC2 SSM parameters must merge SSM component tags over provider tags." + } + + assert { + condition = alltrue([ + for log_group in aws_cloudwatch_log_group.gh_runners : ( + log_group.tags["Name"] == "log-name" + && log_group.tags["Scope"] == "log" + && log_group.tags["LogOnly"] == "log" + && !contains(keys(log_group.tags), "RunnerOnly") + && !contains(keys(log_group.tags), "SsmOnly") + ) + ]) + error_message = "EC2 log groups must merge shared log tags over provider tags without runner or SSM tags." + } + + assert { + condition = ( + aws_ssm_parameter.runner_ami_id[0].tags["Name"] == "ssm-name" + && aws_ssm_parameter.runner_ami_id[0].tags["Scope"] == "ssm" + && aws_ssm_parameter.runner_ami_id[0].tags["SsmOnly"] == "ssm" + && aws_ssm_parameter.runner_ami_id[0].tags["ghr:ami_name"] == "runner-test" + && aws_ssm_parameter.runner_ami_id[0].tags["ghr:ami_creation_date"] == "2026-01-01T00:00:00.000Z" + && aws_ssm_parameter.runner_ami_id[0].tags["ghr:ami_deprecation_time"] == "" + ) + error_message = "The managed AMI parameter must preserve authoritative AMI metadata over SSM component tags." + } +} + run "requires_distribution_object_when_sync_is_enabled" { command = plan diff --git a/modules/compute-providers/ec2/variables.tf b/modules/compute-providers/ec2/variables.tf index 36189fd8a8..a5d0045727 100644 --- a/modules/compute-providers/ec2/variables.tf +++ b/modules/compute-providers/ec2/variables.tf @@ -1,30 +1,27 @@ variable "ami" { - description = < [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.21 | ## Modules @@ -25,7 +25,7 @@ No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_cloudwatch_log_group.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.lambda_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -38,12 +38,12 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [lambda](#input\_lambda) | Configuration for the lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`handler`: The entrypoint for the lambda.
`principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`.
`memory_size`: Memory size limit in MB of the lambda.
`reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
`metrics_namespace`: Namespace for the metrics emitted by the lambda.
`name`: The name of the lambda function.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, "aws")
architecture = optional(string, "arm64")
environment_variables = optional(map(string), {})
handler = string
lambda_tags = optional(map(string), {})
log_level = optional(string, "info")
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, 180)
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, null)
metrics_namespace = optional(string, "GitHub Runners")
name = string
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, "nodejs24.x")
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
tags = optional(map(string), {})
timeout = optional(number, 60)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | +| ---- | ----------- | ---- | ------- | :------: | +| [lambda](#input\_lambda) | Configuration for the lambda function.

- `aws_partition`: Partition for the base arn if not 'aws'
- `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
- `environment_variables`: Additional environment variables for the Lambda function.
- `handler`: The entrypoint for the lambda.
- `principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
- `principals[*].type`: IAM principal type, such as `Service` or `AWS`.
- `principals[*].identifiers`: IAM principal identifiers for the selected principal type.
- `lambda_tags`: Tags added specifically to the Lambda function. These override `tags` values with the same key.
- `log_group_tags`: Tags added specifically to the Lambda log group. These override `tags` values with the same key.
- `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
- `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
- `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.
- `log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`.
- `memory_size`: Memory size limit in MB of the lambda.
- `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
- `metrics_namespace`: Namespace for the metrics emitted by the lambda.
- `name`: The name of the lambda function.
- `prefix`: The prefix used for naming resources.
- `role_path`: The path that will be added to the role, if not set the environment name will be used.
- `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
- `runtime`: AWS Lambda runtime.
- `s3_bucket`: S3 bucket containing the Lambda deployment package. This is an alternative to `zip`.
- `s3_key`: Object key of the Lambda deployment package. Required when `s3_bucket` is set.
- `s3_object_version`: Optional version of the Lambda deployment-package object.
- `security_group_ids`: List of security group IDs associated with the Lambda function.
- `subnet_ids`: Subnets used for the Lambda VPC configuration.
- `tags`: Base tags added to the Lambda function, log group, and execution role. `lambda_tags` and `log_group_tags` override matching keys for their respective resources.
- `timeout`: Time out of the lambda in seconds.
- `tracing_config`: Configuration for lambda tracing.
- `tracing_config.mode`: AWS X-Ray tracing mode. A null value disables tracing.
- `tracing_config.capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests.
- `tracing_config.capture_error`: Whether Powertools tracing captures errors as tracing metadata.
- `zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, "aws")
architecture = optional(string, "arm64")
environment_variables = optional(map(string), {})
handler = string
lambda_tags = optional(map(string), {})
log_group_tags = optional(map(string), {})
log_level = optional(string, "info")
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, 180)
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, null)
metrics_namespace = optional(string, "GitHub Runners")
name = string
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, "nodejs24.x")
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
tags = optional(map(string), {})
timeout = optional(number, 60)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [lambda](#output\_lambda) | n/a | diff --git a/modules/lambda/main.tf b/modules/lambda/main.tf index ff7cec5de2..797e234028 100644 --- a/modules/lambda/main.tf +++ b/modules/lambda/main.tf @@ -58,7 +58,7 @@ resource "aws_cloudwatch_log_group" "main" { retention_in_days = var.lambda.logging_retention_in_days kms_key_id = var.lambda.logging_kms_key_id log_group_class = var.lambda.log_class - tags = var.lambda.tags + tags = merge(var.lambda.tags, var.lambda.log_group_tags) } resource "aws_iam_role" "main" { diff --git a/modules/lambda/variables.tf b/modules/lambda/variables.tf index 76416b8bc1..61d34f4ca1 100644 --- a/modules/lambda/variables.tf +++ b/modules/lambda/variables.tf @@ -2,33 +2,39 @@ variable "lambda" { description = <<-EOF Configuration for the lambda function. - `aws_partition`: Partition for the base arn if not 'aws' - `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86_64' functions. - `environment_variables`: Environment variables for the lambda. - `handler`: The entrypoint for the lambda. - `principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing. - `lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment. - `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. - `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with - `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. - `log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. - `memory_size`: Memory size limit in MB of the lambda. - `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation. - `metrics_namespace`: Namespace for the metrics emitted by the lambda. - `name`: The name of the lambda function. - `prefix`: The prefix used for naming resources. - `role_path`: The path that will be added to the role, if not set the environment name will be used. - `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda. - `runtime`: AWS Lambda runtime. - `s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly. - `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. - `security_group_ids`: List of security group IDs associated with the Lambda function. - `subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. - `tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment. - `timeout`: Time out of the lambda in seconds. - `tracing_config`: Configuration for lambda tracing. - `zip`: File location of the lambda zip file. + - `aws_partition`: Partition for the base arn if not 'aws' + - `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86_64' functions. + - `environment_variables`: Additional environment variables for the Lambda function. + - `handler`: The entrypoint for the lambda. + - `principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing. + - `principals[*].type`: IAM principal type, such as `Service` or `AWS`. + - `principals[*].identifiers`: IAM principal identifiers for the selected principal type. + - `lambda_tags`: Tags added specifically to the Lambda function. These override `tags` values with the same key. + - `log_group_tags`: Tags added specifically to the Lambda log group. These override `tags` values with the same key. + - `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. + - `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with + - `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. + - `log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. + - `memory_size`: Memory size limit in MB of the lambda. + - `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation. + - `metrics_namespace`: Namespace for the metrics emitted by the lambda. + - `name`: The name of the lambda function. + - `prefix`: The prefix used for naming resources. + - `role_path`: The path that will be added to the role, if not set the environment name will be used. + - `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda. + - `runtime`: AWS Lambda runtime. + - `s3_bucket`: S3 bucket containing the Lambda deployment package. This is an alternative to `zip`. + - `s3_key`: Object key of the Lambda deployment package. Required when `s3_bucket` is set. + - `s3_object_version`: Optional version of the Lambda deployment-package object. + - `security_group_ids`: List of security group IDs associated with the Lambda function. + - `subnet_ids`: Subnets used for the Lambda VPC configuration. + - `tags`: Base tags added to the Lambda function, log group, and execution role. `lambda_tags` and `log_group_tags` override matching keys for their respective resources. + - `timeout`: Time out of the lambda in seconds. + - `tracing_config`: Configuration for lambda tracing. + - `tracing_config.mode`: AWS X-Ray tracing mode. A null value disables tracing. + - `tracing_config.capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests. + - `tracing_config.capture_error`: Whether Powertools tracing captures errors as tracing metadata. + - `zip`: File location of the lambda zip file. EOF type = object({ aws_partition = optional(string, "aws") @@ -36,6 +42,7 @@ variable "lambda" { environment_variables = optional(map(string), {}) handler = string lambda_tags = optional(map(string), {}) + log_group_tags = optional(map(string), {}) log_level = optional(string, "info") log_class = optional(string, "STANDARD") logging_kms_key_id = optional(string, null) @@ -69,20 +76,23 @@ variable "lambda" { validation { condition = var.lambda.zip != null || (var.lambda.s3_bucket != null && var.lambda.s3_key != null) - error_message = "Either `lambda_zip` or `lambda_s3_bucket` and `lambda_s3_key` must be provided." + error_message = "Either lambda.zip or both lambda.s3_bucket and lambda.s3_key must be provided." } validation { condition = var.lambda.architecture == "arm64" || var.lambda.architecture == "x86_64" - error_message = "`lambda_architecture` value is not valid, valid values are: `arm64` and `x86_64`." + error_message = "lambda.architecture must be arm64 or x86_64." } validation { - condition = anytrue([ - var.lambda.log_level == "debug", - var.lambda.log_level == "info", - var.lambda.log_level == "warn", - var.lambda.log_level == "error", - ]) - error_message = "`log_level` value not valid. Valid values are 'debug', 'info', 'warn', 'error'." + condition = contains([ + "silly", + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + ], var.lambda.log_level) + error_message = "lambda.log_level must be one of silly, trace, debug, info, warn, error, or fatal." } validation { diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index ce5492600c..84e747483f 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -8,25 +8,31 @@ The module takes a configuration as input containing a matcher for the labels. T ## Provider boundary -The multi-runner module owns provider-neutral lane normalization, queues, shared runner-binary discovery, and webhook routing. Stable `multi_runner_config` lanes continue to use the existing `modules/runners` module at their historical `module.runners["lane"]` addresses. +See [Experimental compute-provider refactor](https://github-aws-runners.github.io/terraform-aws-github-runner/compute-provider-refactor/) for the motivation, ownership contract, opt-in flow, state guarantees, and migration phases. -Lanes in `multi_runner_config_v2` opt into `modules/runner-stack` at `module.runner_stacks["lane"]`. That stack owns common scale-up, scale-down, pool, retry, Lambda roles, and the runner role and attachments. It dispatches typed provider configuration through `compute_provider.type`; the EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. +The multi-runner module owns provider-neutral runner-configuration normalization, queues, shared runner-binary discovery, and webhook routing. Stable `multi_runner_config` entries continue to use the existing `modules/runners` module at their historical `module.runners["configuration"]` addresses. + +Entries under `experimental.multi_runner_config_v2` opt into `modules/runner-stack` at `module.runner_stacks["configuration"]`. That stack owns common scale-up, scale-down, pool, retry, Lambda roles, and the runner role and attachments. It dispatches typed provider configuration through `compute_provider.type`; the EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. In v2, common runner-role configuration belongs under `runner.iam`; EC2's optional external instance-profile selection belongs under `compute_provider.ec2.instance_profile`. Provider policy documents are generated internally and attached by the common stack when it creates the role. An external role remains unmanaged and must already contain the required policies. -The two input maps can be used in the same module instance during phase 1, provided their lane keys do not overlap. Shared queues, webhook routing, and runner-binary discovery use the union of both maps. A duplicate key is rejected so an existing lane cannot silently switch module address or output contract. +The two input maps can be used in the same module instance during phase 1, provided their runner configuration keys do not overlap. Shared queues, webhook routing, and runner-binary discovery use the union of both maps. A duplicate key is rejected so an existing configuration cannot silently switch module address or output contract. + +### V2 tagging + +For v2 runner configurations, top-level module `tags` are merged with configuration `tags`. Shared `lambda.tags`, `queue.tags`, and `observability.logs.tags` are then merged with component tags such as `runner.tags`, `scale_up.tags`, `scale_down.tags`, `pool.tags`, `job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags also apply to the configuration build queue and dead-letter queue owned by multi-runner. Stable v1 configurations keep their existing tag behavior unchanged. -Stable v1 entries in `runners_map` retain their existing flat output shape. Experimental v2 entries keep common control-plane Lambda and runner-role outputs at the lane level, while compute-provider resources are available only under `provider`. For EC2 lanes, use `runners_map["lane"].provider.ec2.launch_template`, `.runners_log_groups`, and `.logfiles`; use `runners_map["lane"].role_runner` for the common runner role. The corresponding flat EC2 attributes are intentionally not duplicated in v2 entries. +Stable v1 entries in `runners_map` retain their existing flat output shape. Experimental v2 entries keep common control-plane Lambda and runner-role outputs at the runner-configuration level, while compute-provider resources are available only under `provider`. For EC2 configurations, use `runners_map["configuration"].provider.ec2.launch_template`, `.runners_log_groups`, and `.logfiles`; use `runners_map["configuration"].role_runner` for the common runner role. The corresponding flat EC2 attributes are intentionally not duplicated in v2 entries. ### Multi-runner v2 migration roadmap -Here, v1 and v2 refer to the `multi_runner_config` input contracts, not module release versions. The migration is intentionally split across releases so configuration migration, state migration, and interface removal do not happen at the same time. +Here, v1 and v2 refer to `multi_runner_config` and `experimental.multi_runner_config_v2`, not module release versions. The migration is intentionally split across releases so configuration migration, state migration, and interface removal do not happen at the same time. #### Phase 1 — Add v2 alongside v1 (current) -Both input contracts are available in the same module release and can manage different lane keys in one module instance. Existing `multi_runner_config` lanes continue through the unchanged `modules/runners` implementation at `module.runners["lane"]`, retaining their input contract, flat `runners_map` output, and Terraform addresses. Experimental `multi_runner_config_v2` lanes use `module.runner_stacks["lane"]` and the provider-oriented output shape. +Both input contracts are available in the same module release and can manage different runner configuration keys in one module instance. Existing `multi_runner_config` entries continue through the unchanged `modules/runners` implementation at `module.runners["configuration"]`, retaining their input contract, flat `runners_map` output, and Terraform addresses. Experimental `experimental.multi_runner_config_v2` entries use `module.runner_stacks["configuration"]` and the provider-oriented output shape. -Compatibility guarantee: upgrading without moving a lane from `multi_runner_config` to `multi_runner_config_v2` requires no state migration and must not move or replace legacy runner resources. Moving an existing lane key to v2 is deliberately deferred until phase 2 supplies the state mapping. +Compatibility guarantee: upgrading without moving a runner configuration from `multi_runner_config` to `experimental.multi_runner_config_v2` requires no state migration and must not move or replace legacy runner resources. Moving an existing configuration key to v2 is deliberately deferred until phase 2 supplies the state mapping. #### Phase 2 — Translate v1 and migrate state @@ -115,7 +121,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -123,14 +129,14 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | | [random](#provider\_random) | ~> 3.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -142,7 +148,7 @@ module "multi-runner" { ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -153,7 +159,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | | [ami\_housekeeper\_lambda\_s3\_key](#input\_ami\_housekeeper\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | @@ -168,6 +174,7 @@ module "multi-runner" { | [enable\_ami\_housekeeper](#input\_enable\_ami\_housekeeper) | Option to disable the lambda to clean up old AMIs. | `bool` | `false` | no | | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | | [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling. |
object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
})
| `{}` | no | +| [experimental](#input\_experimental) | Opt-in experimental features. Omit this object to retain only the stable `multi_runner_config` behavior. Experimental schemas can change before they become stable.

- `multi_runner_config_v2`: Provider-oriented runner configurations keyed by configuration name. Each entry is deployed with `runner-stack`; stable `multi_runner_config` entries continue to use the unchanged `runners` module.

Each `multi_runner_config_v2` entry supports the following nested fields:

- `tags`: Configuration-wide tags. These override module-level `tags`; narrower component and compute-provider tag maps take precedence for their resources.
- `runner.os`: Runner operating system.
- `runner.architecture`: Runner distribution architecture.
- `runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `runner.extra_labels`: Additional labels combined with `matcherConfig.labelMatchers`. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `runner.group_name`: GitHub runner group used during registration.
- `runner.name_prefix`: Prefix added to registered runner names.
- `runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `runner.run_as`: Operating-system user used when `run_as_root` is false.
- `runner.maximum_count`: Maximum number of runners for this configuration.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `ephemeral`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role.
- `runner.iam.path`: IAM path for the module-managed runner role.
- `runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `lambda.tags`: Shared tags for control-plane Lambda functions. Component tags override this map.
- `queue.delay_webhook_event`: Delay in seconds applied to webhook job messages.
- `queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds.
- `queue.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation. Null uses the module-level setting.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. Null uses the module-level setting.
- `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue.
- `queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue.
- `queue.tags`: Tags for configuration-owned queue resources. These override entry-level `tags`; component tags override this map.
- `scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null follows the runner mode default.
- `scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `scale_down.idle_config`: Time-based desired idle-runner configurations.
- `scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `pool.config[].size`: Desired number of runners for the schedule.
- `pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `ssm.tags`: Shared tags for SSM-related resources. These override entry-level `tags`.
- `ssm.kms_key`: Optional customer-managed KMS key used for temporary registration parameters. The wrapper's presence selects the KMS policy at plan time.
- `ssm.kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply.
- `ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `observability.logs.tags`: Shared tags for CloudWatch log groups. Component tags override this map.
- `compute_provider.type`: Compute-provider discriminator. The only currently implemented value is `ec2`.
- `compute_provider.ec2`: EC2-specific configuration. This object is required when `type` is `ec2`.
- `compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `compute_provider.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. The wrapper's presence selects external ownership at plan time.
- `compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `compute_provider.ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence selects the KMS policy at plan time.
- `compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `compute_provider.ec2.binaries_syncer.enabled`: Enables use of the module-level synchronized runner distribution from S3.
- `compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. Null uses the module-level value.
- `compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. Null uses the module-level value.
- `compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `compute_provider.ec2.placement.affinity`: Host affinity setting.
- `compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `compute_provider.ec2.placement.group_id`: Placement-group ID.
- `compute_provider.ec2.placement.group_name`: Placement-group name.
- `compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `compute_provider.ec2.tags`: Tags for runtime EC2 instances, volumes, network interfaces, and eligible Spot requests. These override entry-level tags and the generated runner `Name`; provider-required bootstrap tags take final precedence.
- `compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `matcherConfig.exactMatch`: Requires the job labels to exactly match a configured label group.
- `matcherConfig.bidirectionalLabelMatch`: Requires labels to match in both directions instead of allowing configured subsets.
- `matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels. |
object({
multi_runner_config_v2 = optional(map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = number
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

lambda = optional(object({
tags = optional(map(string), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
}), {})

scale_up = optional(object({
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
tags = optional(map(string), {})
}), {})

scale_down = optional(object({
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
tags = optional(map(string), {})
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
}), {})

pool = optional(object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

ssm = optional(object({
tags = optional(map(string), {})
kms_key = optional(object({
arn = string
}), null)
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

compute_provider = object({
type = string

ec2 = optional(object({
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)
}), {})
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)
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
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
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)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = 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
})), [])
use_dedicated_host = optional(bool, false)
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)
tags = optional(map(string), {})
}), null)

# Future provider references only. Do not uncomment until the Terraform
# resources for these compute providers are implemented.
#
# microvm = optional(object({
# environment_variables = optional(map(string), {})
# }), null)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
})), {})
})
| `{}` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. .However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | | [github\_app](#input\_github\_app) | GitHub app parameters, see your github app.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | @@ -192,7 +199,6 @@ module "multi-runner" { | [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, osx, 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\_job\_queued\_check: Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
enable\_on\_demand\_failover\_for\_errors: "Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later."
scale\_errors: "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`."
instance\_type\_priorities: "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
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)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
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."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` disables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_reserved\_concurrent\_executions: "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
enable\_jit\_config: "Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
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."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`."
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."
pool\_config: "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
map(object({
runner_config = object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
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
})), [])
use_dedicated_host = optional(bool, false)
runner_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)
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
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}))
| `{}` | no | -| [multi\_runner\_config\_v2](#input\_multi\_runner\_config\_v2) | Experimental runner lane configuration keyed by lane name. This v2 shape uses the same canonical ownership groups as runner-stack. The schema can change while the provider model is being finalized. V1 and v2 maps can be used together when their lane keys do not overlap.

Each lane has:
- `runner`: runner identity, registration, runtime, and IAM configuration.
- `github`: GitHub registration scope for the lane.
- `scale_up`, `scale_down`, `pool`, and `job_retry`: control-plane behavior.
- `compute_provider`: backend discriminator plus typed provider configuration.
- `queue`: queue and event-source settings for the lane.
- `matcherConfig`: webhook routing labels and priority. |
map(object({
runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = number
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}), {})

scale_up = optional(object({
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
}), {})

scale_down = optional(object({
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
}), {})

pool = optional(object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
runner_owner = optional(string, null)
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

compute_provider = object({
type = string

ec2 = optional(object({
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)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), 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
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
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)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = 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
})), [])
use_dedicated_host = optional(bool, false)
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)
tags = optional(map(string), {})
}), null)

# Future provider references only. Do not uncomment until the Terraform
# resources for these lanes are implemented.
#
# microvm = optional(object({
# environment_variables = optional(map(string), {})
# }), null)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| `{}` | no | | [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | | [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | @@ -237,7 +243,7 @@ module "multi-runner" { ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 883e063109..66bb5d2bad 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -20,7 +20,7 @@ resource "random_string" "random" { lifecycle { precondition { condition = length(local.duplicate_runner_config_keys) == 0 - error_message = "Lane keys must be unique across multi_runner_config and multi_runner_config_v2. Duplicate keys: ${join(", ", sort(tolist(local.duplicate_runner_config_keys)))}." + error_message = "Runner configuration keys must be unique across multi_runner_config and experimental.multi_runner_config_v2. Duplicate keys: ${join(", ", sort(tolist(local.duplicate_runner_config_keys)))}." } } } diff --git a/modules/multi-runner/multi-runner-config.tf b/modules/multi-runner/multi-runner-config.tf index b201e8d284..58b1098d78 100644 --- a/modules/multi-runner/multi-runner-config.tf +++ b/modules/multi-runner/multi-runner-config.tf @@ -3,6 +3,8 @@ locals { # multi-runner consumers can use the same ownership model as experimental v2. multi_runner_config_v1_as_v2 = { for k, v in var.multi_runner_config : k => { + tags = {} + runner = { os = v.runner_config.runner_os architecture = v.runner_config.runner_architecture @@ -17,6 +19,7 @@ locals { ephemeral = v.runner_config.enable_ephemeral_runners jit_config_enabled = v.runner_config.enable_jit_config auto_update_disabled = v.runner_config.disable_runner_autoupdate + tags = {} hooks = { job_started = v.runner_config.runner_hook_job_started job_completed = v.runner_config.runner_hook_job_completed @@ -38,6 +41,10 @@ locals { organization_runners = v.runner_config.enable_organization_runners } + lambda = { + tags = {} + } + queue = { delay_webhook_event = v.runner_config.delay_webhook_event job_queue_retention_in_seconds = v.runner_config.job_queue_retention_in_seconds @@ -46,22 +53,26 @@ locals { maximum_batching_window_in_seconds = v.runner_config.lambda_event_source_mapping_maximum_batching_window_in_seconds } redrive_build_queue = v.redrive_build_queue + tags = {} } scale_up = { reserved_concurrent_executions = v.runner_config.scale_up_reserved_concurrent_executions job_queued_check_enabled = v.runner_config.enable_job_queued_check + tags = {} } scale_down = { schedule_expression = v.runner_config.scale_down_schedule_expression minimum_running_time_in_minutes = v.runner_config.minimum_running_time_in_minutes idle_config = v.runner_config.idle_config + tags = {} } pool = { config = v.runner_config.pool_config runner_owner = v.runner_config.pool_runner_owner + tags = {} } job_retry = { @@ -69,6 +80,7 @@ locals { delay_in_seconds = v.runner_config.job_retry.delay_in_seconds delay_backoff = v.runner_config.job_retry.delay_backoff max_attempts = v.runner_config.job_retry.max_attempts + tags = {} lambda = { memory_size = v.runner_config.job_retry.lambda_memory_size timeout = v.runner_config.job_retry.lambda_timeout @@ -76,11 +88,40 @@ locals { } } + ssm = { + tags = {} + kms_key = null + parameters = { + tags = {} + } + housekeeper = { + tags = {} + } + } + + observability = { + logs = { + tags = {} + } + } + compute_provider = { type = "ec2" ec2 = { - metadata_options = v.runner_config.runner_metadata_options - ami = v.runner_config.ami + metadata_options = v.runner_config.runner_metadata_options + # Stable v1 keeps its nullable `id_ssm_parameter_arn` leaf. Translate + # it once into v2's caller-known ownership wrapper without changing + # the input passed to the legacy runners module. + ami = v.runner_config.ami == null ? null : { + filter = v.runner_config.ami.filter + owners = v.runner_config.ami.owners + id_ssm_parameter = v.runner_config.ami.id_ssm_parameter_arn == null ? null : { + arn = v.runner_config.ami.id_ssm_parameter_arn + } + kms_key = v.runner_config.ami.kms_key_arn == null ? null : { + arn = v.runner_config.ami.kms_key_arn + } + } block_device_mappings = v.runner_config.block_device_mappings create_service_linked_role_spot = v.runner_config.create_service_linked_role_spot credit_specification = v.runner_config.credit_specification @@ -130,13 +171,13 @@ locals { duplicate_runner_config_keys = setintersection( toset(keys(var.multi_runner_config)), - toset(keys(var.multi_runner_config_v2)), + toset(keys(var.experimental.multi_runner_config_v2)), ) - # Phase 1 keeps stable v1 lanes and experimental v2 lanes side by side. A - # lane key must belong to exactly one input so its module address and output + # Phase 1 keeps stable v1 and experimental v2 runner configurations side by + # side. A configuration key must belong to exactly one input so its module address and output # contract remain unambiguous. - multi_runner_config = merge(local.multi_runner_config_v1_as_v2, var.multi_runner_config_v2) + multi_runner_config = merge(local.multi_runner_config_v1_as_v2, var.experimental.multi_runner_config_v2) runner_extra_labels = { for k, v in local.multi_runner_config : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.extra_labels))) @@ -152,16 +193,31 @@ locals { }) } - # Keep stable v1 lanes at their historical module.runners addresses while - # v2 lanes opt into the provider-oriented runner stack. + # Preserve the exact stable v1 shape for the legacy module call. The v1-to-v2 + # translation above is intentionally limited to shared multi-runner consumers. + runner_extra_labels_v1 = { + for k, v in var.multi_runner_config : + k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner_config.runner_extra_labels))) + } + runner_config_v1 = { - for k, v in local.runner_config : k => v - if contains(keys(var.multi_runner_config), k) + for k, v in var.multi_runner_config : k => merge( + { + id = aws_sqs_queue.queued_builds[k].id + arn = aws_sqs_queue.queued_builds[k].arn + url = aws_sqs_queue.queued_builds[k].url + }, + merge(v, { + runner_config = merge(v.runner_config, { + runner_extra_labels = local.runner_extra_labels_v1[k] + }) + }), + ) } runner_config_v2 = { for k, v in local.runner_config : k => v - if contains(keys(var.multi_runner_config_v2), k) + if contains(keys(var.experimental.multi_runner_config_v2), k) } runner_matcher_config = { diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index 5e264d54f9..2ae14c555d 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -2,10 +2,10 @@ output "runners_map" { value = merge( { for runner_key, runner in module.runners : runner_key => { - launch_template_name = try(runner.launch_template.name, null) - launch_template_id = try(runner.launch_template.id, null) - launch_template_version = try(runner.launch_template.latest_version, null) - launch_template_ami_id = try(runner.launch_template.image_id, null) + launch_template_name = runner.launch_template.name + launch_template_id = runner.launch_template.id + launch_template_version = runner.launch_template.latest_version + launch_template_ami_id = runner.launch_template.image_id lambda_up = runner.lambda_scale_up lambda_up_log_group = runner.lambda_scale_up_log_group lambda_down = runner.lambda_scale_down diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf index 2b02010cd2..dd03cab8d2 100644 --- a/modules/multi-runner/queues.tf +++ b/modules/multi-runner/queues.tf @@ -42,7 +42,11 @@ resource "aws_sqs_queue" "queued_builds" { kms_master_key_id = var.queue_encryption.kms_master_key_id kms_data_key_reuse_period_seconds = var.queue_encryption.kms_data_key_reuse_period_seconds - tags = var.tags + tags = contains(keys(var.experimental.multi_runner_config_v2), each.key) ? merge( + var.tags, + each.value.tags, + each.value.queue.tags, + ) : var.tags } resource "aws_sqs_queue_policy" "build_queue_policy" { @@ -58,7 +62,11 @@ resource "aws_sqs_queue" "queued_builds_dlq" { sqs_managed_sse_enabled = var.queue_encryption.sqs_managed_sse_enabled kms_master_key_id = var.queue_encryption.kms_master_key_id kms_data_key_reuse_period_seconds = var.queue_encryption.kms_data_key_reuse_period_seconds - tags = var.tags + tags = contains(keys(var.experimental.multi_runner_config_v2), each.key) ? merge( + var.tags, + each.value.tags, + each.value.queue.tags, + ) : var.tags } resource "aws_sqs_queue_policy" "build_queue_dlq_policy" { diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 511b3eb876..05b75f9851 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -4,14 +4,14 @@ module "runners" { aws_region = var.aws_region aws_partition = var.aws_partition - vpc_id = coalesce(each.value.compute_provider.ec2.vpc_id, var.vpc_id) - subnet_ids = coalesce(each.value.compute_provider.ec2.subnet_ids, var.subnet_ids) + vpc_id = coalesce(each.value.runner_config.vpc_id, var.vpc_id) + subnet_ids = coalesce(each.value.runner_config.subnet_ids, var.subnet_ids) prefix = "${var.prefix}-${each.key}" tags = merge(local.tags, { "ghr:environment" = "${var.prefix}-${each.key}" }) - s3_runner_binaries = each.value.compute_provider.ec2.binaries_syncer.enabled ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.os}_${each.value.runner.architecture}"] : null + s3_runner_binaries = each.value.runner_config.enable_runner_binaries_syncer ? local.runner_binaries_by_os_and_arch_map["${each.value.runner_config.runner_os}_${each.value.runner_config.runner_architecture}"] : null ssm_paths = { root = "${local.ssm_root_path}/${each.key}" @@ -19,49 +19,49 @@ module "runners" { config = "${var.ssm_paths.runners}/config" } - runner_os = each.value.runner.os - instance_types = each.value.compute_provider.ec2.instance_types - instance_target_capacity_type = each.value.compute_provider.ec2.instance_target_capacity_type - instance_allocation_strategy = each.value.compute_provider.ec2.instance_allocation_strategy - instance_type_priorities = each.value.compute_provider.ec2.instance_type_priorities - instance_max_spot_price = each.value.compute_provider.ec2.instance_max_spot_price - block_device_mappings = each.value.compute_provider.ec2.block_device_mappings + runner_os = each.value.runner_config.runner_os + instance_types = each.value.runner_config.instance_types + instance_target_capacity_type = each.value.runner_config.instance_target_capacity_type + instance_allocation_strategy = each.value.runner_config.instance_allocation_strategy + instance_type_priorities = each.value.runner_config.instance_type_priorities + instance_max_spot_price = each.value.runner_config.instance_max_spot_price + block_device_mappings = each.value.runner_config.block_device_mappings - runner_architecture = each.value.runner.architecture - ami = each.value.compute_provider.ec2.ami + runner_architecture = each.value.runner_config.runner_architecture + ami = each.value.runner_config.ami sqs_build_queue = { "arn" : each.value.arn, "url" : each.value.url } github_app_parameters = local.github_app_parameters - ebs_optimized = each.value.compute_provider.ec2.ebs_optimized - enable_on_demand_failover_for_errors = each.value.compute_provider.ec2.enable_on_demand_failover_for_errors - scale_errors = each.value.compute_provider.ec2.scale_errors - enable_organization_runners = each.value.github.organization_runners - enable_ephemeral_runners = each.value.runner.ephemeral - enable_jit_config = each.value.runner.jit_config_enabled - enable_job_queued_check = each.value.scale_up.job_queued_check_enabled - disable_runner_autoupdate = each.value.runner.auto_update_disabled + ebs_optimized = each.value.runner_config.ebs_optimized + enable_on_demand_failover_for_errors = each.value.runner_config.enable_on_demand_failover_for_errors + scale_errors = each.value.runner_config.scale_errors + enable_organization_runners = each.value.runner_config.enable_organization_runners + enable_ephemeral_runners = each.value.runner_config.enable_ephemeral_runners + enable_jit_config = each.value.runner_config.enable_jit_config + enable_job_queued_check = each.value.runner_config.enable_job_queued_check + disable_runner_autoupdate = each.value.runner_config.disable_runner_autoupdate enable_managed_runner_security_group = var.enable_managed_runner_security_group - enable_runner_detailed_monitoring = each.value.compute_provider.ec2.detailed_monitoring_enabled - scale_down_schedule_expression = each.value.scale_down.schedule_expression - minimum_running_time_in_minutes = each.value.scale_down.minimum_running_time_in_minutes - runner_boot_time_in_minutes = each.value.runner.boot_time_in_minutes - runner_disable_default_labels = each.value.runner.disable_default_labels - runner_labels = each.value.runner.disable_default_labels ? sort(distinct(each.value.runner.extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.os, each.value.runner.architecture], each.value.runner.extra_labels))) - runner_as_root = each.value.runner.run_as_root - runner_run_as = each.value.runner.run_as - runners_maximum_count = each.value.runner.maximum_count - idle_config = each.value.scale_down.idle_config - enable_ssm_on_runners = each.value.compute_provider.ec2.ssm_enabled + enable_runner_detailed_monitoring = each.value.runner_config.enable_runner_detailed_monitoring + scale_down_schedule_expression = each.value.runner_config.scale_down_schedule_expression + minimum_running_time_in_minutes = each.value.runner_config.minimum_running_time_in_minutes + runner_boot_time_in_minutes = each.value.runner_config.runner_boot_time_in_minutes + runner_disable_default_labels = each.value.runner_config.runner_disable_default_labels + runner_labels = each.value.runner_config.runner_disable_default_labels ? sort(distinct(each.value.runner_config.runner_extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner_config.runner_os, each.value.runner_config.runner_architecture], each.value.runner_config.runner_extra_labels))) + runner_as_root = each.value.runner_config.runner_as_root + runner_run_as = each.value.runner_config.runner_run_as + runners_maximum_count = each.value.runner_config.runners_maximum_count + idle_config = each.value.runner_config.idle_config + enable_ssm_on_runners = each.value.runner_config.enable_ssm_on_runners egress_rules = var.runner_egress_rules - runner_additional_security_group_ids = try(coalescelist(each.value.compute_provider.ec2.additional_security_group_ids, var.runner_additional_security_group_ids), []) - metadata_options = each.value.compute_provider.ec2.metadata_options - credit_specification = each.value.compute_provider.ec2.credit_specification - cpu_options = each.value.compute_provider.ec2.cpu_options - placement = each.value.compute_provider.ec2.placement - license_specifications = each.value.compute_provider.ec2.license_specifications - use_dedicated_host = each.value.compute_provider.ec2.use_dedicated_host - - enable_runner_binaries_syncer = each.value.compute_provider.ec2.binaries_syncer.enabled + runner_additional_security_group_ids = try(coalescelist(each.value.runner_config.runner_additional_security_group_ids, var.runner_additional_security_group_ids), []) + metadata_options = each.value.runner_config.runner_metadata_options + credit_specification = each.value.runner_config.credit_specification + cpu_options = each.value.runner_config.cpu_options + placement = each.value.runner_config.placement + license_specifications = each.value.runner_config.license_specifications + use_dedicated_host = each.value.runner_config.use_dedicated_host + + enable_runner_binaries_syncer = each.value.runner_config.enable_runner_binaries_syncer lambda_s3_bucket = var.lambda_s3_bucket runners_lambda_s3_key = var.runners_lambda_s3_key runners_lambda_s3_object_version = var.runners_lambda_s3_object_version @@ -69,8 +69,8 @@ module "runners" { lambda_architecture = var.lambda_architecture lambda_zip = var.runners_lambda_zip lambda_scale_up_memory_size = var.scale_up_lambda_memory_size - lambda_event_source_mapping_batch_size = coalesce(each.value.queue.event_source_mapping.batch_size, var.lambda_event_source_mapping_batch_size) - lambda_event_source_mapping_maximum_batching_window_in_seconds = coalesce(each.value.queue.event_source_mapping.maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) + lambda_event_source_mapping_batch_size = coalesce(each.value.runner_config.lambda_event_source_mapping_batch_size, var.lambda_event_source_mapping_batch_size) + lambda_event_source_mapping_maximum_batching_window_in_seconds = coalesce(each.value.runner_config.lambda_event_source_mapping_maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) lambda_timeout_scale_up = var.runners_scale_up_lambda_timeout lambda_scale_down_memory_size = var.scale_down_lambda_memory_size lambda_timeout_scale_down = var.runners_scale_down_lambda_timeout @@ -81,35 +81,33 @@ module "runners" { logging_retention_in_days = var.logging_retention_in_days logging_kms_key_id = var.logging_kms_key_id log_class = var.log_class - enable_cloudwatch_agent = each.value.compute_provider.ec2.cloudwatch_agent.enabled - cloudwatch_config = try(coalesce(each.value.compute_provider.ec2.cloudwatch_agent.config, var.cloudwatch_config), null) - runner_log_files = each.value.compute_provider.ec2.log_files - runner_group_name = each.value.runner.group_name - runner_name_prefix = each.value.runner.name_prefix + enable_cloudwatch_agent = each.value.runner_config.enable_cloudwatch_agent + cloudwatch_config = try(coalesce(each.value.runner_config.cloudwatch_config, var.cloudwatch_config), null) + runner_log_files = each.value.runner_config.runner_log_files + runner_group_name = each.value.runner_config.runner_group_name + runner_name_prefix = each.value.runner_config.runner_name_prefix parameter_store_tags = var.parameter_store_tags - scale_up_reserved_concurrent_executions = each.value.scale_up.reserved_concurrent_executions + scale_up_reserved_concurrent_executions = each.value.runner_config.scale_up_reserved_concurrent_executions instance_profile_path = var.instance_profile_path role_path = var.role_path role_permissions_boundary = var.role_permissions_boundary - enable_userdata = each.value.compute_provider.ec2.user_data.enabled - userdata_template = each.value.compute_provider.ec2.user_data.template - userdata_content = each.value.compute_provider.ec2.user_data.content - userdata_pre_install = each.value.compute_provider.ec2.user_data.pre_install - userdata_post_install = each.value.compute_provider.ec2.user_data.post_install - runner_hook_job_started = each.value.runner.hooks.job_started - runner_hook_job_completed = each.value.runner.hooks.job_completed + enable_userdata = each.value.runner_config.enable_userdata + userdata_template = each.value.runner_config.userdata_template + userdata_content = each.value.runner_config.userdata_content + userdata_pre_install = each.value.runner_config.userdata_pre_install + userdata_post_install = each.value.runner_config.userdata_post_install + runner_hook_job_started = each.value.runner_config.runner_hook_job_started + runner_hook_job_completed = each.value.runner_config.runner_hook_job_completed key_name = var.key_name - runner_ec2_tags = each.value.compute_provider.ec2.tags + runner_ec2_tags = each.value.runner_config.runner_ec2_tags - create_service_linked_role_spot = each.value.compute_provider.ec2.create_service_linked_role_spot + create_service_linked_role_spot = each.value.runner_config.create_service_linked_role_spot - # Preserve stable v1 values verbatim rather than reconstructing legacy IAM - # inputs from the canonical normalized representation. - runner_iam_role_managed_policy_arns = var.multi_runner_config[each.key].runner_config.runner_iam_role_managed_policy_arns - iam_overrides = var.multi_runner_config[each.key].runner_config.iam_overrides + runner_iam_role_managed_policy_arns = each.value.runner_config.runner_iam_role_managed_policy_arns + iam_overrides = each.value.runner_config.iam_overrides ghes_url = var.ghes_url ghes_ssl_verify = var.ghes_ssl_verify @@ -119,15 +117,15 @@ module "runners" { log_level = var.log_level - pool_config = each.value.pool.config + pool_config = each.value.runner_config.pool_config pool_lambda_timeout = var.pool_lambda_timeout - pool_runner_owner = each.value.pool.runner_owner + pool_runner_owner = each.value.runner_config.pool_runner_owner pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions associate_public_ipv4_address = var.associate_public_ipv4_address ssm_housekeeper = var.runners_ssm_housekeeper - job_retry = var.multi_runner_config[each.key].runner_config.job_retry + job_retry = each.value.runner_config.job_retry metrics = var.metrics } @@ -139,9 +137,7 @@ module "runner_stacks" { aws_region = var.aws_region aws_partition = var.aws_partition prefix = "${var.prefix}-${each.key}" - tags = merge(local.tags, { - "ghr:environment" = "${var.prefix}-${each.key}" - }) + tags = merge(var.tags, each.value.tags) runner = { os = each.value.runner.os @@ -157,6 +153,7 @@ module "runner_stacks" { ephemeral = each.value.runner.ephemeral jit_config_enabled = each.value.runner.jit_config_enabled auto_update_disabled = each.value.runner.auto_update_disabled + tags = each.value.runner.tags hooks = each.value.runner.hooks iam = { role = each.value.runner.iam.role @@ -185,6 +182,7 @@ module "runner_stacks" { batch_size = coalesce(each.value.queue.event_source_mapping.batch_size, var.lambda_event_source_mapping_batch_size) maximum_batching_window_in_seconds = coalesce(each.value.queue.event_source_mapping.maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) } + tags = each.value.queue.tags } lambda = { @@ -198,7 +196,7 @@ module "runner_stacks" { architecture = var.lambda_architecture subnet_ids = var.lambda_subnet_ids security_group_ids = var.lambda_security_group_ids - tags = var.lambda_tags + tags = merge(var.lambda_tags, each.value.lambda.tags) role = { path = var.role_path permissions_boundary = var.role_permissions_boundary @@ -210,6 +208,7 @@ module "runner_stacks" { timeout = var.runners_scale_up_lambda_timeout reserved_concurrent_executions = each.value.scale_up.reserved_concurrent_executions job_queued_check_enabled = each.value.scale_up.job_queued_check_enabled + tags = each.value.scale_up.tags } scale_down = { @@ -218,12 +217,14 @@ module "runner_stacks" { schedule_expression = each.value.scale_down.schedule_expression minimum_running_time_in_minutes = each.value.scale_down.minimum_running_time_in_minutes idle_config = each.value.scale_down.idle_config + tags = each.value.scale_down.tags } pool = { config = each.value.pool.config include_busy_runners = false runner_owner = each.value.pool.runner_owner + tags = each.value.pool.tags lambda = { timeout = var.pool_lambda_timeout reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions @@ -238,11 +239,15 @@ module "runner_stacks" { tokens = "${var.ssm_paths.runners}/tokens" config = "${var.ssm_paths.runners}/config" } - kms_key_arn = var.kms_key_arn - parameter_tags = var.parameter_store_tags + kms_key = each.value.ssm.kms_key + tags = each.value.ssm.tags + parameters = { + tags = merge(var.parameter_store_tags, each.value.ssm.parameters.tags) + } housekeeper = { schedule_expression = var.runners_ssm_housekeeper.schedule_expression state = var.runners_ssm_housekeeper.enabled ? "ENABLED" : "DISABLED" + tags = each.value.ssm.housekeeper.tags lambda = { memory_size = var.runners_ssm_housekeeper.lambda_memory_size timeout = var.runners_ssm_housekeeper.lambda_timeout @@ -252,11 +257,12 @@ module "runner_stacks" { } observability = { - log_level = var.log_level logs = { + level = var.log_level retention_in_days = var.logging_retention_in_days kms_key_id = var.logging_kms_key_id class = var.log_class + tags = each.value.observability.logs.tags } tracing = var.tracing_config metrics = var.metrics diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 69e954ac1d..860b9af956 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -36,6 +36,11 @@ run "stable_v1_keeps_legacy_runner_module" { command = plan variables { + tags = { + StableGlobal = "global" + Precedence = "global" + } + multi_runner_config = { linux = { runner_config = { @@ -49,28 +54,49 @@ run "stable_v1_keeps_legacy_runner_module" { matcherConfig = { labelMatchers = [["self-hosted", "linux", "x64"]] } + redrive_build_queue = { + enabled = true + maxReceiveCount = 3 + } } } } assert { condition = keys(local.runner_config_by_provider.ec2) == ["linux"] - error_message = "Stable multi_runner_config lanes must route to the EC2 provider." + error_message = "Stable multi_runner_config entries must route to the EC2 provider." } assert { condition = keys(local.runner_config_v1) == ["linux"] && length(local.runner_config_v2) == 0 - error_message = "Stable multi_runner_config lanes must remain isolated in the v1 lane map." + error_message = "Stable multi_runner_config entries must remain isolated in the v1 configuration map." + } + + assert { + condition = ( + contains(keys(local.runner_config_v1["linux"]), "runner_config") + && !contains(keys(local.runner_config_v1["linux"]), "compute_provider") + && local.runner_config_v1["linux"].runner_config.enable_organization_runners + ) + error_message = "Stable module inputs must retain the original v1 shape instead of being reconstructed from the v1-to-v2 translation." } assert { condition = keys(module.runners) == ["linux"] && length(module.runner_stacks) == 0 - error_message = "Stable multi_runner_config lanes must retain the historical module.runners address." + error_message = "Stable multi_runner_config entries must retain the historical module.runners address." } assert { condition = keys(aws_sqs_queue.queued_builds) == ["linux"] - error_message = "Common queue ownership must preserve the stable lane key." + error_message = "Common queue ownership must preserve the stable runner configuration key." + } + + assert { + condition = ( + aws_sqs_queue.queued_builds["linux"].tags == var.tags + && aws_sqs_queue.queued_builds_dlq["linux"].tags == var.tags + ) + error_message = "Stable multi_runner_config queues must continue to receive exactly the module-level tags." } assert { @@ -107,48 +133,50 @@ run "experimental_v2_routes_through_provider_stack" { command = plan variables { - multi_runner_config_v2 = { - linux = { - runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 - hooks = { - job_started = "/opt/actions/job-started.sh" - } - iam = { - managed_policy_arns = { - readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + experimental = { + multi_runner_config_v2 = { + linux = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + hooks = { + job_started = "/opt/actions/job-started.sh" + } + iam = { + managed_policy_arns = { + readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } } } - } - github = { - organization_runners = true - } - scale_down = { - idle_config = [{ - cron = "* * * * *" - timeZone = "UTC" - idleCount = 1 - }] - } - pool = { - config = [{ - schedule_expression = "cron(0 8 * * ? *)" - size = 1 - }] - } - compute_provider = { - type = "ec2" - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + github = { + organization_runners = true + } + scale_down = { + idle_config = [{ + cron = "* * * * *" + timeZone = "UTC" + idleCount = 1 + }] + } + pool = { + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + compute_provider = { + type = "ec2" + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } } } } @@ -156,22 +184,22 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = keys(local.runner_config_by_provider.ec2) == ["linux"] - error_message = "Experimental multi_runner_config_v2 lanes must route to the EC2 provider." + error_message = "Experimental multi_runner_config_v2 entries must route to the EC2 provider." } assert { condition = length(local.runner_config_v1) == 0 && keys(local.runner_config_v2) == ["linux"] - error_message = "Experimental multi_runner_config_v2 lanes must remain isolated in the v2 lane map." + error_message = "Experimental multi_runner_config_v2 entries must remain isolated in the v2 configuration map." } assert { condition = length(module.runners) == 0 && keys(module.runner_stacks) == ["linux"] - error_message = "Experimental multi_runner_config_v2 lanes must dispatch through module.runner_stacks." + error_message = "Experimental multi_runner_config_v2 entries must dispatch through module.runner_stacks." } assert { condition = keys(aws_sqs_queue.queued_builds) == ["linux"] - error_message = "Common queue ownership must preserve the experimental lane key." + error_message = "Common queue ownership must preserve the experimental runner configuration key." } assert { @@ -207,7 +235,7 @@ run "experimental_v2_routes_through_provider_stack" { "logfiles", ]) ) - error_message = "Experimental v2 must expose only EC2-owned resources under runners_map..provider.ec2." + error_message = "Experimental v2 must expose only EC2-owned resources under runners_map..provider.ec2." } assert { @@ -218,7 +246,7 @@ run "experimental_v2_routes_through_provider_stack" { && !contains(keys(output.runners_map["linux"]), "runners_log_groups") && !contains(keys(output.runners_map["linux"]), "logfiles") ) - error_message = "Experimental v2 must expose the common runner role at lane level without duplicating EC2 resources." + error_message = "Experimental v2 must expose the common runner role at runner-configuration level without duplicating EC2 resources." } assert { @@ -240,6 +268,184 @@ run "experimental_v2_routes_through_provider_stack" { } } +run "experimental_v2_layers_shared_and_component_tags" { + command = plan + + variables { + tags = { + GlobalOnly = "global" + Precedence = "global" + } + + lambda_tags = { + SharedLambdaOnly = "shared-lambda" + Precedence = "shared-lambda" + } + + experimental = { + multi_runner_config_v2 = { + tagged = { + tags = { + RunnerConfigOnly = "runner-config" + Precedence = "runner-config" + } + + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + tags = { + RunnerOnly = "runner" + Precedence = "runner" + } + } + + lambda = { + tags = { + ConfigLambdaOnly = "config-lambda" + Precedence = "config-lambda" + } + } + + queue = { + redrive_build_queue = { + enabled = true + maxReceiveCount = 3 + } + tags = { + SharedQueueOnly = "shared-queue" + Precedence = "shared-queue" + } + } + + scale_up = { + tags = { + ScaleUpOnly = "scale-up" + Precedence = "scale-up" + } + } + + scale_down = { + tags = { + ScaleDownOnly = "scale-down" + Precedence = "scale-down" + } + } + + observability = { + logs = { + tags = { + SharedLogOnly = "shared-log" + Precedence = "shared-log" + } + } + } + + compute_provider = { + type = "ec2" + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "tagged"]] + } + } + } + } + } + + assert { + condition = aws_sqs_queue.queued_builds["tagged"].tags == tomap({ + GlobalOnly = "global" + RunnerConfigOnly = "runner-config" + SharedQueueOnly = "shared-queue" + Precedence = "shared-queue" + }) + error_message = "Experimental v2 build queue tags must merge global, runner-configuration, and queue tags in that precedence order." + } + + assert { + condition = aws_sqs_queue.queued_builds_dlq["tagged"].tags == tomap({ + GlobalOnly = "global" + RunnerConfigOnly = "runner-config" + SharedQueueOnly = "shared-queue" + Precedence = "shared-queue" + }) + error_message = "Experimental v2 dead-letter queue tags must use the same layered precedence as the build queue." + } + + assert { + condition = module.runner_stacks["tagged"].lambda_scale_up.tags == tomap({ + GlobalOnly = "global" + RunnerConfigOnly = "runner-config" + SharedLambdaOnly = "shared-lambda" + ConfigLambdaOnly = "config-lambda" + ScaleUpOnly = "scale-up" + Precedence = "scale-up" + }) + error_message = "Scale-up Lambda tags must merge global, runner-configuration, shared Lambda, configuration Lambda, and component tags in that precedence order." + } + + assert { + condition = module.runner_stacks["tagged"].lambda_scale_up_log_group.tags == tomap({ + GlobalOnly = "global" + RunnerConfigOnly = "runner-config" + SharedLogOnly = "shared-log" + ScaleUpOnly = "scale-up" + Precedence = "scale-up" + }) + error_message = "Scale-up log-group tags must merge global, runner-configuration, shared log, and component tags in that precedence order." + } + + assert { + condition = module.runner_stacks["tagged"].role_scale_up.tags == tomap({ + GlobalOnly = "global" + RunnerConfigOnly = "runner-config" + ScaleUpOnly = "scale-up" + Precedence = "scale-up" + }) + error_message = "Scale-up role tags must merge global, runner-configuration, and component tags without Lambda- or log-only tags." + } + + assert { + condition = module.runner_stacks["tagged"].role_runner[0].tags == tomap({ + GlobalOnly = "global" + RunnerConfigOnly = "runner-config" + RunnerOnly = "runner" + Precedence = "runner" + }) + error_message = "Runner role tags must merge global, runner-configuration, and runner-component tags in that precedence order." + } + + assert { + condition = module.runner_stacks["tagged"].lambda_scale_down.tags == tomap({ + GlobalOnly = "global" + RunnerConfigOnly = "runner-config" + SharedLambdaOnly = "shared-lambda" + ConfigLambdaOnly = "config-lambda" + ScaleDownOnly = "scale-down" + Precedence = "scale-down" + }) + error_message = "Scale-down Lambda tags must preserve shared layers before applying scale-down component tags." + } + + assert { + condition = module.runner_stacks["tagged"].lambda_scale_down_log_group.tags == tomap({ + GlobalOnly = "global" + RunnerConfigOnly = "runner-config" + SharedLogOnly = "shared-log" + ScaleDownOnly = "scale-down" + Precedence = "scale-down" + }) + error_message = "Scale-down log-group tags must preserve shared log tags before applying scale-down component tags." + } +} + run "stable_v1_and_experimental_v2_coexist" { command = plan @@ -260,27 +466,29 @@ run "stable_v1_and_experimental_v2_coexist" { } } - multi_runner_config_v2 = { - experimental = { - runner = { - os = "linux" - architecture = "arm64" - maximum_count = 2 - } - github = { - organization_runners = true - } - compute_provider = { - type = "ec2" - ec2 = { - instance_types = ["m7g.large"] - binaries_syncer = { - enabled = true + experimental = { + multi_runner_config_v2 = { + experimental = { + runner = { + os = "linux" + architecture = "arm64" + maximum_count = 2 + } + github = { + organization_runners = true + } + compute_provider = { + type = "ec2" + ec2 = { + instance_types = ["m7g.large"] + binaries_syncer = { + enabled = true + } } } - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "arm64", "experimental"]] + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "arm64", "experimental"]] + } } } } @@ -288,12 +496,12 @@ run "stable_v1_and_experimental_v2_coexist" { assert { condition = keys(local.runner_config_v1) == ["legacy"] && keys(local.runner_config_v2) == ["experimental"] - error_message = "Stable and experimental lanes must remain isolated in their respective lane maps." + error_message = "Stable and experimental runner configurations must remain isolated in their respective configuration maps." } assert { condition = keys(module.runners) == ["legacy"] && keys(module.runner_stacks) == ["experimental"] - error_message = "Stable lanes must keep module.runners addresses while v2 lanes use module.runner_stacks." + error_message = "Stable runner configurations must keep module.runners addresses while v2 configurations use module.runner_stacks." } assert { @@ -301,7 +509,7 @@ run "stable_v1_and_experimental_v2_coexist" { toset(keys(aws_sqs_queue.queued_builds)) == toset(["legacy", "experimental"]) && toset(keys(local.runner_matcher_config)) == toset(["legacy", "experimental"]) ) - error_message = "Queues and webhook routing must use the union of stable and experimental lane keys." + error_message = "Queues and webhook routing must use the union of stable and experimental runner configuration keys." } assert { @@ -311,7 +519,7 @@ run "stable_v1_and_experimental_v2_coexist" { assert { condition = toset(keys(output.runners_map)) == toset(["legacy", "experimental"]) - error_message = "The public runner map must expose both stable and experimental lane keys." + error_message = "The public runner map must expose both stable and experimental runner configuration keys." } assert { @@ -335,16 +543,16 @@ run "stable_v1_and_experimental_v2_coexist" { "logfiles", ] ) - error_message = "A coexisting stable lane must retain the legacy flat runners_map entry shape." + error_message = "A coexisting stable runner configuration must retain the legacy flat runners_map entry shape." } assert { condition = output.runners_map["experimental"].provider.type == "ec2" && contains(keys(output.runners_map["experimental"].provider.ec2), "launch_template") - error_message = "A coexisting v2 lane must retain its nested EC2 provider output." + error_message = "A coexisting v2 runner configuration must retain its nested EC2 provider output." } } -run "duplicate_lane_keys_are_rejected" { +run "duplicate_runner_configuration_keys_are_rejected" { command = plan variables { @@ -363,24 +571,26 @@ run "duplicate_lane_keys_are_rejected" { } } - multi_runner_config_v2 = { - duplicate = { - runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 - } - compute_provider = { - type = "ec2" - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + experimental = { + multi_runner_config_v2 = { + duplicate = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + type = "ec2" + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "experimental"]] + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "experimental"]] + } } } } @@ -393,52 +603,56 @@ run "experimental_v2_rejects_future_providers" { command = plan variables { - multi_runner_config_v2 = { - microvm = { - runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 - } - compute_provider = { - type = "microvm" - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] + experimental = { + multi_runner_config_v2 = { + microvm = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + type = "microvm" + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } } } } } - expect_failures = [var.multi_runner_config_v2] + expect_failures = [var.experimental] } run "experimental_v2_rejects_profile_without_role" { command = plan variables { - multi_runner_config_v2 = { - invalid_profile = { - runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 - } - compute_provider = { - type = "ec2" - ec2 = { - instance_types = ["m5.large"] - instance_profile = { - name = "external-profile" + experimental = { + multi_runner_config_v2 = { + invalid_profile = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + type = "ec2" + ec2 = { + instance_types = ["m5.large"] + instance_profile = { + name = "external-profile" + } } } - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } } } } } - expect_failures = [var.multi_runner_config_v2] + expect_failures = [var.experimental] } diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index b0bb34512a..720102914a 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -1,246 +1,417 @@ -variable "multi_runner_config_v2" { - description = < [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [ec2\_runner\_role](#module\_ec2\_runner\_role) | ../compute-providers/ec2/runner-role | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | @@ -75,7 +83,7 @@ yarn run dist ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | @@ -131,27 +139,27 @@ yarn run dist ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | -| [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings must remain inside the selected provider block. |
object({
type = string

ec2 = optional(object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(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)
enable_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)
}), null)
})
| n/a | yes | -| [github](#input\_github) | GitHub API and registration configuration. |
object({
app_parameters = object({
key_base64 = map(string)
id = map(string)
})
organization_runners = bool
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | -| [job\_retry](#input\_job\_retry) | Job-retry queue and Lambda configuration. |
object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
})
| `{}` | no | -| [lambda](#input\_lambda) | Configuration shared by the control-plane Lambda functions. |
object({
zip = optional(string, null)
s3 = optional(object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| `{}` | no | -| [observability](#input\_observability) | Logging, tracing, and metrics configuration. |
object({
log_level = optional(string, "info")
logs = optional(object({
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(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 | -| [pool](#input\_pool) | Scheduled runner-pool configuration. |
object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
lambda = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
}), {})
})
| `{}` | no | +| [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block.

- `type`: Compute-provider discriminator. The currently supported value is `ec2`.
- `ec2`: EC2 compute-provider configuration. This object is required when `type` is `ec2`.
- `ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults.
- `ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter.
- `ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource.
- `ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator.
- `ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `ec2.vpc_id`: VPC in which runner networking resources are created.
- `ec2.subnet_ids`: Subnets from which scale-up may launch runner instances.
- `ec2.overrides`: Optional resource-name overrides.
- `ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name.
- `ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name.
- `ec2.instance_profile`: Optional externally managed instance profile used by the launch template.
- `ec2.instance_profile.name`: Name of the externally managed instance profile.
- `ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the stack prefix.
- `ec2.binaries_syncer`: Runner-distribution synchronization configuration.
- `ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3.
- `ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies.
- `ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI.
- `ec2.binaries_syncer.s3.key`: Object key of the runner distribution.
- `ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it.
- `ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `ec2.block_device_mappings[].volume_type`: EBS volume type.
- `ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity.
- `ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `ec2.user_data`: Runner bootstrap user-data configuration.
- `ec2.user_data.enabled`: Enables launch-template user data.
- `ec2.user_data.template`: Optional path to a custom user-data template.
- `ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template.
- `ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances.
- `ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`.
- `ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group.
- `ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults.
- `ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing.
- `ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `ec2.key_name`: Optional EC2 key-pair name added to the launch template.
- `ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group.
- `ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `ec2.egress_rules`: Egress rules created on the managed runner security group.
- `ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `ec2.egress_rules[].from_port`: First destination port in the permitted range.
- `ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `ec2.egress_rules[].security_groups`: Destination security-group IDs.
- `ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `ec2.egress_rules[].to_port`: Last destination port in the permitted range.
- `ec2.egress_rules[].description`: Optional rule description.
- `ec2.tags`: Additional tags for runner instances, EBS volumes, network interfaces, and eligible Spot instance requests created from the launch template. They override module-level tags and the generated runner `Name`; the provider-managed `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` bootstrap tags take final precedence. These tags do not apply to static provider resources such as the launch template, security group, IAM resources, SSM parameters, or log groups.
- `ec2.metadata_options`: Instance Metadata Service configuration in the launch template.
- `ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`.
- `ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`.
- `ec2.cpu_options`: CPU topology and processor-feature configuration.
- `ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `ec2.placement`: EC2 placement configuration for runner instances.
- `ec2.placement.affinity`: Host affinity setting.
- `ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `ec2.placement.group_id`: Placement-group ID.
- `ec2.placement.group_name`: Placement-group name.
- `ec2.placement.host_id`: Dedicated Host ID.
- `ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `ec2.placement.spread_domain`: Spread-domain placement value.
- `ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `ec2.placement.partition_number`: Placement-group partition number.
- `ec2.license_specifications`: License Manager configurations added to the launch template.
- `ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. |
object({
type = string

ec2 = optional(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)
enable_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)
}), null)
})
| n/a | yes | +| [github](#input\_github) | GitHub API and runner-registration configuration.

- `app_parameters.key_base64`: Parameter Store reference for the GitHub App private key.
- `app_parameters.key_base64.name`: Name of the private-key parameter supplied to the control-plane functions.
- `app_parameters.key_base64.arn`: ARN of the private-key parameter used by IAM policies.
- `app_parameters.id`: Parameter Store reference for the GitHub App ID.
- `app_parameters.id.name`: Name of the App-ID parameter supplied to the control-plane functions.
- `app_parameters.id.arn`: ARN of the App-ID parameter used by IAM policies.
- `organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `user_agent`: Optional User-Agent value added to GitHub API requests. |
object({
app_parameters = object({
key_base64 = map(string)
id = map(string)
})
organization_runners = bool
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | +| [job\_retry](#input\_job\_retry) | Job-retry queue and Lambda configuration.

- `enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `delay_in_seconds`: Initial delay before a queued-job retry check. AWS SQS limits this value to 900 seconds.
- `delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `tags`: Tags for job-retry resources, including the Lambda function, log group, IAM role, retry queue, and event-source mapping. These override module-level tags and the shared `lambda.tags`, `queue.tags`, and `observability.logs.tags` maps when keys conflict.
- `lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. |
object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
})
| `{}` | no | +| [lambda](#input\_lambda) | Configuration shared by the control-plane Lambda functions.

- `zip`: Local control-plane archive. When null, the module's packaged runner archive is used.
- `s3.bucket`: Optional S3 bucket containing the Lambda archive. Setting this selects S3 instead of a local archive.
- `s3.key`: Object key of the Lambda archive in `s3.bucket`.
- `s3.object_version`: Optional version of the Lambda archive object.
- `runtime`: Runtime used by all control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by all control-plane Lambda functions. Supported values are `arm64` and `x86_64`.
- `subnet_ids`: Subnets used for Lambda VPC configuration.
- `security_group_ids`: Security groups used for Lambda VPC configuration.
- `tags`: Shared tags applied to Lambda function resources only. These override module-level `tags`; component `tags` override this map when keys conflict.
- `role.path`: IAM path for module-managed Lambda execution roles. Defaults to a path derived from `prefix`.
- `role.permissions_boundary`: Permissions-boundary ARN applied to module-managed Lambda execution roles. |
object({
zip = optional(string, null)
s3 = optional(object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| `{}` | no | +| [observability](#input\_observability) | Logging, tracing, and metrics configuration for control-plane and provider resources.

- `logs.level`: Application log level supplied to the control-plane functions.
- `logs.retention_in_days`: CloudWatch Logs retention period.
- `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt CloudWatch log groups.
- `logs.class`: CloudWatch log-group class. Supported values are `STANDARD` and `INFREQUENT_ACCESS`.
- `logs.tags`: Shared tags for CloudWatch log groups. These override module-level `tags`; component `tags` override this map when keys conflict.
- `tracing.mode`: Optional Lambda active-tracing mode. Null disables X-Ray tracing configuration.
- `tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `tracing.capture_error`: Enables error capture in the tracing helper.
- `metrics.enable`: Enables module-emitted metrics.
- `metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `metrics.metric.enable_spot_termination_warning`: Emits spot-termination warning metrics where supported. |
object({
logs = optional(object({
level = optional(string, "info")
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(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 | +| [pool](#input\_pool) | Scheduled runner-pool configuration. The pool component is created only when `config` is non-empty.

- `config`: Scheduled target pool sizes.
- `config[].schedule_expression`: Scheduler expression that activates the target size.
- `config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `config[].size`: Desired number of runners for the schedule.
- `include_busy_runners`: Includes busy runners when calculating the current pool size.
- `runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `tags`: Tags for pool resources, including the Lambda function, log group, IAM roles, and scheduler group. These override module-level tags and the shared `lambda.tags` and `observability.logs.tags` maps when keys conflict.
- `lambda.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.timeout`: Pool Lambda timeout in seconds.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency. |
object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
}), {})
})
| `{}` | no | | [prefix](#input\_prefix) | The prefix used for naming resources. | `string` | `"github-actions"` | no | -| [queue](#input\_queue) | Build queue and Lambda event-source configuration. |
object({
build = object({
arn = string
url = string
})
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
})
| n/a | yes | -| [runner](#input\_runner) | Provider-neutral GitHub runner configuration. |
object({
os = optional(string, "linux")
architecture = optional(string, "x64")
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
labels = list(string)
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = optional(number, 3)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| n/a | yes | -| [scale\_down](#input\_scale\_down) | Scale-down Lambda and idle-runner configuration. |
object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
})
| `{}` | no | -| [scale\_up](#input\_scale\_up) | Scale-up Lambda configuration. |
object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
})
| `{}` | no | -| [ssm](#input\_ssm) | Parameter Store paths, encryption, tags, and housekeeper configuration. |
object({
paths = object({
root = string
tokens = string
config = string
})
kms_key_arn = optional(string, null)
parameter_tags = optional(map(string), {})
housekeeper = optional(object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
lambda = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
})
| n/a | yes | -| [tags](#input\_tags) | Map of tags added to created resources. | `map(string)` | `{}` | no | +| [queue](#input\_queue) | Build queue reference and queue-integrated Lambda configuration.

- `build.arn`: ARN of the externally managed build queue consumed by scale-up.
- `build.url`: URL of the externally managed build queue used when messages are published.
- `event_source_mapping.batch_size`: Maximum records delivered to a Lambda invocation.
- `event_source_mapping.maximum_batching_window_in_seconds`: Maximum time Lambda may buffer records before invocation.
- `tags`: Shared tags for queue-related resources created by this stack, including event-source mappings and the optional job-retry queue. These override module-level `tags`; component `tags` override this map when keys conflict. The referenced build queue is not managed or tagged by this module. |
object({
build = object({
arn = string
url = string
})
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
})
| n/a | yes | +| [runner](#input\_runner) | Provider-neutral GitHub runner configuration.

- `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`.
- `architecture`: Runner distribution architecture, such as `x64` or `arm64`.
- `boot_time_in_minutes`: Expected instance boot duration used before a runner is considered stale.
- `disable_default_labels`: Prevents GitHub's default self-hosted, operating-system, and architecture labels from being registered.
- `labels`: Complete set of labels supplied to the control-plane functions.
- `group_name`: GitHub runner group used during registration.
- `name_prefix`: Prefix added to registered runner names.
- `run_as_root`: Runs the runner service as root when supported by the compute provider.
- `run_as`: Operating-system user used when `run_as_root` is false.
- `maximum_count`: Maximum number of runners that may exist for this stack.
- `ephemeral`: Registers runners in ephemeral mode.
- `jit_config_enabled`: Explicitly enables or disables just-in-time configuration. When null, runtime behavior follows `ephemeral`.
- `auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `tags`: Additional tags for common runner resources, currently the managed runner IAM role. These override module-level `tags` with the same key.
- `hooks.job_started`: Script content installed as the runner job-started hook.
- `hooks.job_completed`: Script content installed as the runner job-completed hook.
- `iam.role.arn`: ARN of an externally managed runner role. When set, this module does not create or modify that role.
- `iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role.
- `iam.path`: IAM path for the module-managed runner role. Defaults to a path derived from `prefix`.
- `iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role. |
object({
os = optional(string, "linux")
architecture = optional(string, "x64")
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
labels = list(string)
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = optional(number, 3)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| n/a | yes | +| [scale\_down](#input\_scale\_down) | Scale-down Lambda, schedule, and idle-runner configuration.

- `memory_size`: Memory allocated to the scale-down Lambda in MB.
- `timeout`: Scale-down Lambda timeout in seconds.
- `schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `tags`: Tags for scale-down resources, including the Lambda function, log group, EventBridge rule, and IAM role. These override module-level tags and the shared `lambda.tags` and `observability.logs.tags` maps when keys conflict.
- `idle_config`: Time-based desired idle-runner configurations.
- `idle_config[].cron`: Cron expression identifying when the configuration applies.
- `idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. |
object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
tags = optional(map(string), {})
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
})
| `{}` | no | +| [scale\_up](#input\_scale\_up) | Scale-up component configuration.

- `memory_size`: Memory allocated to the scale-up Lambda in MB.
- `timeout`: Scale-up Lambda timeout in seconds.
- `reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `job_queued_check_enabled`: Enables the queued-job verification before scaling. When null, the default is enabled for persistent runners and disabled for ephemeral runners.
- `tags`: Tags for scale-up resources, including the Lambda function, log group, event-source mapping, and IAM role. These override module-level tags and the shared `lambda.tags`, `queue.tags`, and `observability.logs.tags` maps when keys conflict. |
object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
tags = optional(map(string), {})
})
| `{}` | no | +| [ssm](#input\_ssm) | Parameter Store paths, encryption, tag scopes, and housekeeper configuration.

- `paths.root`: Root Parameter Store path for this runner stack.
- `paths.tokens`: Path segment under `paths.root` used for registration tokens and just-in-time configuration.
- `paths.config`: Path segment under `paths.root` used for persistent runner configuration.
- `kms_key`: Optional customer-managed KMS key used to encrypt temporary registration parameters. The wrapper's presence is the plan-time policy discriminator.
- `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply.
- `tags`: Shared tags for SSM-related resources. These override module-level `tags` and are inherited by parameter and housekeeper resources.
- `parameters.tags`: Tags for Terraform-managed runner configuration parameters and temporary parameters created by the scale-up and pool Lambdas. These override module-level and `ssm.tags` values with the same key.
- `housekeeper.schedule_expression`: EventBridge schedule expression that invokes the SSM housekeeper.
- `housekeeper.state`: EventBridge rule state, such as `ENABLED` or `DISABLED`.
- `housekeeper.tags`: Tags for housekeeper resources, including the Lambda function, log group, EventBridge rule, and IAM role. These override module-level, `ssm.tags`, shared Lambda, and shared log tags when keys conflict.
- `housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `housekeeper.config.tokenPath`: Parameter Store token path cleaned by the housekeeper. When omitted, the configured runner token path is used.
- `housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. |
object({
paths = object({
root = string
tokens = string
config = string
})
kms_key = optional(object({
arn = string
}), null)
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
})
| n/a | yes | +| [tags](#input\_tags) | Base tags added to taggable resources created by this stack. Shared, component, and compute-provider tag maps override matching keys within their documented resource scopes. | `map(string)` | `{}` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [lambda\_pool](#output\_lambda\_pool) | n/a | | [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a | | [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a | diff --git a/modules/runner-stack/common-config.tf b/modules/runner-stack/common-config.tf index 031cefa3a5..bbbe12b65f 100644 --- a/modules/runner-stack/common-config.tf +++ b/modules/runner-stack/common-config.tf @@ -1,25 +1,45 @@ # Shared control-plane configuration: naming, paths, tags, and normalized values. locals { - tags = merge( - { - "Name" = format("%s-action-runner", var.prefix) - }, - { - "ghr:ssm_config_path" = "${var.ssm.paths.root}/${var.ssm.paths.config}" - }, - var.tags, - ) + common_tags = var.tags + runner_tags = merge(local.common_tags, var.runner.tags) + lambda_tags = merge(local.common_tags, var.lambda.tags) + queue_tags = merge(local.common_tags, var.queue.tags) + observability_log_tags = merge(local.common_tags, var.observability.logs.tags) + + scale_up_tags = merge(local.common_tags, var.scale_up.tags) + scale_up_lambda_tags = merge(local.lambda_tags, var.scale_up.tags) + scale_up_log_tags = merge(local.observability_log_tags, var.scale_up.tags) + scale_up_queue_tags = merge(local.queue_tags, var.scale_up.tags) + + scale_down_tags = merge(local.common_tags, var.scale_down.tags) + scale_down_lambda_tags = merge(local.lambda_tags, var.scale_down.tags) + scale_down_log_tags = merge(local.observability_log_tags, var.scale_down.tags) + + pool_tags = merge(local.common_tags, var.pool.tags) + pool_lambda_tags = merge(local.lambda_tags, var.pool.tags) + pool_log_tags = merge(local.observability_log_tags, var.pool.tags) + + job_retry_tags = merge(local.common_tags, var.job_retry.tags) + job_retry_lambda_tags = merge(local.lambda_tags, var.job_retry.tags) + job_retry_log_tags = merge(local.observability_log_tags, var.job_retry.tags) + job_retry_queue_tags = merge(local.queue_tags, var.job_retry.tags) + + ssm_tags = merge(local.common_tags, var.ssm.tags) + ssm_parameter_tags = merge(local.ssm_tags, var.ssm.parameters.tags) + ssm_housekeeper_tags = merge(local.ssm_tags, var.ssm.housekeeper.tags) + ssm_housekeeper_lambda_tags = merge(local.lambda_tags, var.ssm.tags, var.ssm.housekeeper.tags) + ssm_housekeeper_log_tags = merge(local.observability_log_tags, var.ssm.tags, var.ssm.housekeeper.tags) lambda_role_path = var.lambda.role.path == null ? "/${var.prefix}/" : var.lambda.role.path runner_role_path = var.runner.iam.path == null ? "/${var.prefix}/" : var.runner.iam.path lambda_zip = var.lambda.zip == null ? "${path.module}/../../lambdas/functions/control-plane/runners.zip" : var.lambda.zip - kms_key_arn = var.ssm.kms_key_arn != null ? var.ssm.kms_key_arn : "" + kms_key = var.ssm.kms_key enable_job_queued_check = var.scale_up.job_queued_check_enabled == null ? !var.runner.ephemeral : var.scale_up.job_queued_check_enabled token_path = "${var.ssm.paths.root}/${var.ssm.paths.tokens}" arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm.paths.root}/${var.ssm.paths.config}" parameter_store_tags = jsonencode([ - for key, value in merge(local.tags, var.ssm.parameter_tags) : { + for key, value in local.ssm_parameter_tags : { Key = key Value = value } diff --git a/modules/runner-stack/compute-provider-variables.tf b/modules/runner-stack/compute-provider-variables.tf index 02e0db8801..6dfd81fdca 100644 --- a/modules/runner-stack/compute-provider-variables.tf +++ b/modules/runner-stack/compute-provider-variables.tf @@ -1,16 +1,123 @@ # Typed input boundary between the common control plane and compute implementations. variable "compute_provider" { - description = "Typed compute-provider configuration. Provider-owned settings must remain inside the selected provider block." + description = <<-EOT + Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block. + + - `type`: Compute-provider discriminator. The currently supported value is `ec2`. + - `ec2`: EC2 compute-provider configuration. This object is required when `type` is `ec2`. + - `ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults. + - `ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter. + - `ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI. + - `ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource. + - `ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply. + - `ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator. + - `ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply. + - `ec2.vpc_id`: VPC in which runner networking resources are created. + - `ec2.subnet_ids`: Subnets from which scale-up may launch runner instances. + - `ec2.overrides`: Optional resource-name overrides. + - `ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name. + - `ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name. + - `ec2.instance_profile`: Optional externally managed instance profile used by the launch template. + - `ec2.instance_profile.name`: Name of the externally managed instance profile. + - `ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the stack prefix. + - `ec2.binaries_syncer`: Runner-distribution synchronization configuration. + - `ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3. + - `ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled. + - `ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies. + - `ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI. + - `ec2.binaries_syncer.s3.key`: Object key of the runner distribution. + - `ec2.block_device_mappings`: EBS mappings added to the runner launch template. + - `ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates. + - `ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance. + - `ec2.block_device_mappings[].encrypted`: Enables EBS encryption. + - `ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it. + - `ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume. + - `ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume. + - `ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it. + - `ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes. + - `ec2.block_device_mappings[].volume_size`: Volume size in GiB. + - `ec2.block_device_mappings[].volume_type`: EBS volume type. + - `ec2.ebs_optimized`: Requests EBS-optimized runner instances. + - `ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`. + - `ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity. + - `ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type. + - `ec2.instance_max_spot_price`: Optional maximum hourly Spot price. + - `ec2.instance_types`: EC2 instance types available to the scale-up and pool functions. + - `ec2.user_data`: Runner bootstrap user-data configuration. + - `ec2.user_data.enabled`: Enables launch-template user data. + - `ec2.user_data.template`: Optional path to a custom user-data template. + - `ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template. + - `ec2.user_data.pre_install`: Script content inserted before runner installation in the default template. + - `ec2.user_data.post_install`: Script content inserted after runner installation in the default template. + - `ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs. + - `ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access. + - `ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role. + - `ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances. + - `ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow. + - `ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`. + - `ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group. + - `ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults. + - `ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing. + - `ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true. + - `ec2.log_files[].file_path`: File or glob read by the CloudWatch agent. + - `ec2.log_files[].log_stream_name`: CloudWatch log-stream name template. + - `ec2.log_files[].log_class`: CloudWatch log-group class for the collected file. + - `ec2.key_name`: Optional EC2 key-pair name added to the launch template. + - `ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group. + - `ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances. + - `ec2.egress_rules`: Egress rules created on the managed runner security group. + - `ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations. + - `ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations. + - `ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations. + - `ec2.egress_rules[].from_port`: First destination port in the permitted range. + - `ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols. + - `ec2.egress_rules[].security_groups`: Destination security-group IDs. + - `ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true. + - `ec2.egress_rules[].to_port`: Last destination port in the permitted range. + - `ec2.egress_rules[].description`: Optional rule description. + - `ec2.tags`: Additional tags for runner instances, EBS volumes, network interfaces, and eligible Spot instance requests created from the launch template. They override module-level tags and the generated runner `Name`; the provider-managed `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` bootstrap tags take final precedence. These tags do not apply to static provider resources such as the launch template, security group, IAM resources, SSM parameters, or log groups. + - `ec2.metadata_options`: Instance Metadata Service configuration in the launch template. + - `ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`. + - `ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint. + - `ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required. + - `ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses. + - `ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`. + - `ec2.cpu_options`: CPU topology and processor-feature configuration. + - `ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance. + - `ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core. + - `ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types. + - `ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types. + - `ec2.placement`: EC2 placement configuration for runner instances. + - `ec2.placement.affinity`: Host affinity setting. + - `ec2.placement.availability_zone`: Availability Zone in which the instance is placed. + - `ec2.placement.group_id`: Placement-group ID. + - `ec2.placement.group_name`: Placement-group name. + - `ec2.placement.host_id`: Dedicated Host ID. + - `ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement. + - `ec2.placement.spread_domain`: Spread-domain placement value. + - `ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`. + - `ec2.placement.partition_number`: Placement-group partition number. + - `ec2.license_specifications`: License Manager configurations added to the launch template. + - `ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration. + - `ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces. + - `ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure. + - `ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures. + - `ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. + EOT type = object({ type = string ec2 = optional(object({ ami = optional(object({ - filter = optional(map(list(string)), { state = ["available"] }) - owners = optional(list(string), ["amazon"]) - id_ssm_parameter_arn = optional(string, null) - kms_key_arn = optional(string, null) + 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) diff --git a/modules/runner-stack/ec2.tf b/modules/runner-stack/ec2.tf index 421640df05..ea9d018428 100644 --- a/modules/runner-stack/ec2.tf +++ b/modules/runner-stack/ec2.tf @@ -9,7 +9,6 @@ module "ec2" { source = "../compute-providers/ec2" ami = local.ec2.ami - aws_region = var.aws_region vpc_id = local.ec2.vpc_id subnet_ids = local.ec2.subnet_ids overrides = local.ec2.overrides @@ -45,6 +44,7 @@ module "ec2" { runner_architecture = var.runner.architecture logging_retention_in_days = var.observability.logs.retention_in_days logging_kms_key_id = var.observability.logs.kms_key_id + log_group_tags = local.observability_log_tags create_service_linked_role_spot = local.ec2.create_service_linked_role_spot aws_partition = var.aws_partition enable_cloudwatch_agent = local.ec2.cloudwatch_agent.enabled @@ -61,6 +61,7 @@ module "ec2" { metadata_options = local.ec2.metadata_options enable_runner_binaries_syncer = local.ec2.binaries_syncer.enabled ssm_paths = var.ssm.paths + ssm_parameter_tags = local.ssm_parameter_tags runner_name_prefix = var.runner.name_prefix credit_specification = local.ec2.credit_specification cpu_options = local.ec2.cpu_options diff --git a/modules/runner-stack/job-retry.tf b/modules/runner-stack/job-retry.tf index 9245696463..b51c0da0fe 100644 --- a/modules/runner-stack/job-retry.tf +++ b/modules/runner-stack/job-retry.tf @@ -4,15 +4,17 @@ locals { job_retry = { prefix = var.prefix - tags = local.tags + tags = local.job_retry_tags aws_partition = var.aws_partition architecture = var.lambda.architecture runtime = var.lambda.runtime security_group_ids = var.lambda.security_group_ids subnet_ids = var.lambda.subnet_ids - kms_key_arn = var.ssm.kms_key_arn - lambda_tags = var.lambda.tags - log_level = var.observability.log_level + kms_key = var.ssm.kms_key + lambda_tags = local.job_retry_lambda_tags + log_group_tags = local.job_retry_log_tags + queue_tags = local.job_retry_queue_tags + log_level = var.observability.logs.level log_class = var.observability.logs.class logging_kms_key_id = var.observability.logs.kms_key_id logging_retention_in_days = var.observability.logs.retention_in_days diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md index 85ccad7693..584a5d6da5 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/runner-stack/job-retry/README.md @@ -11,26 +11,26 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.21 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [job\_retry](#module\_job\_retry) | ../../lambda | n/a | ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_iam_role_policy.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_lambda_event_source_mapping.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | | [aws_lambda_permission.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | @@ -42,13 +42,13 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runner_name_prefix`: Prefix used to identify runners belonging to this lane.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
reserved_concurrent_executions = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | +| ---- | ----------- | ---- | ------- | :------: | +| [config](#input\_config) | Configuration for the job-retry Lambda and retry queue.

- `aws_partition`: Partition for the base ARN if not `aws`.
- `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
- `environment_variables`: Additional environment variables for the job-retry Lambda. Required job-retry variables override matching keys.
- `enable_organization_runners`: Enable organization runners.
- `ghes_url`: Optional GitHub Enterprise Server URL.
- `user_agent`: Optional User-Agent header for GitHub API requests.
- `github_app_parameters`: SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Metadata for the SSM parameter containing the base64-encoded GitHub App private key.
- `github_app_parameters.key_base64.name`: Name of the private-key parameter supplied to the job-retry Lambda.
- `github_app_parameters.key_base64.arn`: ARN of the private-key parameter used by the job-retry IAM policy.
- `github_app_parameters.id`: Metadata for the SSM parameter containing the GitHub App ID.
- `github_app_parameters.id.name`: Name of the App-ID parameter supplied to the job-retry Lambda.
- `github_app_parameters.id.arn`: ARN of the App-ID parameter used by the job-retry IAM policy.
- `kms_key`: Optional customer-managed KMS key used by the job-retry IAM policy. Object presence controls whether the KMS statement exists.
- `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply.
- `lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
- `lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
- `lambda_tags`: Tags added specifically to the job-retry Lambda function. These override `tags` values with the same key.
- `log_group_tags`: Tags added specifically to the job-retry Lambda log group. These override `tags` values with the same key.
- `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
- `log_class`: CloudWatch log-group class for the job-retry Lambda. Supported values are `STANDARD` and `INFREQUENT_ACCESS`.
- `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
- `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.
- `memory_size`: Memory size limit in MB of the lambda.
- `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
- `metrics`: Configuration to enable metrics creation by the lambda.
- `metrics.enable`: Whether the job-retry Lambda emits metrics.
- `metrics.namespace`: CloudWatch namespace for metrics emitted by the job-retry Lambda.
- `metrics.metric`: Controls individual metrics emitted by the job-retry Lambda.
- `metrics.metric.enable_github_app_rate_limit`: Whether to emit GitHub App rate-limit metrics.
- `metrics.metric.enable_job_retry`: Whether to emit job-retry metrics.
- `prefix`: The prefix used for naming resources.
- `principals`: Extra principals allowed to assume the job-retry Lambda execution role, for example during local testing.
- `principals[*].type`: IAM principal type, such as `Service` or `AWS`.
- `principals[*].identifiers`: IAM principal identifiers for the selected principal type.
- `queue_encryption`: Encryption configuration for the retry queue.
- `queue_encryption.kms_data_key_reuse_period_seconds`: Length of time, in seconds, for which SQS reuses a data key.
- `queue_encryption.kms_master_key_id`: KMS key ID used to encrypt the retry queue.
- `queue_encryption.sqs_managed_sse_enabled`: Whether SQS-managed server-side encryption is enabled.
- `role_path`: The path that will be added to the role, if not set the environment name will be used.
- `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
- `runner_name_prefix`: Prefix used to identify runners belonging to this runner configuration.
- `runtime`: AWS Lambda runtime.
- `s3_bucket`: S3 bucket containing the job-retry Lambda deployment package. This is an alternative to `zip`.
- `s3_key`: Object key of the job-retry Lambda deployment package. Required when `s3_bucket` is set.
- `s3_object_version`: Optional version of the job-retry Lambda deployment-package object.
- `security_group_ids`: List of security group IDs associated with the Lambda function.
- `sqs_build_queue`: SQS queue to which the job-retry Lambda republishes job requests.
- `sqs_build_queue.url`: URL of the build queue.
- `sqs_build_queue.arn`: ARN of the build queue.
- `queue_tags`: Map of tags that will be added to the retry queue and event-source mapping.
- `subnet_ids`: Subnets used for the job-retry Lambda VPC configuration.
- `tags`: Base component tags added to the Lambda function, log group, and execution role. Specialized Lambda and log-group tags override matching keys.
- `timeout`: Time out of the lambda in seconds.
- `tracing_config`: Configuration for lambda tracing.
- `tracing_config.mode`: AWS X-Ray tracing mode. A null value disables tracing.
- `tracing_config.capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests.
- `tracing_config.capture_error`: Whether Powertools tracing captures errors as tracing metadata.
- `zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key = optional(object({
arn = string
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_group_tags = optional(map(string), {})
log_level = optional(string, null)
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
reserved_concurrent_executions = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
queue_tags = optional(map(string), {})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | n/a | | [lambda](#output\_lambda) | n/a | diff --git a/modules/runner-stack/job-retry/iam-policies.tf b/modules/runner-stack/job-retry/iam-policies.tf index f8eca8cebd..fcba4469b5 100644 --- a/modules/runner-stack/job-retry/iam-policies.tf +++ b/modules/runner-stack/job-retry/iam-policies.tf @@ -38,7 +38,7 @@ data "aws_iam_policy_document" "job_retry" { } dynamic "statement" { - for_each = var.config.kms_key_arn == null ? [] : var.config.kms_key_arn == "" ? [] : [var.config.kms_key_arn] + for_each = var.config.kms_key == null ? [] : [var.config.kms_key] content { effect = "Allow" @@ -49,7 +49,7 @@ data "aws_iam_policy_document" "job_retry" { "kms:GenerateDataKey", ] - resources = [statement.value] + resources = [statement.value.arn] } } } diff --git a/modules/runner-stack/job-retry/job-retry.tf b/modules/runner-stack/job-retry/job-retry.tf index 411b0903c9..1bb40fcf0a 100644 --- a/modules/runner-stack/job-retry/job-retry.tf +++ b/modules/runner-stack/job-retry/job-retry.tf @@ -19,7 +19,7 @@ locals { name = local.name, handler = "index.jobRetryCheck", zip = local.lambda_zip, - environment_variables = local.environment_variables + environment_variables = merge(var.config.environment_variables, local.environment_variables) metrics_namespace = var.config.metrics.namespace }) } @@ -37,7 +37,7 @@ resource "aws_sqs_queue" "job_retry_check_queue" { kms_master_key_id = var.config.queue_encryption.kms_master_key_id kms_data_key_reuse_period_seconds = var.config.queue_encryption.kms_data_key_reuse_period_seconds - tags = var.config.tags + tags = var.config.queue_tags } module "job_retry" { @@ -50,6 +50,7 @@ resource "aws_lambda_event_source_mapping" "job_retry" { function_name = module.job_retry.lambda.function.arn batch_size = var.config.lambda_event_source_mapping_batch_size maximum_batching_window_in_seconds = var.config.lambda_event_source_mapping_maximum_batching_window_in_seconds + tags = var.config.queue_tags } resource "aws_lambda_permission" "job_retry" { diff --git a/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl b/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl new file mode 100644 index 0000000000..57f38cc742 --- /dev/null +++ b/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl @@ -0,0 +1,77 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/job-retry-test" + } + } +} + +variables { + config = { + prefix = "job-retry-test" + architecture = "arm64" + runtime = "nodejs24.x" + log_level = "trace" + log_class = "INFREQUENT_ACCESS" + enable_organization_runners = false + ghes_url = "" + user_agent = "job-retry-test" + runner_name_prefix = "required-prefix-" + environment_variables = { + CUSTOM_ENV = "preserved" + RUNNER_NAME_PREFIX = "caller-prefix-" + } + github_app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } + } + kms_key = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/job-retry-test" + } + metrics = { + namespace = "JobRetryTest" + } + s3_bucket = "lambda-artifacts" + s3_key = "job-retry.zip" + sqs_build_queue = { + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + } + } +} + +run "preserves_optional_lambda_configuration" { + command = plan + + assert { + condition = output.lambda.function.function.environment[0].variables["CUSTOM_ENV"] == "preserved" + error_message = "Caller-provided job-retry environment variables must be preserved." + } + + assert { + condition = output.lambda.function.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "required-prefix-" + error_message = "Required job-retry environment variables must override caller-provided values." + } + + assert { + condition = output.lambda.log_group.log_group_class == "INFREQUENT_ACCESS" + error_message = "The job-retry log-group class must be preserved through the typed child-module boundary." + } + + assert { + condition = length(data.aws_iam_policy_document.job_retry.statement) == 4 + error_message = "A present KMS key object must add the job-retry KMS policy statement." + } +} diff --git a/modules/runner-stack/job-retry/variables.tf b/modules/runner-stack/job-retry/variables.tf index 7493544cbd..120309a015 100644 --- a/modules/runner-stack/job-retry/variables.tf +++ b/modules/runner-stack/job-retry/variables.tf @@ -1,42 +1,66 @@ variable "config" { description = <<-EOF - Configuration for the spot termination watcher lambda function. + Configuration for the job-retry Lambda and retry queue. - `aws_partition`: Partition for the base arn if not 'aws' - `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86_64' functions. - `environment_variables`: Environment variables for the lambda. - `enable_organization_runners`: Enable organization runners. - `enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning. - 'ghes_url': Optional GitHub Enterprise Server URL. - 'user_agent': Optional User-Agent header for GitHub API requests. - 'github_app_parameters': Parameter Store for GitHub App Parameters. - 'kms_key_arn': Optional CMK Key ARN instead of using the default AWS managed key. - `lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used. - `lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch_size is greater than 10. - `lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing. - `lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment. - `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. - `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with - `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. - `memory_size`: Memory size limit in MB of the lambda. - `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation. - `metrics`: Configuration to enable metrics creation by the lambda. - `prefix`: The prefix used for naming resources. - `role_path`: The path that will be added to the role, if not set the environment name will be used. - `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda. - `runner_name_prefix`: Prefix used to identify runners belonging to this lane. - `runtime`: AWS Lambda runtime. - `s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly. - `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. - `security_group_ids`: List of security group IDs associated with the Lambda function. - 'sqs_build_queue': SQS queue for build events to re-publish job request. - `subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. - `tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked. - `tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment. - `timeout`: Time out of the lambda in seconds. - `tracing_config`: Configuration for lambda tracing. - `zip`: File location of the lambda zip file. + - `aws_partition`: Partition for the base ARN if not `aws`. + - `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86_64' functions. + - `environment_variables`: Additional environment variables for the job-retry Lambda. Required job-retry variables override matching keys. + - `enable_organization_runners`: Enable organization runners. + - `ghes_url`: Optional GitHub Enterprise Server URL. + - `user_agent`: Optional User-Agent header for GitHub API requests. + - `github_app_parameters`: SSM parameter metadata for GitHub App credentials. + - `github_app_parameters.key_base64`: Metadata for the SSM parameter containing the base64-encoded GitHub App private key. + - `github_app_parameters.key_base64.name`: Name of the private-key parameter supplied to the job-retry Lambda. + - `github_app_parameters.key_base64.arn`: ARN of the private-key parameter used by the job-retry IAM policy. + - `github_app_parameters.id`: Metadata for the SSM parameter containing the GitHub App ID. + - `github_app_parameters.id.name`: Name of the App-ID parameter supplied to the job-retry Lambda. + - `github_app_parameters.id.arn`: ARN of the App-ID parameter used by the job-retry IAM policy. + - `kms_key`: Optional customer-managed KMS key used by the job-retry IAM policy. Object presence controls whether the KMS statement exists. + - `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply. + - `lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used. + - `lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch_size is greater than 10. + - `lambda_tags`: Tags added specifically to the job-retry Lambda function. These override `tags` values with the same key. + - `log_group_tags`: Tags added specifically to the job-retry Lambda log group. These override `tags` values with the same key. + - `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. + - `log_class`: CloudWatch log-group class for the job-retry Lambda. Supported values are `STANDARD` and `INFREQUENT_ACCESS`. + - `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with + - `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. + - `memory_size`: Memory size limit in MB of the lambda. + - `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation. + - `metrics`: Configuration to enable metrics creation by the lambda. + - `metrics.enable`: Whether the job-retry Lambda emits metrics. + - `metrics.namespace`: CloudWatch namespace for metrics emitted by the job-retry Lambda. + - `metrics.metric`: Controls individual metrics emitted by the job-retry Lambda. + - `metrics.metric.enable_github_app_rate_limit`: Whether to emit GitHub App rate-limit metrics. + - `metrics.metric.enable_job_retry`: Whether to emit job-retry metrics. + - `prefix`: The prefix used for naming resources. + - `principals`: Extra principals allowed to assume the job-retry Lambda execution role, for example during local testing. + - `principals[*].type`: IAM principal type, such as `Service` or `AWS`. + - `principals[*].identifiers`: IAM principal identifiers for the selected principal type. + - `queue_encryption`: Encryption configuration for the retry queue. + - `queue_encryption.kms_data_key_reuse_period_seconds`: Length of time, in seconds, for which SQS reuses a data key. + - `queue_encryption.kms_master_key_id`: KMS key ID used to encrypt the retry queue. + - `queue_encryption.sqs_managed_sse_enabled`: Whether SQS-managed server-side encryption is enabled. + - `role_path`: The path that will be added to the role, if not set the environment name will be used. + - `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda. + - `runner_name_prefix`: Prefix used to identify runners belonging to this runner configuration. + - `runtime`: AWS Lambda runtime. + - `s3_bucket`: S3 bucket containing the job-retry Lambda deployment package. This is an alternative to `zip`. + - `s3_key`: Object key of the job-retry Lambda deployment package. Required when `s3_bucket` is set. + - `s3_object_version`: Optional version of the job-retry Lambda deployment-package object. + - `security_group_ids`: List of security group IDs associated with the Lambda function. + - `sqs_build_queue`: SQS queue to which the job-retry Lambda republishes job requests. + - `sqs_build_queue.url`: URL of the build queue. + - `sqs_build_queue.arn`: ARN of the build queue. + - `queue_tags`: Map of tags that will be added to the retry queue and event-source mapping. + - `subnet_ids`: Subnets used for the job-retry Lambda VPC configuration. + - `tags`: Base component tags added to the Lambda function, log group, and execution role. Specialized Lambda and log-group tags override matching keys. + - `timeout`: Time out of the lambda in seconds. + - `tracing_config`: Configuration for lambda tracing. + - `tracing_config.mode`: AWS X-Ray tracing mode. A null value disables tracing. + - `tracing_config.capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests. + - `tracing_config.capture_error`: Whether Powertools tracing captures errors as tracing metadata. + - `zip`: File location of the lambda zip file. EOF type = object({ aws_partition = optional(string, null) @@ -49,11 +73,15 @@ variable "config" { key_base64 = map(string) id = map(string) }) - kms_key_arn = optional(string, null) + kms_key = optional(object({ + arn = string + }), null) lambda_event_source_mapping_batch_size = optional(number, 10) lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0) lambda_tags = optional(map(string), {}) + log_group_tags = optional(map(string), {}) log_level = optional(string, null) + log_class = optional(string, "STANDARD") logging_kms_key_id = optional(string, null) logging_retention_in_days = optional(number, null) memory_size = optional(number, null) @@ -89,8 +117,9 @@ variable "config" { url = string arn = string }) - tags = optional(map(string), {}) - timeout = optional(number, 30) + queue_tags = optional(map(string), {}) + tags = optional(map(string), {}) + timeout = optional(number, 30) tracing_config = optional(object({ mode = optional(string, null) capture_http_requests = optional(bool, false) diff --git a/modules/runner-stack/pool.tf b/modules/runner-stack/pool.tf index 9a4f42340b..1b1c730604 100644 --- a/modules/runner-stack/pool.tf +++ b/modules/runner-stack/pool.tf @@ -12,9 +12,9 @@ module "pool" { user_agent = var.github.user_agent github_app_parameters = var.github.app_parameters runners_maximum_count = var.runner.maximum_count - kms_key_arn = local.kms_key_arn + kms_key = local.kms_key lambda = { - log_level = var.observability.log_level + log_level = var.observability.logs.level logging_retention_in_days = var.observability.logs.retention_in_days logging_kms_key_id = var.observability.logs.kms_key_id log_class = var.observability.logs.class @@ -46,8 +46,9 @@ module "pool" { } ssm_token_path = "${var.ssm.paths.root}/${var.ssm.paths.tokens}" ssm_config_path = "${var.ssm.paths.root}/${var.ssm.paths.config}" - tags = local.tags - lambda_tags = var.lambda.tags + tags = local.pool_tags + lambda_tags = local.pool_lambda_tags + log_group_tags = local.pool_log_tags arn_ssm_parameters_path_config = local.arn_ssm_parameters_path_config } diff --git a/modules/runner-stack/pool/README.md b/modules/runner-stack/pool/README.md index 932eb26341..9d1136d6d0 100644 --- a/modules/runner-stack/pool/README.md +++ b/modules/runner-stack/pool/README.md @@ -9,14 +9,14 @@ The pool is an opt-in feature. To be able to use the count on a module level to ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.21 | ## Modules @@ -26,7 +26,7 @@ No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_cloudwatch_log_group.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | @@ -50,16 +50,16 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no | -| [config](#input\_config) | Lookup details in parent module. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_arn = string
role_path = string
ssm_token_path = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
})
| n/a | yes | -| [runner\_provider](#input\_runner\_provider) | Compute provider configuration for the pool Lambda. |
object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
| n/a | yes | -| [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 | +| [config](#input\_config) | Configuration passed from the runner stack to the pool Lambda and scheduler.

- `lambda`: Pool Lambda runtime and deployment configuration.
- `lambda.log_level`: Logging level used by the pool Lambda.
- `lambda.logging_retention_in_days`: Number of days to retain events in the pool Lambda log group.
- `lambda.logging_kms_key_id`: KMS key ID used to encrypt the pool Lambda log group.
- `lambda.log_class`: CloudWatch Logs class for the pool Lambda log group.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use -1 for no reservation.
- `lambda.s3_bucket`: S3 bucket containing the pool Lambda deployment package.
- `lambda.s3_key`: S3 key of the pool Lambda deployment package.
- `lambda.s3_object_version`: S3 object version of the pool Lambda deployment package.
- `lambda.security_group_ids`: Security group IDs associated with the pool Lambda.
- `lambda.runtime`: AWS Lambda runtime used by the pool Lambda.
- `lambda.architecture`: AWS Lambda architecture used by the pool Lambda.
- `lambda.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.timeout`: Pool Lambda timeout in seconds.
- `lambda.zip`: Local path to the pool Lambda deployment package when S3 is not used.
- `lambda.subnet_ids`: Subnet IDs in which the pool Lambda runs.
- `lambda.parameter_store_tags`: JSON-encoded tags supplied to the pool Lambda for SSM parameters it creates.
- `tags`: Common tags added to pool resources.
- `ghes`: GitHub Enterprise Server connection configuration.
- `ghes.url`: GitHub Enterprise Server URL; null when using public GitHub.
- `ghes.ssl_verify`: Whether the pool Lambda verifies the GitHub Enterprise Server TLS certificate.
- `github_app_parameters`: SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Metadata for the SSM parameter containing the base64-encoded GitHub App private key.
- `github_app_parameters.key_base64.name`: Name of the private-key parameter supplied to the pool Lambda.
- `github_app_parameters.key_base64.arn`: ARN of the private-key parameter used by the pool IAM policy.
- `github_app_parameters.id`: Metadata for the SSM parameter containing the GitHub App ID.
- `github_app_parameters.id.name`: Name of the App-ID parameter supplied to the pool Lambda.
- `github_app_parameters.id.arn`: ARN of the App-ID parameter used by the pool IAM policy.
- `runner`: Runner registration configuration used by the pool Lambda.
- `runner.disable_runner_autoupdate`: Whether GitHub runner automatic updates are disabled.
- `runner.ephemeral`: Whether runners register as ephemeral runners.
- `runner.enable_jit_config`: Whether runners use just-in-time registration configuration.
- `runner.labels`: Labels assigned to runners created by the pool Lambda.
- `runner.group_name`: GitHub runner group assigned to runners created by the pool Lambda.
- `runner.name_prefix`: Prefix used for runner names.
- `runner.pool_owner`: GitHub organization or repository that owns the runner pool.
- `runners_maximum_count`: Maximum number of runners that the pool Lambda may create.
- `prefix`: Prefix used to name pool resources.
- `pool`: Scheduled pool targets.
- `pool[*].schedule_expression`: EventBridge Scheduler expression for a pool target.
- `pool[*].schedule_expression_timezone`: Time zone used to evaluate the schedule expression.
- `pool[*].size`: Desired runner count for the scheduled pool target.
- `include_busy_runners`: Whether busy runners count toward the desired pool size.
- `role_permissions_boundary`: Permissions boundary applied to IAM roles created for the pool.
- `kms_key`: Optional customer-managed KMS key that the pool Lambda may use to decrypt encrypted parameters. Object presence controls whether the KMS statement exists.
- `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_config_path`: SSM path under which runner configuration is stored.
- `arn_ssm_parameters_path_config`: ARN matching the runner configuration SSM path.
- `lambda_tags`: Tags added specifically to the pool Lambda function, overriding common tags with the same key.
- `log_group_tags`: Tags added specifically to the pool Lambda log group, overriding common tags with the same key.
- `user_agent`: User-Agent header used for GitHub API requests. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key = optional(object({
arn = string
}), null)
role_path = string
ssm_token_path = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
log_group_tags = optional(map(string), {})
user_agent = string
})
| n/a | yes | +| [runner\_provider](#input\_runner\_provider) | Compute provider integration used by the pool Lambda.

- `type`: Compute provider type passed to scheduled pool invocations.
- `environment_variables`: Provider-specific environment variables added to the pool Lambda.
- `iam_policy_json`: Provider-specific IAM policy document merged into the pool Lambda policy.
- `managed_policy_enabled`: Whether to attach a provider-specific managed IAM policy to the pool Lambda role.
- `managed_policy_arn`: ARN of the provider-specific managed IAM policy to attach when enabled. |
object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
| n/a | yes | +| [tracing\_config](#input\_tracing\_config) | Tracing configuration for the pool Lambda.

- `mode`: AWS X-Ray tracing mode. A null value disables tracing.
- `capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests.
- `capture_error`: Whether Powertools tracing captures errors as tracing metadata. |
object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
})
| `{}` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [lambda](#output\_lambda) | n/a | | [lambda\_log\_group](#output\_lambda\_log\_group) | n/a | | [role\_pool](#output\_role\_pool) | n/a | diff --git a/modules/runner-stack/pool/iam-policies.tf b/modules/runner-stack/pool/iam-policies.tf index 4815b993eb..13690cce09 100644 --- a/modules/runner-stack/pool/iam-policies.tf +++ b/modules/runner-stack/pool/iam-policies.tf @@ -41,13 +41,13 @@ data "aws_iam_policy_document" "pool_common" { } dynamic "statement" { - for_each = var.config.kms_key_arn == "" ? [] : [var.config.kms_key_arn] + for_each = var.config.kms_key == null ? [] : [var.config.kms_key] content { effect = "Allow" actions = ["kms:Decrypt"] - resources = [statement.value] + resources = [statement.value.arn] } } } diff --git a/modules/runner-stack/pool/pool.tf b/modules/runner-stack/pool/pool.tf index 15fec39a7e..5e95c897ca 100644 --- a/modules/runner-stack/pool/pool.tf +++ b/modules/runner-stack/pool/pool.tf @@ -76,7 +76,7 @@ resource "aws_cloudwatch_log_group" "pool" { retention_in_days = var.config.lambda.logging_retention_in_days kms_key_id = var.config.lambda.logging_kms_key_id log_group_class = var.config.lambda.log_class - tags = var.config.tags + tags = merge(var.config.tags, var.config.log_group_tags) } resource "aws_iam_role" "pool" { diff --git a/modules/runner-stack/pool/tests/provider.tftest.hcl b/modules/runner-stack/pool/tests/provider.tftest.hcl index 729ff76592..fc42af2780 100644 --- a/modules/runner-stack/pool/tests/provider.tftest.hcl +++ b/modules/runner-stack/pool/tests/provider.tftest.hcl @@ -59,9 +59,11 @@ variables { schedule_expression_timezone = "UTC" size = 2 }] - include_busy_runners = false - role_permissions_boundary = null - kms_key_arn = "" + include_busy_runners = false + role_permissions_boundary = null + kms_key = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/pool-test" + } role_path = "/" ssm_token_path = "/github-runner/tokens" ssm_config_path = "/github-runner/config" @@ -116,6 +118,11 @@ run "provider_supplies_only_compute_specific_pool_configuration" { error_message = "The pool role policy must merge the common and compute-provider policy documents." } + assert { + condition = length(data.aws_iam_policy_document.pool_common.statement) == 4 + error_message = "A present KMS key object must add the pool KMS policy statement." + } + assert { condition = length(aws_iam_role_policy_attachment.provider) == 1 error_message = "The optional compute-provider managed policy must be attached to the pool role." diff --git a/modules/runner-stack/pool/variables.tf b/modules/runner-stack/pool/variables.tf index 833fdb4956..63cbf90e30 100644 --- a/modules/runner-stack/pool/variables.tf +++ b/modules/runner-stack/pool/variables.tf @@ -1,5 +1,61 @@ variable "config" { - description = "Lookup details in parent module." + description = <<-EOF + Configuration passed from the runner stack to the pool Lambda and scheduler. + + - `lambda`: Pool Lambda runtime and deployment configuration. + - `lambda.log_level`: Logging level used by the pool Lambda. + - `lambda.logging_retention_in_days`: Number of days to retain events in the pool Lambda log group. + - `lambda.logging_kms_key_id`: KMS key ID used to encrypt the pool Lambda log group. + - `lambda.log_class`: CloudWatch Logs class for the pool Lambda log group. + - `lambda.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use -1 for no reservation. + - `lambda.s3_bucket`: S3 bucket containing the pool Lambda deployment package. + - `lambda.s3_key`: S3 key of the pool Lambda deployment package. + - `lambda.s3_object_version`: S3 object version of the pool Lambda deployment package. + - `lambda.security_group_ids`: Security group IDs associated with the pool Lambda. + - `lambda.runtime`: AWS Lambda runtime used by the pool Lambda. + - `lambda.architecture`: AWS Lambda architecture used by the pool Lambda. + - `lambda.memory_size`: Memory allocated to the pool Lambda in MB. + - `lambda.timeout`: Pool Lambda timeout in seconds. + - `lambda.zip`: Local path to the pool Lambda deployment package when S3 is not used. + - `lambda.subnet_ids`: Subnet IDs in which the pool Lambda runs. + - `lambda.parameter_store_tags`: JSON-encoded tags supplied to the pool Lambda for SSM parameters it creates. + - `tags`: Common tags added to pool resources. + - `ghes`: GitHub Enterprise Server connection configuration. + - `ghes.url`: GitHub Enterprise Server URL; null when using public GitHub. + - `ghes.ssl_verify`: Whether the pool Lambda verifies the GitHub Enterprise Server TLS certificate. + - `github_app_parameters`: SSM parameter metadata for GitHub App credentials. + - `github_app_parameters.key_base64`: Metadata for the SSM parameter containing the base64-encoded GitHub App private key. + - `github_app_parameters.key_base64.name`: Name of the private-key parameter supplied to the pool Lambda. + - `github_app_parameters.key_base64.arn`: ARN of the private-key parameter used by the pool IAM policy. + - `github_app_parameters.id`: Metadata for the SSM parameter containing the GitHub App ID. + - `github_app_parameters.id.name`: Name of the App-ID parameter supplied to the pool Lambda. + - `github_app_parameters.id.arn`: ARN of the App-ID parameter used by the pool IAM policy. + - `runner`: Runner registration configuration used by the pool Lambda. + - `runner.disable_runner_autoupdate`: Whether GitHub runner automatic updates are disabled. + - `runner.ephemeral`: Whether runners register as ephemeral runners. + - `runner.enable_jit_config`: Whether runners use just-in-time registration configuration. + - `runner.labels`: Labels assigned to runners created by the pool Lambda. + - `runner.group_name`: GitHub runner group assigned to runners created by the pool Lambda. + - `runner.name_prefix`: Prefix used for runner names. + - `runner.pool_owner`: GitHub organization or repository that owns the runner pool. + - `runners_maximum_count`: Maximum number of runners that the pool Lambda may create. + - `prefix`: Prefix used to name pool resources. + - `pool`: Scheduled pool targets. + - `pool[*].schedule_expression`: EventBridge Scheduler expression for a pool target. + - `pool[*].schedule_expression_timezone`: Time zone used to evaluate the schedule expression. + - `pool[*].size`: Desired runner count for the scheduled pool target. + - `include_busy_runners`: Whether busy runners count toward the desired pool size. + - `role_permissions_boundary`: Permissions boundary applied to IAM roles created for the pool. + - `kms_key`: Optional customer-managed KMS key that the pool Lambda may use to decrypt encrypted parameters. Object presence controls whether the KMS statement exists. + - `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply. + - `role_path`: IAM path applied to roles created for the pool. + - `ssm_token_path`: SSM path under which runner registration tokens are stored. + - `ssm_config_path`: SSM path under which runner configuration is stored. + - `arn_ssm_parameters_path_config`: ARN matching the runner configuration SSM path. + - `lambda_tags`: Tags added specifically to the pool Lambda function, overriding common tags with the same key. + - `log_group_tags`: Tags added specifically to the pool Lambda log group, overriding common tags with the same key. + - `user_agent`: User-Agent header used for GitHub API requests. + EOF type = object({ lambda = object({ log_level = string @@ -44,20 +100,31 @@ variable "config" { schedule_expression_timezone = string size = number })) - include_busy_runners = bool - role_permissions_boundary = string - kms_key_arn = string + include_busy_runners = bool + role_permissions_boundary = string + kms_key = optional(object({ + arn = string + }), null) role_path = string ssm_token_path = string ssm_config_path = string arn_ssm_parameters_path_config = string lambda_tags = map(string) + log_group_tags = optional(map(string), {}) user_agent = string }) } variable "runner_provider" { - description = "Compute provider configuration for the pool Lambda." + description = <<-EOF + Compute provider integration used by the pool Lambda. + + - `type`: Compute provider type passed to scheduled pool invocations. + - `environment_variables`: Provider-specific environment variables added to the pool Lambda. + - `iam_policy_json`: Provider-specific IAM policy document merged into the pool Lambda policy. + - `managed_policy_enabled`: Whether to attach a provider-specific managed IAM policy to the pool Lambda role. + - `managed_policy_arn`: ARN of the provider-specific managed IAM policy to attach when enabled. + EOF type = object({ type = string environment_variables = map(string) @@ -89,7 +156,13 @@ variable "aws_partition" { } variable "tracing_config" { - description = "Configuration for lambda tracing." + description = <<-EOF + Tracing configuration for the pool Lambda. + + - `mode`: AWS X-Ray tracing mode. A null value disables tracing. + - `capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests. + - `capture_error`: Whether Powertools tracing captures errors as tracing metadata. + EOF type = object({ mode = optional(string, null) capture_http_requests = optional(bool, false) diff --git a/modules/runner-stack/runner-role.tf b/modules/runner-stack/runner-role.tf index 6afeae5867..0b9a8f916b 100644 --- a/modules/runner-stack/runner-role.tf +++ b/modules/runner-stack/runner-role.tf @@ -47,7 +47,7 @@ resource "aws_iam_role" "runner" { assume_role_policy = local.provider_runner_role.assume_role_policy_json path = local.runner_role_path permissions_boundary = var.runner.iam.permissions_boundary - tags = local.tags + tags = local.runner_tags lifecycle { precondition { diff --git a/modules/runner-stack/runner-ssm-parameters.tf b/modules/runner-stack/runner-ssm-parameters.tf index e1b56b2412..43708f1d02 100644 --- a/modules/runner-stack/runner-ssm-parameters.tf +++ b/modules/runner-stack/runner-ssm-parameters.tf @@ -3,26 +3,26 @@ resource "aws_ssm_parameter" "runner_agent_mode" { name = "${var.ssm.paths.root}/${var.ssm.paths.config}/agent_mode" type = "String" value = var.runner.ephemeral ? "ephemeral" : "persistent" - tags = local.tags + tags = local.ssm_parameter_tags } resource "aws_ssm_parameter" "disable_default_labels" { name = "${var.ssm.paths.root}/${var.ssm.paths.config}/disable_default_labels" type = "String" value = var.runner.disable_default_labels - tags = local.tags + tags = local.ssm_parameter_tags } resource "aws_ssm_parameter" "jit_config_enabled" { name = "${var.ssm.paths.root}/${var.ssm.paths.config}/enable_jit_config" type = "String" value = var.runner.jit_config_enabled == null ? var.runner.ephemeral : var.runner.jit_config_enabled - tags = local.tags + tags = local.ssm_parameter_tags } resource "aws_ssm_parameter" "token_path" { name = "${var.ssm.paths.root}/${var.ssm.paths.config}/token_path" type = "String" value = "${var.ssm.paths.root}/${var.ssm.paths.tokens}" - tags = local.tags + tags = local.ssm_parameter_tags } diff --git a/modules/runner-stack/scale-down-iam-policies.tf b/modules/runner-stack/scale-down-iam-policies.tf index 8f2650d431..60a2aff111 100644 --- a/modules/runner-stack/scale-down-iam-policies.tf +++ b/modules/runner-stack/scale-down-iam-policies.tf @@ -13,12 +13,12 @@ data "aws_iam_policy_document" "scale_down_common" { } dynamic "statement" { - for_each = local.kms_key_arn == "" ? [] : [local.kms_key_arn] + for_each = local.kms_key == null ? [] : [local.kms_key] content { effect = "Allow" actions = ["kms:Decrypt"] - resources = [statement.value] + resources = [statement.value.arn] } } } diff --git a/modules/runner-stack/scale-down.tf b/modules/runner-stack/scale-down.tf index 93093544c7..57094fa499 100644 --- a/modules/runner-stack/scale-down.tf +++ b/modules/runner-stack/scale-down.tf @@ -19,7 +19,7 @@ resource "aws_lambda_function" "scale_down" { handler = "index.scaleDownHandler" runtime = var.lambda.runtime timeout = var.scale_down.timeout - tags = merge(local.tags, var.lambda.tags) + tags = local.scale_down_lambda_tags memory_size = var.scale_down.memory_size architectures = [var.lambda.architecture] @@ -29,12 +29,12 @@ resource "aws_lambda_function" "scale_down" { ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.observability.metrics.enable && var.observability.metrics.metric.enable_github_app_rate_limit GHES_URL = var.github.enterprise_server.url USER_AGENT = var.github.user_agent - LOG_LEVEL = upper(var.observability.log_level) + LOG_LEVEL = upper(var.observability.logs.level) MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner.os]) NODE_TLS_REJECT_UNAUTHORIZED = var.github.enterprise_server.url != null && !var.github.enterprise_server.ssl_verify ? 0 : 1 PARAMETER_GITHUB_APP_ID_NAME = var.github.app_parameters.id.name PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github.app_parameters.key_base64.name - POWERTOOLS_LOGGER_LOG_EVENT = var.observability.log_level == "debug" ? "true" : "false" + POWERTOOLS_LOGGER_LOG_EVENT = var.observability.logs.level == "debug" ? "true" : "false" SCALE_DOWN_CONFIG = jsonencode(var.scale_down.idle_config) POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-down" POWERTOOLS_METRICS_NAMESPACE = var.observability.metrics.namespace @@ -66,13 +66,13 @@ resource "aws_cloudwatch_log_group" "scale_down" { retention_in_days = var.observability.logs.retention_in_days kms_key_id = var.observability.logs.kms_key_id log_group_class = var.observability.logs.class - tags = var.tags + tags = local.scale_down_log_tags } resource "aws_cloudwatch_event_rule" "scale_down" { name = "${var.prefix}-scale-down-rule" schedule_expression = var.scale_down.schedule_expression - tags = var.tags + tags = local.scale_down_tags } resource "aws_cloudwatch_event_target" "scale_down" { @@ -93,7 +93,7 @@ resource "aws_iam_role" "scale_down" { assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json path = local.lambda_role_path permissions_boundary = var.lambda.role.permissions_boundary - tags = local.tags + tags = local.scale_down_tags } resource "aws_iam_role_policy" "scale_down" { diff --git a/modules/runner-stack/scale-up-iam-policies.tf b/modules/runner-stack/scale-up-iam-policies.tf index 29cc34b946..6a45e1db17 100644 --- a/modules/runner-stack/scale-up-iam-policies.tf +++ b/modules/runner-stack/scale-up-iam-policies.tf @@ -33,12 +33,12 @@ data "aws_iam_policy_document" "scale_up_common" { } dynamic "statement" { - for_each = local.kms_key_arn == "" ? [] : [local.kms_key_arn] + for_each = local.kms_key == null ? [] : [local.kms_key] content { effect = "Allow" actions = ["kms:Decrypt"] - resources = [statement.value] + resources = [statement.value.arn] } } } diff --git a/modules/runner-stack/scale-up.tf b/modules/runner-stack/scale-up.tf index 1f21503186..cc9e6d3281 100644 --- a/modules/runner-stack/scale-up.tf +++ b/modules/runner-stack/scale-up.tf @@ -21,7 +21,7 @@ resource "aws_lambda_function" "scale_up" { timeout = var.scale_up.timeout reserved_concurrent_executions = var.scale_up.reserved_concurrent_executions memory_size = var.scale_up.memory_size - tags = merge(local.tags, var.lambda.tags) + tags = local.scale_up_lambda_tags architectures = [var.lambda.architecture] environment { variables = merge(local.provider.scale_up.environment_variables, { @@ -34,12 +34,12 @@ resource "aws_lambda_function" "scale_up" { ENVIRONMENT = var.prefix GHES_URL = var.github.enterprise_server.url USER_AGENT = var.github.user_agent - LOG_LEVEL = upper(var.observability.log_level) + LOG_LEVEL = upper(var.observability.logs.level) MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner.os]) NODE_TLS_REJECT_UNAUTHORIZED = var.github.enterprise_server.url != null && !var.github.enterprise_server.ssl_verify ? 0 : 1 PARAMETER_GITHUB_APP_ID_NAME = var.github.app_parameters.id.name PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github.app_parameters.key_base64.name - POWERTOOLS_LOGGER_LOG_EVENT = var.observability.log_level == "debug" ? "true" : "false" + POWERTOOLS_LOGGER_LOG_EVENT = var.observability.logs.level == "debug" ? "true" : "false" POWERTOOLS_METRICS_NAMESPACE = var.observability.metrics.namespace POWERTOOLS_TRACE_ENABLED = var.observability.tracing.mode != null ? true : false POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.observability.tracing.capture_http_requests @@ -78,7 +78,7 @@ resource "aws_cloudwatch_log_group" "scale_up" { retention_in_days = var.observability.logs.retention_in_days kms_key_id = var.observability.logs.kms_key_id log_group_class = var.observability.logs.class - tags = var.tags + tags = local.scale_up_log_tags } resource "aws_lambda_event_source_mapping" "scale_up" { @@ -87,7 +87,7 @@ resource "aws_lambda_event_source_mapping" "scale_up" { function_response_types = ["ReportBatchItemFailures"] batch_size = var.queue.event_source_mapping.batch_size maximum_batching_window_in_seconds = var.queue.event_source_mapping.maximum_batching_window_in_seconds - tags = var.tags + tags = local.scale_up_queue_tags } resource "aws_lambda_permission" "scale_runners_lambda" { @@ -103,7 +103,7 @@ resource "aws_iam_role" "scale_up" { assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json path = local.lambda_role_path permissions_boundary = var.lambda.role.permissions_boundary - tags = local.tags + tags = local.scale_up_tags } resource "aws_iam_role_policy" "scale_up" { diff --git a/modules/runner-stack/ssm-housekeeper.tf b/modules/runner-stack/ssm-housekeeper.tf index c5fa641b9c..f831619e56 100644 --- a/modules/runner-stack/ssm-housekeeper.tf +++ b/modules/runner-stack/ssm-housekeeper.tf @@ -23,14 +23,14 @@ resource "aws_lambda_function" "ssm_housekeeper" { handler = "index.ssmHousekeeper" runtime = var.lambda.runtime timeout = local.ssm_housekeeper.lambda_timeout - tags = merge(local.tags, var.lambda.tags) + tags = local.ssm_housekeeper_lambda_tags memory_size = local.ssm_housekeeper.lambda_memory_size architectures = [var.lambda.architecture] environment { variables = { ENVIRONMENT = var.prefix - LOG_LEVEL = upper(var.observability.log_level) + LOG_LEVEL = upper(var.observability.logs.level) SSM_CLEANUP_CONFIG = jsonencode(local.ssm_housekeeper.config) POWERTOOLS_SERVICE_NAME = "${var.prefix}-ssm-housekeeper" POWERTOOLS_TRACE_ENABLED = var.observability.tracing.mode != null ? true : false @@ -60,13 +60,13 @@ resource "aws_cloudwatch_log_group" "ssm_housekeeper" { retention_in_days = var.observability.logs.retention_in_days kms_key_id = var.observability.logs.kms_key_id log_group_class = var.observability.logs.class - tags = var.tags + tags = local.ssm_housekeeper_log_tags } resource "aws_cloudwatch_event_rule" "ssm_housekeeper" { name = "${var.prefix}-ssm-housekeeper" schedule_expression = local.ssm_housekeeper.schedule_expression - tags = var.tags + tags = local.ssm_housekeeper_tags state = local.ssm_housekeeper.state } @@ -89,7 +89,7 @@ resource "aws_iam_role" "ssm_housekeeper" { assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json path = local.lambda_role_path permissions_boundary = var.lambda.role.permissions_boundary - tags = local.tags + tags = local.ssm_housekeeper_tags } resource "aws_iam_role_policy" "ssm_housekeeper" { diff --git a/modules/runner-stack/tests/computed-iam-inputs.tftest.hcl b/modules/runner-stack/tests/computed-iam-inputs.tftest.hcl index bf4469e1f5..9e81f63b64 100644 --- a/modules/runner-stack/tests/computed-iam-inputs.tftest.hcl +++ b/modules/runner-stack/tests/computed-iam-inputs.tftest.hcl @@ -6,7 +6,7 @@ mock_provider "aws" { } } -run "computed_iam_values_keep_plan_shape_known" { +run "computed_external_values_keep_plan_shape_known" { command = plan module { @@ -15,7 +15,7 @@ run "computed_iam_values_keep_plan_shape_known" { assert { condition = output.external_role_runner_count == 0 - error_message = "A computed external role ARN and profile name must not make role or profile counts unknown." + error_message = "Computed external AMI parameter, KMS key, role, and profile values must not make resource or policy-block counts unknown." } assert { diff --git a/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf b/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf index 7abef22746..e0265a21b1 100644 --- a/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf +++ b/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -1,4 +1,7 @@ -# Plan-time regression fixture for computed role, profile, and policy values. +# A .tftest.hcl variable block supplies plan-known values. This wrapper uses +# random_id results to exercise caller inputs that remain unknown during plan, +# which catches invalid count, for_each, and dynamic-block expressions in the +# IAM boundary. resource "random_id" "external" { byte_length = 4 } @@ -19,6 +22,14 @@ module "external_iam" { vpc_id = "vpc-12345678" subnet_ids = ["subnet-12345678"] instance_types = ["m5.large"] + ami = { + id_ssm_parameter = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/external-ami-${random_id.external.hex}" + } + kms_key = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/${random_id.external.hex}" + } + } instance_profile = { name = "external-runner-${random_id.external.hex}" } @@ -54,6 +65,18 @@ module "external_iam" { } } + job_retry = { + enabled = true + } + + pool = { + runner_owner = "example" + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + github = { organization_runners = true app_parameters = { @@ -69,6 +92,9 @@ module "external_iam" { } ssm = { + kms_key = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/${random_id.external.hex}" + } paths = { root = "/github-runner/computed-external" tokens = "tokens" diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl index 749aab40cc..d1ddc99156 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -28,10 +28,12 @@ variables { subnet_ids = ["subnet-12345678"] instance_types = ["m5.large"] ami = { - filter = { state = ["available"] } - owners = ["amazon"] - id_ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id" - kms_key_arn = null + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id" + } + kms_key = null } binaries_syncer = { s3 = { @@ -112,14 +114,8 @@ run "plan_with_pool_enabled" { } assert { - condition = tomap({ - for tag in jsondecode(aws_lambda_function.scale_up.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : - tag.Key => tag.Value - }) == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - }) - error_message = "Parameter Store tags must include the normalized common stack tags." + condition = length(jsondecode(aws_lambda_function.scale_up.environment[0].variables["SSM_PARAMETER_STORE_TAGS"])) == 0 + error_message = "Runtime Parameter Store tags must remain empty when no module or SSM tags are configured; EC2 bootstrap tags must not leak into them." } assert { @@ -324,7 +320,7 @@ run "rejects_unimplemented_compute_provider" { expect_failures = [var.compute_provider] } -run "job_retry_uses_common_lane_identity" { +run "job_retry_uses_common_runner_configuration_identity" { command = plan variables { @@ -342,7 +338,7 @@ run "job_retry_uses_common_lane_identity" { assert { condition = module.job_retry[0].lambda.function.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "provider-neutral-" - error_message = "Job retry must receive the common lane runner-name prefix." + error_message = "Job retry must receive the common runner-configuration name prefix." } assert { diff --git a/modules/runner-stack/tests/tags.tftest.hcl b/modules/runner-stack/tests/tags.tftest.hcl new file mode 100644 index 0000000000..0702feff0c --- /dev/null +++ b/modules/runner-stack/tests/tags.tftest.hcl @@ -0,0 +1,311 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/runner-test" + } + } + + mock_resource "aws_ssm_parameter" { + defaults = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/config" + } + } +} + +variables { + aws_region = "eu-west-1" + + tags = { + precedence = "module" + module = "yes" + } + + compute_provider = { + type = "ec2" + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id" + } + kms_key = null + } + binaries_syncer = { + s3 = { + arn = "arn:aws:s3:::my-bucket" + id = "my-bucket" + key = "runners/linux/actions-runner.tar.gz" + } + } + } + } + + runner = { + labels = ["self-hosted", "linux", "x64"] + tags = { + precedence = "runner" + runner = "yes" + } + } + + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + } + tags = { + precedence = "queue" + queue = "yes" + } + } + + lambda = { + s3 = { + bucket = "my-lambda-bucket" + key = "runners.zip" + } + tags = { + precedence = "lambda" + lambda = "yes" + } + } + + github = { + organization_runners = true + app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } + } + } + + scale_up = { + tags = { + precedence = "scale-up" + scale_up = "yes" + } + } + + scale_down = { + tags = { + precedence = "scale-down" + scale_down = "yes" + } + } + + pool = { + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + tags = { + precedence = "pool" + pool = "yes" + } + } + + job_retry = { + enabled = true + tags = { + precedence = "job-retry" + job_retry = "yes" + } + } + + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + tags = { + precedence = "ssm" + ssm = "yes" + } + parameters = { + tags = { + precedence = "ssm-parameter" + parameter = "yes" + } + } + housekeeper = { + tags = { + precedence = "ssm-housekeeper" + housekeeper = "yes" + } + } + } + + observability = { + logs = { + level = "debug" + tags = { + precedence = "log" + log = "yes" + } + } + } +} + +run "layered_component_tags" { + command = plan + + assert { + condition = aws_lambda_function.scale_up.environment[0].variables["LOG_LEVEL"] == "DEBUG" + error_message = "The nested observability.logs.level value must configure the control-plane functions." + } + + assert { + condition = aws_lambda_function.scale_up.tags == tomap({ + precedence = "scale-up" + module = "yes" + lambda = "yes" + scale_up = "yes" + }) && aws_cloudwatch_log_group.scale_up.tags == tomap({ + precedence = "scale-up" + module = "yes" + log = "yes" + scale_up = "yes" + }) && aws_lambda_event_source_mapping.scale_up.tags == tomap({ + precedence = "scale-up" + module = "yes" + queue = "yes" + scale_up = "yes" + }) && aws_iam_role.scale_up.tags == tomap({ + precedence = "scale-up" + module = "yes" + scale_up = "yes" + }) + error_message = "Scale-up tags must layer module, shared resource, and component tags with the component taking precedence." + } + + assert { + condition = aws_lambda_function.scale_down.tags == tomap({ + precedence = "scale-down" + module = "yes" + lambda = "yes" + scale_down = "yes" + }) && aws_cloudwatch_log_group.scale_down.tags == tomap({ + precedence = "scale-down" + module = "yes" + log = "yes" + scale_down = "yes" + }) && aws_cloudwatch_event_rule.scale_down.tags == tomap({ + precedence = "scale-down" + module = "yes" + scale_down = "yes" + }) && aws_iam_role.scale_down.tags == tomap({ + precedence = "scale-down" + module = "yes" + scale_down = "yes" + }) + error_message = "Scale-down tags must layer module, shared resource, and component tags with the component taking precedence." + } + + assert { + condition = aws_iam_role.runner[0].tags == tomap({ + precedence = "runner" + module = "yes" + runner = "yes" + }) + error_message = "Runner tags must override module tags on the common runner role." + } + + assert { + condition = aws_ssm_parameter.runner_agent_mode.tags == tomap({ + precedence = "ssm-parameter" + module = "yes" + ssm = "yes" + parameter = "yes" + }) && tomap({ + for tag in jsondecode(aws_lambda_function.scale_up.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + tag.Key => tag.Value + }) == tomap({ + precedence = "ssm-parameter" + module = "yes" + ssm = "yes" + parameter = "yes" + }) + error_message = "Terraform-managed and runtime-created SSM parameters must use the same layered parameter tags." + } + + assert { + condition = aws_lambda_function.ssm_housekeeper.tags == tomap({ + precedence = "ssm-housekeeper" + module = "yes" + lambda = "yes" + ssm = "yes" + housekeeper = "yes" + }) && aws_cloudwatch_log_group.ssm_housekeeper.tags == tomap({ + precedence = "ssm-housekeeper" + module = "yes" + log = "yes" + ssm = "yes" + housekeeper = "yes" + }) && aws_iam_role.ssm_housekeeper.tags == tomap({ + precedence = "ssm-housekeeper" + module = "yes" + ssm = "yes" + housekeeper = "yes" + }) + error_message = "SSM housekeeper tags must layer module, SSM, shared resource, and housekeeper tags." + } + + assert { + condition = module.pool[0].lambda.tags == tomap({ + precedence = "pool" + module = "yes" + lambda = "yes" + pool = "yes" + }) && module.pool[0].lambda_log_group.tags == tomap({ + precedence = "pool" + module = "yes" + log = "yes" + pool = "yes" + }) && module.pool[0].role_pool.tags == tomap({ + precedence = "pool" + module = "yes" + pool = "yes" + }) + error_message = "Pool tags must layer module, shared resource, and component tags with the component taking precedence." + } + + assert { + condition = module.job_retry[0].lambda.function.function.tags == tomap({ + precedence = "job-retry" + module = "yes" + lambda = "yes" + job_retry = "yes" + }) && module.job_retry[0].lambda.log_group.tags == tomap({ + precedence = "job-retry" + module = "yes" + log = "yes" + job_retry = "yes" + }) && module.job_retry[0].lambda.role.tags == tomap({ + precedence = "job-retry" + module = "yes" + job_retry = "yes" + }) && module.job_retry[0].job_retry_check_queue.tags == tomap({ + precedence = "job-retry" + module = "yes" + queue = "yes" + job_retry = "yes" + }) + error_message = "Job-retry tags must layer module, shared resource, and component tags with the component taking precedence." + } +} diff --git a/modules/runner-stack/variables.tf b/modules/runner-stack/variables.tf index e4fb4cf6ea..cfb2257a5b 100644 --- a/modules/runner-stack/variables.tf +++ b/modules/runner-stack/variables.tf @@ -16,13 +16,36 @@ variable "prefix" { } variable "tags" { - description = "Map of tags added to created resources." + description = "Base tags added to taggable resources created by this stack. Shared, component, and compute-provider tag maps override matching keys within their documented resource scopes." type = map(string) default = {} } variable "runner" { - description = "Provider-neutral GitHub runner configuration." + description = <<-EOT + Provider-neutral GitHub runner configuration. + + - `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`. + - `architecture`: Runner distribution architecture, such as `x64` or `arm64`. + - `boot_time_in_minutes`: Expected instance boot duration used before a runner is considered stale. + - `disable_default_labels`: Prevents GitHub's default self-hosted, operating-system, and architecture labels from being registered. + - `labels`: Complete set of labels supplied to the control-plane functions. + - `group_name`: GitHub runner group used during registration. + - `name_prefix`: Prefix added to registered runner names. + - `run_as_root`: Runs the runner service as root when supported by the compute provider. + - `run_as`: Operating-system user used when `run_as_root` is false. + - `maximum_count`: Maximum number of runners that may exist for this stack. + - `ephemeral`: Registers runners in ephemeral mode. + - `jit_config_enabled`: Explicitly enables or disables just-in-time configuration. When null, runtime behavior follows `ephemeral`. + - `auto_update_disabled`: Disables the GitHub runner application's built-in updater. + - `tags`: Additional tags for common runner resources, currently the managed runner IAM role. These override module-level `tags` with the same key. + - `hooks.job_started`: Script content installed as the runner job-started hook. + - `hooks.job_completed`: Script content installed as the runner job-completed hook. + - `iam.role.arn`: ARN of an externally managed runner role. When set, this module does not create or modify that role. + - `iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. + - `iam.path`: IAM path for the module-managed runner role. Defaults to a path derived from `prefix`. + - `iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role. + EOT type = object({ os = optional(string, "linux") architecture = optional(string, "x64") @@ -37,6 +60,7 @@ variable "runner" { ephemeral = optional(bool, false) jit_config_enabled = optional(bool, null) auto_update_disabled = optional(bool, false) + tags = optional(map(string), {}) hooks = optional(object({ job_started = optional(string, "") job_completed = optional(string, "") @@ -73,7 +97,20 @@ variable "runner" { } variable "github" { - description = "GitHub API and registration configuration." + description = <<-EOT + GitHub API and runner-registration configuration. + + - `app_parameters.key_base64`: Parameter Store reference for the GitHub App private key. + - `app_parameters.key_base64.name`: Name of the private-key parameter supplied to the control-plane functions. + - `app_parameters.key_base64.arn`: ARN of the private-key parameter used by IAM policies. + - `app_parameters.id`: Parameter Store reference for the GitHub App ID. + - `app_parameters.id.name`: Name of the App-ID parameter supplied to the control-plane functions. + - `app_parameters.id.arn`: ARN of the App-ID parameter used by IAM policies. + - `organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used. + - `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com. + - `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests. + - `user_agent`: Optional User-Agent value added to GitHub API requests. + EOT type = object({ app_parameters = object({ key_base64 = map(string) @@ -89,7 +126,15 @@ variable "github" { } variable "queue" { - description = "Build queue and Lambda event-source configuration." + description = <<-EOT + Build queue reference and queue-integrated Lambda configuration. + + - `build.arn`: ARN of the externally managed build queue consumed by scale-up. + - `build.url`: URL of the externally managed build queue used when messages are published. + - `event_source_mapping.batch_size`: Maximum records delivered to a Lambda invocation. + - `event_source_mapping.maximum_batching_window_in_seconds`: Maximum time Lambda may buffer records before invocation. + - `tags`: Shared tags for queue-related resources created by this stack, including event-source mappings and the optional job-retry queue. These override module-level `tags`; component `tags` override this map when keys conflict. The referenced build queue is not managed or tagged by this module. + EOT type = object({ build = object({ arn = string @@ -99,6 +144,7 @@ variable "queue" { batch_size = optional(number, 10) maximum_batching_window_in_seconds = optional(number, 0) }), {}) + tags = optional(map(string), {}) }) validation { @@ -113,7 +159,21 @@ variable "queue" { } variable "lambda" { - description = "Configuration shared by the control-plane Lambda functions." + description = <<-EOT + Configuration shared by the control-plane Lambda functions. + + - `zip`: Local control-plane archive. When null, the module's packaged runner archive is used. + - `s3.bucket`: Optional S3 bucket containing the Lambda archive. Setting this selects S3 instead of a local archive. + - `s3.key`: Object key of the Lambda archive in `s3.bucket`. + - `s3.object_version`: Optional version of the Lambda archive object. + - `runtime`: Runtime used by all control-plane Lambda functions. + - `architecture`: Instruction-set architecture used by all control-plane Lambda functions. Supported values are `arm64` and `x86_64`. + - `subnet_ids`: Subnets used for Lambda VPC configuration. + - `security_group_ids`: Security groups used for Lambda VPC configuration. + - `tags`: Shared tags applied to Lambda function resources only. These override module-level `tags`; component `tags` override this map when keys conflict. + - `role.path`: IAM path for module-managed Lambda execution roles. Defaults to a path derived from `prefix`. + - `role.permissions_boundary`: Permissions-boundary ARN applied to module-managed Lambda execution roles. + EOT type = object({ zip = optional(string, null) s3 = optional(object({ @@ -140,23 +200,46 @@ variable "lambda" { } variable "scale_up" { - description = "Scale-up Lambda configuration." + description = <<-EOT + Scale-up component configuration. + + - `memory_size`: Memory allocated to the scale-up Lambda in MB. + - `timeout`: Scale-up Lambda timeout in seconds. + - `reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency. + - `job_queued_check_enabled`: Enables the queued-job verification before scaling. When null, the default is enabled for persistent runners and disabled for ephemeral runners. + - `tags`: Tags for scale-up resources, including the Lambda function, log group, event-source mapping, and IAM role. These override module-level tags and the shared `lambda.tags`, `queue.tags`, and `observability.logs.tags` maps when keys conflict. + EOT type = object({ memory_size = optional(number, 512) timeout = optional(number, 60) reserved_concurrent_executions = optional(number, 1) job_queued_check_enabled = optional(bool, null) + tags = optional(map(string), {}) }) default = {} } variable "scale_down" { - description = "Scale-down Lambda and idle-runner configuration." + description = <<-EOT + Scale-down Lambda, schedule, and idle-runner configuration. + + - `memory_size`: Memory allocated to the scale-down Lambda in MB. + - `timeout`: Scale-down Lambda timeout in seconds. + - `schedule_expression`: EventBridge schedule expression that invokes scale-down. + - `minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null selects the operating-system default. + - `tags`: Tags for scale-down resources, including the Lambda function, log group, EventBridge rule, and IAM role. These override module-level tags and the shared `lambda.tags` and `observability.logs.tags` maps when keys conflict. + - `idle_config`: Time-based desired idle-runner configurations. + - `idle_config[].cron`: Cron expression identifying when the configuration applies. + - `idle_config[].timeZone`: IANA time zone used to evaluate `cron`. + - `idle_config[].idleCount`: Number of idle runners to retain during the matching period. + - `idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. + EOT type = object({ memory_size = optional(number, 512) timeout = optional(number, 60) schedule_expression = optional(string, "cron(*/5 * * * ? *)") minimum_running_time_in_minutes = optional(number, null) + tags = optional(map(string), {}) idle_config = optional(list(object({ cron = string timeZone = string @@ -168,7 +251,20 @@ variable "scale_down" { } variable "pool" { - description = "Scheduled runner-pool configuration." + description = <<-EOT + Scheduled runner-pool configuration. The pool component is created only when `config` is non-empty. + + - `config`: Scheduled target pool sizes. + - `config[].schedule_expression`: Scheduler expression that activates the target size. + - `config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. + - `config[].size`: Desired number of runners for the schedule. + - `include_busy_runners`: Includes busy runners when calculating the current pool size. + - `runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners. + - `tags`: Tags for pool resources, including the Lambda function, log group, IAM roles, and scheduler group. These override module-level tags and the shared `lambda.tags` and `observability.logs.tags` maps when keys conflict. + - `lambda.memory_size`: Memory allocated to the pool Lambda in MB. + - `lambda.timeout`: Pool Lambda timeout in seconds. + - `lambda.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency. + EOT type = object({ config = optional(list(object({ schedule_expression = string @@ -177,6 +273,7 @@ variable "pool" { })), []) include_busy_runners = optional(bool, false) runner_owner = optional(string, null) + tags = optional(map(string), {}) lambda = optional(object({ memory_size = optional(number, 512) timeout = optional(number, 60) @@ -187,12 +284,24 @@ variable "pool" { } variable "job_retry" { - description = "Job-retry queue and Lambda configuration." + description = <<-EOT + Job-retry queue and Lambda configuration. + + - `enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. + - `delay_in_seconds`: Initial delay before a queued-job retry check. AWS SQS limits this value to 900 seconds. + - `delay_backoff`: Multiplier applied to the delay after each unsuccessful check. + - `max_attempts`: Maximum retry-check attempts before the message is no longer republished. + - `tags`: Tags for job-retry resources, including the Lambda function, log group, IAM role, retry queue, and event-source mapping. These override module-level tags and the shared `lambda.tags`, `queue.tags`, and `observability.logs.tags` maps when keys conflict. + - `lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. + - `lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency. + - `lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. + EOT type = object({ enabled = optional(bool, false) delay_in_seconds = optional(number, 300) delay_backoff = optional(number, 2) max_attempts = optional(number, 1) + tags = optional(map(string), {}) lambda = optional(object({ memory_size = optional(number, 256) reserved_concurrent_executions = optional(number, 1) @@ -208,18 +317,42 @@ variable "job_retry" { } variable "ssm" { - description = "Parameter Store paths, encryption, tags, and housekeeper configuration." + description = <<-EOT + Parameter Store paths, encryption, tag scopes, and housekeeper configuration. + + - `paths.root`: Root Parameter Store path for this runner stack. + - `paths.tokens`: Path segment under `paths.root` used for registration tokens and just-in-time configuration. + - `paths.config`: Path segment under `paths.root` used for persistent runner configuration. + - `kms_key`: Optional customer-managed KMS key used to encrypt temporary registration parameters. The wrapper's presence is the plan-time policy discriminator. + - `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply. + - `tags`: Shared tags for SSM-related resources. These override module-level `tags` and are inherited by parameter and housekeeper resources. + - `parameters.tags`: Tags for Terraform-managed runner configuration parameters and temporary parameters created by the scale-up and pool Lambdas. These override module-level and `ssm.tags` values with the same key. + - `housekeeper.schedule_expression`: EventBridge schedule expression that invokes the SSM housekeeper. + - `housekeeper.state`: EventBridge rule state, such as `ENABLED` or `DISABLED`. + - `housekeeper.tags`: Tags for housekeeper resources, including the Lambda function, log group, EventBridge rule, and IAM role. These override module-level, `ssm.tags`, shared Lambda, and shared log tags when keys conflict. + - `housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB. + - `housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds. + - `housekeeper.config.tokenPath`: Parameter Store token path cleaned by the housekeeper. When omitted, the configured runner token path is used. + - `housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. + - `housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. + EOT type = object({ paths = object({ root = string tokens = string config = string }) - kms_key_arn = optional(string, null) - parameter_tags = optional(map(string), {}) + kms_key = optional(object({ + arn = string + }), null) + tags = optional(map(string), {}) + parameters = optional(object({ + tags = optional(map(string), {}) + }), {}) housekeeper = optional(object({ schedule_expression = optional(string, "rate(1 day)") state = optional(string, "ENABLED") + tags = optional(map(string), {}) lambda = optional(object({ memory_size = optional(number, 512) timeout = optional(number, 60) @@ -234,13 +367,30 @@ variable "ssm" { } variable "observability" { - description = "Logging, tracing, and metrics configuration." + description = <<-EOT + Logging, tracing, and metrics configuration for control-plane and provider resources. + + - `logs.level`: Application log level supplied to the control-plane functions. + - `logs.retention_in_days`: CloudWatch Logs retention period. + - `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt CloudWatch log groups. + - `logs.class`: CloudWatch log-group class. Supported values are `STANDARD` and `INFREQUENT_ACCESS`. + - `logs.tags`: Shared tags for CloudWatch log groups. These override module-level `tags`; component `tags` override this map when keys conflict. + - `tracing.mode`: Optional Lambda active-tracing mode. Null disables X-Ray tracing configuration. + - `tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper. + - `tracing.capture_error`: Enables error capture in the tracing helper. + - `metrics.enable`: Enables module-emitted metrics. + - `metrics.namespace`: CloudWatch namespace used for emitted metrics. + - `metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics. + - `metrics.metric.enable_job_retry`: Emits job-retry metrics. + - `metrics.metric.enable_spot_termination_warning`: Emits spot-termination warning metrics where supported. + EOT type = object({ - log_level = optional(string, "info") logs = optional(object({ + level = optional(string, "info") retention_in_days = optional(number, 180) kms_key_id = optional(string, null) class = optional(string, "STANDARD") + tags = optional(map(string), {}) }), {}) tracing = optional(object({ mode = optional(string, null) @@ -273,7 +423,7 @@ variable "observability" { "warn", "error", "fatal", - ], var.observability.log_level) - error_message = "observability.log_level must be one of silly, trace, debug, info, warn, error, or fatal." + ], var.observability.logs.level) + error_message = "observability.logs.level must be one of silly, trace, debug, info, warn, error, or fatal." } } From bd3e88b053b9d1199127f46c9627b6d15d0dfe5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:23:26 +0000 Subject: [PATCH 19/27] docs: auto update terraform docs --- modules/compute-providers/ec2/README.md | 10 +++++----- modules/lambda/README.md | 10 +++++----- modules/multi-runner/README.md | 12 ++++++------ modules/runner-stack/README.md | 12 ++++++------ modules/runner-stack/job-retry/README.md | 12 ++++++------ modules/runner-stack/pool/README.md | 10 +++++----- 6 files changed, 33 insertions(+), 33 deletions(-) diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index f632554bda..061bba9da1 100644 --- a/modules/compute-providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -10,14 +10,14 @@ EC2 is the only active compute provider. A future provider must implement the sa ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -27,7 +27,7 @@ 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 | @@ -47,7 +47,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [ami](#input\_ami) | AMI selection and encryption configuration for runner instances. Null selects the default AMI configuration for `runner_os`.

- `filter`: AMI filter names mapped to accepted values. These values are merged over the default filter for `runner_os`.
- `owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter from the selected AMI. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource.
- `id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `kms_key`: Optional customer-managed KMS key required to launch an encrypted AMI or snapshot. The wrapper's presence is the plan-time policy discriminator.
- `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply. |
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` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | @@ -112,7 +112,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [control\_plane](#output\_control\_plane) | Provider-neutral control-plane contract containing the EC2 environment and IAM fragments. | | [resources](#output\_resources) | EC2-specific resources and bootstrap logging details. | diff --git a/modules/lambda/README.md b/modules/lambda/README.md index dfdd46c3e4..279ff52376 100644 --- a/modules/lambda/README.md +++ b/modules/lambda/README.md @@ -8,14 +8,14 @@ Generic module to create lambda functions ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | ## Modules @@ -25,7 +25,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_log_group.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.lambda_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -38,12 +38,12 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [lambda](#input\_lambda) | Configuration for the lambda function.

- `aws_partition`: Partition for the base arn if not 'aws'
- `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
- `environment_variables`: Additional environment variables for the Lambda function.
- `handler`: The entrypoint for the lambda.
- `principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
- `principals[*].type`: IAM principal type, such as `Service` or `AWS`.
- `principals[*].identifiers`: IAM principal identifiers for the selected principal type.
- `lambda_tags`: Tags added specifically to the Lambda function. These override `tags` values with the same key.
- `log_group_tags`: Tags added specifically to the Lambda log group. These override `tags` values with the same key.
- `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
- `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
- `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.
- `log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`.
- `memory_size`: Memory size limit in MB of the lambda.
- `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
- `metrics_namespace`: Namespace for the metrics emitted by the lambda.
- `name`: The name of the lambda function.
- `prefix`: The prefix used for naming resources.
- `role_path`: The path that will be added to the role, if not set the environment name will be used.
- `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
- `runtime`: AWS Lambda runtime.
- `s3_bucket`: S3 bucket containing the Lambda deployment package. This is an alternative to `zip`.
- `s3_key`: Object key of the Lambda deployment package. Required when `s3_bucket` is set.
- `s3_object_version`: Optional version of the Lambda deployment-package object.
- `security_group_ids`: List of security group IDs associated with the Lambda function.
- `subnet_ids`: Subnets used for the Lambda VPC configuration.
- `tags`: Base tags added to the Lambda function, log group, and execution role. `lambda_tags` and `log_group_tags` override matching keys for their respective resources.
- `timeout`: Time out of the lambda in seconds.
- `tracing_config`: Configuration for lambda tracing.
- `tracing_config.mode`: AWS X-Ray tracing mode. A null value disables tracing.
- `tracing_config.capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests.
- `tracing_config.capture_error`: Whether Powertools tracing captures errors as tracing metadata.
- `zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, "aws")
architecture = optional(string, "arm64")
environment_variables = optional(map(string), {})
handler = string
lambda_tags = optional(map(string), {})
log_group_tags = optional(map(string), {})
log_level = optional(string, "info")
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, 180)
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, null)
metrics_namespace = optional(string, "GitHub Runners")
name = string
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, "nodejs24.x")
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
tags = optional(map(string), {})
timeout = optional(number, 60)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [lambda](#output\_lambda) | n/a | diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 84e747483f..8d116f410f 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -121,7 +121,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -129,14 +129,14 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | | [random](#provider\_random) | ~> 3.0 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -148,7 +148,7 @@ module "multi-runner" { ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -159,7 +159,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | | [ami\_housekeeper\_lambda\_s3\_key](#input\_ami\_housekeeper\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | @@ -243,7 +243,7 @@ module "multi-runner" { ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index 230325a519..85fed625af 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -61,20 +61,20 @@ yarn run dist ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [ec2\_runner\_role](#module\_ec2\_runner\_role) | ../compute-providers/ec2/runner-role | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | @@ -83,7 +83,7 @@ yarn run dist ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | @@ -139,7 +139,7 @@ yarn run dist ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | | [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block.

- `type`: Compute-provider discriminator. The currently supported value is `ec2`.
- `ec2`: EC2 compute-provider configuration. This object is required when `type` is `ec2`.
- `ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults.
- `ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter.
- `ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource.
- `ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator.
- `ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `ec2.vpc_id`: VPC in which runner networking resources are created.
- `ec2.subnet_ids`: Subnets from which scale-up may launch runner instances.
- `ec2.overrides`: Optional resource-name overrides.
- `ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name.
- `ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name.
- `ec2.instance_profile`: Optional externally managed instance profile used by the launch template.
- `ec2.instance_profile.name`: Name of the externally managed instance profile.
- `ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the stack prefix.
- `ec2.binaries_syncer`: Runner-distribution synchronization configuration.
- `ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3.
- `ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies.
- `ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI.
- `ec2.binaries_syncer.s3.key`: Object key of the runner distribution.
- `ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it.
- `ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `ec2.block_device_mappings[].volume_type`: EBS volume type.
- `ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity.
- `ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `ec2.user_data`: Runner bootstrap user-data configuration.
- `ec2.user_data.enabled`: Enables launch-template user data.
- `ec2.user_data.template`: Optional path to a custom user-data template.
- `ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template.
- `ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances.
- `ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`.
- `ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group.
- `ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults.
- `ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing.
- `ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `ec2.key_name`: Optional EC2 key-pair name added to the launch template.
- `ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group.
- `ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `ec2.egress_rules`: Egress rules created on the managed runner security group.
- `ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `ec2.egress_rules[].from_port`: First destination port in the permitted range.
- `ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `ec2.egress_rules[].security_groups`: Destination security-group IDs.
- `ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `ec2.egress_rules[].to_port`: Last destination port in the permitted range.
- `ec2.egress_rules[].description`: Optional rule description.
- `ec2.tags`: Additional tags for runner instances, EBS volumes, network interfaces, and eligible Spot instance requests created from the launch template. They override module-level tags and the generated runner `Name`; the provider-managed `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` bootstrap tags take final precedence. These tags do not apply to static provider resources such as the launch template, security group, IAM resources, SSM parameters, or log groups.
- `ec2.metadata_options`: Instance Metadata Service configuration in the launch template.
- `ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`.
- `ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`.
- `ec2.cpu_options`: CPU topology and processor-feature configuration.
- `ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `ec2.placement`: EC2 placement configuration for runner instances.
- `ec2.placement.affinity`: Host affinity setting.
- `ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `ec2.placement.group_id`: Placement-group ID.
- `ec2.placement.group_name`: Placement-group name.
- `ec2.placement.host_id`: Dedicated Host ID.
- `ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `ec2.placement.spread_domain`: Spread-domain placement value.
- `ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `ec2.placement.partition_number`: Placement-group partition number.
- `ec2.license_specifications`: License Manager configurations added to the launch template.
- `ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. |
object({
type = string

ec2 = optional(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)
enable_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)
}), null)
})
| n/a | yes | @@ -159,7 +159,7 @@ yarn run dist ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [lambda\_pool](#output\_lambda\_pool) | n/a | | [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a | | [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a | diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md index 584a5d6da5..8b543b13e1 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/runner-stack/job-retry/README.md @@ -11,26 +11,26 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [job\_retry](#module\_job\_retry) | ../../lambda | n/a | ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_iam_role_policy.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_lambda_event_source_mapping.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | | [aws_lambda_permission.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | @@ -42,13 +42,13 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [config](#input\_config) | Configuration for the job-retry Lambda and retry queue.

- `aws_partition`: Partition for the base ARN if not `aws`.
- `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
- `environment_variables`: Additional environment variables for the job-retry Lambda. Required job-retry variables override matching keys.
- `enable_organization_runners`: Enable organization runners.
- `ghes_url`: Optional GitHub Enterprise Server URL.
- `user_agent`: Optional User-Agent header for GitHub API requests.
- `github_app_parameters`: SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Metadata for the SSM parameter containing the base64-encoded GitHub App private key.
- `github_app_parameters.key_base64.name`: Name of the private-key parameter supplied to the job-retry Lambda.
- `github_app_parameters.key_base64.arn`: ARN of the private-key parameter used by the job-retry IAM policy.
- `github_app_parameters.id`: Metadata for the SSM parameter containing the GitHub App ID.
- `github_app_parameters.id.name`: Name of the App-ID parameter supplied to the job-retry Lambda.
- `github_app_parameters.id.arn`: ARN of the App-ID parameter used by the job-retry IAM policy.
- `kms_key`: Optional customer-managed KMS key used by the job-retry IAM policy. Object presence controls whether the KMS statement exists.
- `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply.
- `lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
- `lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
- `lambda_tags`: Tags added specifically to the job-retry Lambda function. These override `tags` values with the same key.
- `log_group_tags`: Tags added specifically to the job-retry Lambda log group. These override `tags` values with the same key.
- `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
- `log_class`: CloudWatch log-group class for the job-retry Lambda. Supported values are `STANDARD` and `INFREQUENT_ACCESS`.
- `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
- `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.
- `memory_size`: Memory size limit in MB of the lambda.
- `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
- `metrics`: Configuration to enable metrics creation by the lambda.
- `metrics.enable`: Whether the job-retry Lambda emits metrics.
- `metrics.namespace`: CloudWatch namespace for metrics emitted by the job-retry Lambda.
- `metrics.metric`: Controls individual metrics emitted by the job-retry Lambda.
- `metrics.metric.enable_github_app_rate_limit`: Whether to emit GitHub App rate-limit metrics.
- `metrics.metric.enable_job_retry`: Whether to emit job-retry metrics.
- `prefix`: The prefix used for naming resources.
- `principals`: Extra principals allowed to assume the job-retry Lambda execution role, for example during local testing.
- `principals[*].type`: IAM principal type, such as `Service` or `AWS`.
- `principals[*].identifiers`: IAM principal identifiers for the selected principal type.
- `queue_encryption`: Encryption configuration for the retry queue.
- `queue_encryption.kms_data_key_reuse_period_seconds`: Length of time, in seconds, for which SQS reuses a data key.
- `queue_encryption.kms_master_key_id`: KMS key ID used to encrypt the retry queue.
- `queue_encryption.sqs_managed_sse_enabled`: Whether SQS-managed server-side encryption is enabled.
- `role_path`: The path that will be added to the role, if not set the environment name will be used.
- `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
- `runner_name_prefix`: Prefix used to identify runners belonging to this runner configuration.
- `runtime`: AWS Lambda runtime.
- `s3_bucket`: S3 bucket containing the job-retry Lambda deployment package. This is an alternative to `zip`.
- `s3_key`: Object key of the job-retry Lambda deployment package. Required when `s3_bucket` is set.
- `s3_object_version`: Optional version of the job-retry Lambda deployment-package object.
- `security_group_ids`: List of security group IDs associated with the Lambda function.
- `sqs_build_queue`: SQS queue to which the job-retry Lambda republishes job requests.
- `sqs_build_queue.url`: URL of the build queue.
- `sqs_build_queue.arn`: ARN of the build queue.
- `queue_tags`: Map of tags that will be added to the retry queue and event-source mapping.
- `subnet_ids`: Subnets used for the job-retry Lambda VPC configuration.
- `tags`: Base component tags added to the Lambda function, log group, and execution role. Specialized Lambda and log-group tags override matching keys.
- `timeout`: Time out of the lambda in seconds.
- `tracing_config`: Configuration for lambda tracing.
- `tracing_config.mode`: AWS X-Ray tracing mode. A null value disables tracing.
- `tracing_config.capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests.
- `tracing_config.capture_error`: Whether Powertools tracing captures errors as tracing metadata.
- `zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key = optional(object({
arn = string
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_group_tags = optional(map(string), {})
log_level = optional(string, null)
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
reserved_concurrent_executions = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
queue_tags = optional(map(string), {})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | n/a | | [lambda](#output\_lambda) | n/a | diff --git a/modules/runner-stack/pool/README.md b/modules/runner-stack/pool/README.md index 9d1136d6d0..f45d83ad00 100644 --- a/modules/runner-stack/pool/README.md +++ b/modules/runner-stack/pool/README.md @@ -9,14 +9,14 @@ The pool is an opt-in feature. To be able to use the count on a module level to ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | ## Modules @@ -26,7 +26,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_log_group.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | @@ -50,7 +50,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no | | [config](#input\_config) | Configuration passed from the runner stack to the pool Lambda and scheduler.

- `lambda`: Pool Lambda runtime and deployment configuration.
- `lambda.log_level`: Logging level used by the pool Lambda.
- `lambda.logging_retention_in_days`: Number of days to retain events in the pool Lambda log group.
- `lambda.logging_kms_key_id`: KMS key ID used to encrypt the pool Lambda log group.
- `lambda.log_class`: CloudWatch Logs class for the pool Lambda log group.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use -1 for no reservation.
- `lambda.s3_bucket`: S3 bucket containing the pool Lambda deployment package.
- `lambda.s3_key`: S3 key of the pool Lambda deployment package.
- `lambda.s3_object_version`: S3 object version of the pool Lambda deployment package.
- `lambda.security_group_ids`: Security group IDs associated with the pool Lambda.
- `lambda.runtime`: AWS Lambda runtime used by the pool Lambda.
- `lambda.architecture`: AWS Lambda architecture used by the pool Lambda.
- `lambda.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.timeout`: Pool Lambda timeout in seconds.
- `lambda.zip`: Local path to the pool Lambda deployment package when S3 is not used.
- `lambda.subnet_ids`: Subnet IDs in which the pool Lambda runs.
- `lambda.parameter_store_tags`: JSON-encoded tags supplied to the pool Lambda for SSM parameters it creates.
- `tags`: Common tags added to pool resources.
- `ghes`: GitHub Enterprise Server connection configuration.
- `ghes.url`: GitHub Enterprise Server URL; null when using public GitHub.
- `ghes.ssl_verify`: Whether the pool Lambda verifies the GitHub Enterprise Server TLS certificate.
- `github_app_parameters`: SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Metadata for the SSM parameter containing the base64-encoded GitHub App private key.
- `github_app_parameters.key_base64.name`: Name of the private-key parameter supplied to the pool Lambda.
- `github_app_parameters.key_base64.arn`: ARN of the private-key parameter used by the pool IAM policy.
- `github_app_parameters.id`: Metadata for the SSM parameter containing the GitHub App ID.
- `github_app_parameters.id.name`: Name of the App-ID parameter supplied to the pool Lambda.
- `github_app_parameters.id.arn`: ARN of the App-ID parameter used by the pool IAM policy.
- `runner`: Runner registration configuration used by the pool Lambda.
- `runner.disable_runner_autoupdate`: Whether GitHub runner automatic updates are disabled.
- `runner.ephemeral`: Whether runners register as ephemeral runners.
- `runner.enable_jit_config`: Whether runners use just-in-time registration configuration.
- `runner.labels`: Labels assigned to runners created by the pool Lambda.
- `runner.group_name`: GitHub runner group assigned to runners created by the pool Lambda.
- `runner.name_prefix`: Prefix used for runner names.
- `runner.pool_owner`: GitHub organization or repository that owns the runner pool.
- `runners_maximum_count`: Maximum number of runners that the pool Lambda may create.
- `prefix`: Prefix used to name pool resources.
- `pool`: Scheduled pool targets.
- `pool[*].schedule_expression`: EventBridge Scheduler expression for a pool target.
- `pool[*].schedule_expression_timezone`: Time zone used to evaluate the schedule expression.
- `pool[*].size`: Desired runner count for the scheduled pool target.
- `include_busy_runners`: Whether busy runners count toward the desired pool size.
- `role_permissions_boundary`: Permissions boundary applied to IAM roles created for the pool.
- `kms_key`: Optional customer-managed KMS key that the pool Lambda may use to decrypt encrypted parameters. Object presence controls whether the KMS statement exists.
- `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_config_path`: SSM path under which runner configuration is stored.
- `arn_ssm_parameters_path_config`: ARN matching the runner configuration SSM path.
- `lambda_tags`: Tags added specifically to the pool Lambda function, overriding common tags with the same key.
- `log_group_tags`: Tags added specifically to the pool Lambda log group, overriding common tags with the same key.
- `user_agent`: User-Agent header used for GitHub API requests. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key = optional(object({
arn = string
}), null)
role_path = string
ssm_token_path = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
log_group_tags = optional(map(string), {})
user_agent = string
})
| n/a | yes | | [runner\_provider](#input\_runner\_provider) | Compute provider integration used by the pool Lambda.

- `type`: Compute provider type passed to scheduled pool invocations.
- `environment_variables`: Provider-specific environment variables added to the pool Lambda.
- `iam_policy_json`: Provider-specific IAM policy document merged into the pool Lambda policy.
- `managed_policy_enabled`: Whether to attach a provider-specific managed IAM policy to the pool Lambda role.
- `managed_policy_arn`: ARN of the provider-specific managed IAM policy to attach when enabled. |
object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
| n/a | yes | @@ -59,7 +59,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [lambda](#output\_lambda) | n/a | | [lambda\_log\_group](#output\_lambda\_log\_group) | n/a | | [role\_pool](#output\_role\_pool) | n/a | From cd03cdf2ea2f1213343b1765c4459e669ba57b10 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 16:47:06 +0200 Subject: [PATCH 20/27] refactor(ec2): consume nested provider configuration --- docs/compute-provider-refactor.md | 2 + modules/compute-providers/ec2/README.md | 78 +- .../compute-providers/ec2/control-plane.tf | 30 +- .../compute-providers/ec2/instance-profile.tf | 4 +- modules/compute-providers/ec2/logging.tf | 26 +- .../compute-providers/ec2/runner-config.tf | 12 +- .../compute-providers/ec2/runner-instances.tf | 125 +-- .../ec2/runner-role/README.md | 17 +- .../ec2/runner-role/policies.tf | 23 +- .../ec2/runner-role/tests/contract.tftest.hcl | 55 +- .../ec2/runner-role/variables.tf | 63 +- .../ec2/tests/provider.tftest.hcl | 190 ++-- modules/compute-providers/ec2/variables.tf | 863 +++++++----------- modules/runner-stack/ec2.tf | 76 +- modules/runner-stack/runner-role.tf | 11 +- 15 files changed, 692 insertions(+), 883 deletions(-) diff --git a/docs/compute-provider-refactor.md b/docs/compute-provider-refactor.md index 4e46c69984..69b117de05 100644 --- a/docs/compute-provider-refactor.md +++ b/docs/compute-provider-refactor.md @@ -22,6 +22,8 @@ The implementation is split into three layers: The EC2 provider currently owns the instance profile, launch template, security group, AMI and bootstrap parameters, runner log groups, EC2 policy statements, and EC2 Lambda environment variables. EC2 is the only implemented Terraform compute provider today. +`runner-stack` passes the canonical `compute_provider.ec2` configuration to the EC2 module as one nested `config` object. It also passes the provider-neutral `runner`, `github`, `ssm`, and `observability` objects without expanding them back into prefixed scalar inputs. The EC2 runner-role policy module consumes the same provider `config` and shared `ssm` boundaries. This keeps ownership visible at every module boundary and gives future compute providers an equivalent contract to implement. + The common stack creates or selects the runner IAM role. A provider supplies the trust policy, inline policy documents, and optional managed-policy requirements; the common stack attaches them. This keeps role ownership provider-neutral while allowing each compute provider to define its permissions. ## Phase 1 dispatch and compatibility diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index 061bba9da1..86e145cb43 100644 --- a/modules/compute-providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -10,14 +10,14 @@ EC2 is the only active compute provider. A future provider must implement the sa ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -27,7 +27,7 @@ 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 | @@ -47,72 +47,20 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [ami](#input\_ami) | AMI selection and encryption configuration for runner instances. Null selects the default AMI configuration for `runner_os`.

- `filter`: AMI filter names mapped to accepted values. These values are merged over the default filter for `runner_os`.
- `owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter from the selected AMI. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource.
- `id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `kms_key`: Optional customer-managed KMS key required to launch an encrypted AMI or snapshot. The wrapper's presence is the plan-time policy discriminator.
- `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply. |
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` | no | -| [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | -| [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | -| [block\_device\_mappings](#input\_block\_device\_mappings) | EBS block-device mappings added to the runner launch template.

- `delete_on_termination`: Deletes the EBS volume when its runner instance terminates.
- `device_name`: Device name exposed to the runner instance.
- `encrypted`: Enables encryption for the EBS volume.
- `iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `kms_key_id`: KMS key ID or ARN used to encrypt the EBS volume.
- `snapshot_id`: Snapshot used to initialize the EBS volume.
- `throughput`: Provisioned throughput in MiB/s for volume types that support configurable throughput.
- `volume_initialization_rate`: Fixed volume initialization rate in MiB/s for supported snapshot-backed volumes.
- `volume_size`: EBS volume size in GiB.
- `volume_type`: EBS volume type. |
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
}
]
| no | -| [cloudwatch\_config](#input\_cloudwatch\_config) | (optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details. | `string` | `null` | no | -| [cpu\_options](#input\_cpu\_options) | CPU topology and processor-feature configuration for runner instances. Not all instance types support these options.

- `core_count`: Number of CPU cores exposed to the runner instance.
- `threads_per_core`: Number of hardware threads exposed per CPU core.
- `amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `nested_virtualization`: Enables or disables nested virtualization on supported instance types. |
object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
})
| `null` | no | -| [create\_service\_linked\_role\_spot](#input\_create\_service\_linked\_role\_spot) | (optional) create the service linked role for spot instances that is required by the scale-up lambda. | `bool` | `false` | no | -| [credit\_specification](#input\_credit\_specification) | The credit option for CPU usage of a T instance. Can be unset, "standard" or "unlimited". | `string` | `null` | no | -| [ebs\_optimized](#input\_ebs\_optimized) | The EC2 EBS optimized configuration. | `bool` | `false` | no | -| [egress\_rules](#input\_egress\_rules) | Egress rules created on the provider-managed runner security group.

- `cidr_blocks`: IPv4 CIDR destinations allowed by the rule.
- `ipv6_cidr_blocks`: IPv6 CIDR destinations allowed by the rule.
- `prefix_list_ids`: AWS prefix-list destinations allowed by the rule.
- `from_port`: First destination port in the permitted range.
- `protocol`: IP protocol name or number. Use `-1` for all protocols.
- `security_groups`: Destination security-group IDs allowed by the rule.
- `self`: Allows traffic to the managed runner security group itself when true.
- `to_port`: Last destination port in the permitted range.
- `description`: Optional description assigned to the security-group rule. |
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"
],
"description": null,
"from_port": 0,
"ipv6_cidr_blocks": [
"::/0"
],
"prefix_list_ids": null,
"protocol": "-1",
"security_groups": null,
"self": null,
"to_port": 0
}
]
| no | -| [enable\_cloudwatch\_agent](#input\_enable\_cloudwatch\_agent) | Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`. | `bool` | `true` | no | -| [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | -| [enable\_on\_demand\_failover\_for\_errors](#input\_enable\_on\_demand\_failover\_for\_errors) | Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later. | `list(string)` | `[]` | no | -| [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Uses a synchronized GitHub runner distribution from `s3_runner_binaries` during bootstrap. Disable this when the runner distribution is already present in a prebuilt AMI. This module does not create the synchronization Lambda. | `bool` | `true` | no | -| [enable\_runner\_detailed\_monitoring](#input\_enable\_runner\_detailed\_monitoring) | Enable detailed monitoring for runners | `bool` | `false` | no | -| [enable\_user\_data\_debug\_logging](#input\_enable\_user\_data\_debug\_logging) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no | -| [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI | `bool` | `true` | no | -| [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | -| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | -| [iam\_overrides](#input\_iam\_overrides) | EC2 instance-profile ownership and selection.

- `override_instance_profile`: Uses an externally managed instance profile when true; otherwise this module creates an instance profile for `runner_role`.
- `instance_profile_name`: Name of the externally managed instance profile used by the launch template. Required when `override_instance_profile` is true. |
object({
override_instance_profile = optional(bool, false)
instance_profile_name = optional(string, null)
})
|
{
"instance_profile_name": null,
"override_instance_profile": false
}
| no | -| [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | -| [instance\_max\_spot\_price](#input\_instance\_max\_spot\_price) | Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet. | `string` | `null` | no | -| [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the prefix will be used. | `string` | `null` | no | -| [instance\_target\_capacity\_type](#input\_instance\_target\_capacity\_type) | Default lifecycle used runner instances, can be either `spot` or `on-demand`. | `string` | `"spot"` | no | -| [instance\_type\_priorities](#input\_instance\_type\_priorities) | A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`. | `map(number)` | `null` | no | -| [instance\_types](#input\_instance\_types) | List of EC2 instance types available when launching runner capacity. | `list(string)` | `null` | no | -| [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no | -| [license\_specifications](#input\_license\_specifications) | License Manager configurations added to the runner launch template. These may be required for macOS dedicated-host runners when the host resource group uses a Mac dedicated-host license configuration.

- `license_configuration_arn`: ARN of an AWS License Manager license configuration. |
list(object({
license_configuration_arn = string
}))
| `[]` | no | -| [log\_group\_tags](#input\_log\_group\_tags) | Map of tags that will be added to CloudWatch log groups created by the EC2 provider. These tags override provider tags with the same key. | `map(string)` | `{}` | no | -| [logging\_kms\_key\_id](#input\_logging\_kms\_key\_id) | Specifies the kms key id to encrypt the logs with | `string` | `null` | no | -| [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Number of days to retain events in the EC2 runner log groups. 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 | -| [metadata\_options](#input\_metadata\_options) | Instance Metadata Service configuration in the runner launch template. The default bootstrap flow reads runner configuration from instance tags, so disable metadata tags only when supplying a custom startup flow.

- `instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when set to `enabled`.
- `http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses. |
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)
})
| `{}` | no | -| [overrides](#input\_overrides) | Optional resource-name overrides.

- `name_runner`: Name tag assigned to runner compute resources. An empty value uses the generated provider name.
- `name_sg`: Name tag assigned to the managed runner security group. An empty value uses the generated provider name. |
object({
name_runner = optional(string, "")
name_sg = optional(string, "")
})
| `{}` | no | -| [placement](#input\_placement) | EC2 placement configuration for runner instances.

- `affinity`: Dedicated Host affinity setting.
- `availability_zone`: Availability Zone in which runner instances are placed.
- `group_id`: Placement-group ID.
- `group_name`: Placement-group name.
- `host_id`: Dedicated Host ID.
- `host_resource_group_arn`: ARN of the host resource group used for placement.
- `spread_domain`: Spread-domain placement value.
- `tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `partition_number`: Placement-group partition number. |
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 | -| [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no | -| [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the prefix will be used. | `string` | `null` | no | -| [runner\_additional\_security\_group\_ids](#input\_runner\_additional\_security\_group\_ids) | (optional) List of additional security groups IDs to apply to the runner | `list(string)` | `[]` | no | -| [runner\_architecture](#input\_runner\_architecture) | The platform architecture of the runner instance\_type. | `string` | `"x64"` | no | -| [runner\_as\_root](#input\_runner\_as\_root) | Run the action runner under the root user. Variable `runner_run_as` will be ignored. | `bool` | `false` | 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\_ec2\_tags](#input\_runner\_ec2\_tags) | Tags added to runner instance, volume, network-interface, and eligible Spot-request tag specifications. These override module tags and the generated runner `Name`; provider-required `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` tags take final precedence. | `map(string)` | `{}` | no | -| [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\_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\_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, osx, windows). | `string` | `"linux"` | no | -| [runner\_role](#input\_runner\_role) | Runner IAM role created or selected by the common runner stack.

- `arn`: Role ARN referenced by the EC2 control-plane policies.
- `name`: Role name associated with the provider-managed EC2 instance profile. |
object({
arn = string
name = string
})
| n/a | yes | -| [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | -| [s3\_runner\_binaries](#input\_s3\_runner\_binaries) | S3 location of the synchronized GitHub runner distribution.

- `arn`: Bucket ARN referenced by the runner IAM policy.
- `id`: Bucket name used to construct the runner-distribution S3 URI.
- `key`: Object key of the synchronized runner distribution. |
object({
arn = string
id = string
key = string
})
| n/a | yes | -| [scale\_errors](#input\_scale\_errors) | List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors | `list(string)` |
[
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]
| no | -| [ssm\_parameter\_tags](#input\_ssm\_parameter\_tags) | Map of tags that will be added to SSM parameters created by the EC2 provider. These tags override provider tags with the same key. | `map(string)` | `{}` | no | -| [ssm\_paths](#input\_ssm\_paths) | Parameter Store paths used by the EC2 provider and runner bootstrap flow.

- `root`: Root Parameter Store path for this runner stack.
- `tokens`: Path segment under `root` used for registration tokens and just-in-time configuration.
- `config`: Path segment under `root` used for persistent runner and provider configuration. |
object({
root = string
tokens = string
config = string
})
| n/a | yes | -| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes | -| [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name. | `map(string)` | `{}` | no | -| [use\_dedicated\_host](#input\_use\_dedicated\_host) | Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly. | `bool` | `false` | 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) | User-data script snippet to insert after GitHub action runner install | `string` | `""` | no | -| [userdata\_pre\_install](#input\_userdata\_pre\_install) | User-data script snippet to insert before GitHub action runner install | `string` | `""` | no | -| [userdata\_template](#input\_userdata\_template) | Alternative user-data template file path replacing the default template. The template receives the standard bootstrap values, including `pre_install` and `post_install`; a custom template decides how to use them and must install the required runner software. | `string` | `null` | no | -| [vpc\_id](#input\_vpc\_id) | The VPC for the security groups. | `string` | n/a | yes | +| ---- | ----------- | ---- | ------- | :------: | +| [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM ARNs. | `string` | `"aws"` | no | +| [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner stack.

- `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`: Provider runner-role setting consumed by the EC2 runner-role contract.
- `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 stack 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.
- `enable_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)
enable_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 used to render runner bootstrap data.

- `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server. |
object({
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
})
| `{}` | no | +| [observability](#input\_observability) | CloudWatch Logs settings used by EC2 runner log groups.

- `logs.retention_in_days`: Retention period for EC2 runner log groups.
- `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner log groups.
- `logs.tags`: Shared log-group tags that override module-level `tags`. |
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 name EC2 provider resources. | `string` | `"github-actions"` | no | +| [runner](#input\_runner) | Provider-neutral runner settings consumed by EC2.

- `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`.
- `architecture`: Runner distribution architecture.
- `boot_time_in_minutes`: Expected boot and registration duration used by scale-down and pool.
- `name_prefix`: Prefix added to registered runner names.
- `run_as_root`: Runs the runner service as root.
- `run_as`: Operating-system user used when `run_as_root` is false.
- `hooks.job_started`: Script installed as the runner job-started hook.
- `hooks.job_completed`: Script installed as the runner job-completed hook.
- `iam.role.arn`: Resolved runner-role ARN referenced by EC2 control-plane policies.
- `iam.role.name`: Resolved runner-role name used by the provider-managed instance profile.
- `iam.path`: IAM path used for provider-managed policies. Null derives the path from `prefix`. |
object({
os = optional(string, "linux")
architecture = optional(string, "x64")
boot_time_in_minutes = optional(number, 5)
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
})
path = optional(string, null)
})
})
| n/a | yes | +| [ssm](#input\_ssm) | Parameter Store paths and tag scopes used by EC2 runner bootstrap resources.

- `paths.root`: Root Parameter Store path for the runner stack.
- `paths.tokens`: Path segment used for registration tokens and just-in-time configuration.
- `paths.config`: Path segment used for persistent runner and provider configuration.
- `tags`: Shared SSM tags that override module-level `tags`.
- `parameters.tags`: Parameter-specific tags that override module-level and shared SSM tags. |
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 added to taggable EC2 provider resources. Nested SSM, log, and runner tags override this map within their documented scopes. | `map(string)` | `{}` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [control\_plane](#output\_control\_plane) | Provider-neutral control-plane contract containing the EC2 environment and IAM fragments. | | [resources](#output\_resources) | EC2-specific resources and bootstrap logging details. | diff --git a/modules/compute-providers/ec2/control-plane.tf b/modules/compute-providers/ec2/control-plane.tf index 8a965ea83b..fd9213d00e 100644 --- a/modules/compute-providers/ec2/control-plane.tf +++ b/modules/compute-providers/ec2/control-plane.tf @@ -60,7 +60,7 @@ data "aws_iam_policy_document" "scale_up" { statement { effect = "Allow" actions = ["iam:PassRole"] - resources = [var.runner_role.arn] + resources = [var.runner.iam.role.arn] } statement { @@ -144,7 +144,7 @@ data "aws_iam_policy_document" "pool" { statement { effect = "Allow" actions = ["iam:PassRole"] - resources = [var.runner_role.arn] + resources = [var.runner.iam.role.arn] } statement { @@ -181,7 +181,7 @@ data "aws_iam_policy_document" "pool" { } data "aws_iam_policy_document" "service_linked_role" { - count = var.create_service_linked_role_spot ? 1 : 0 + count = var.config.create_service_linked_role_spot ? 1 : 0 statement { effect = "Allow" @@ -193,28 +193,28 @@ data "aws_iam_policy_document" "service_linked_role" { locals { scale_up_environment_variables = { AMI_ID_SSM_PARAMETER_NAME = local.ami_id_ssm_parameter_name - INSTANCE_ALLOCATION_STRATEGY = var.instance_allocation_strategy - INSTANCE_MAX_SPOT_PRICE = var.instance_max_spot_price - INSTANCE_TARGET_CAPACITY_TYPE = var.instance_target_capacity_type - INSTANCE_TYPE_PRIORITIES = var.instance_type_priorities != null ? jsonencode(var.instance_type_priorities) : "" - INSTANCE_TYPES = join(",", var.instance_types) + 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.subnet_ids) - ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.enable_on_demand_failover_for_errors) - SCALE_ERRORS = jsonencode(var.scale_errors) - USE_DEDICATED_HOST = var.use_dedicated_host + SUBNET_IDS = join(",", var.config.subnet_ids) + ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.config.enable_on_demand_failover_for_errors) + SCALE_ERRORS = jsonencode(var.config.scale_errors) + USE_DEDICATED_HOST = var.config.use_dedicated_host } scale_down_environment_variables = { - RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes + RUNNER_BOOT_TIME_IN_MINUTES = var.runner.boot_time_in_minutes } pool_environment_variables = merge(local.scale_up_environment_variables, { - RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes + RUNNER_BOOT_TIME_IN_MINUTES = var.runner.boot_time_in_minutes }) 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.create_service_linked_role_spot ? data.aws_iam_policy_document.service_linked_role[0].json : null + 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/ec2/instance-profile.tf b/modules/compute-providers/ec2/instance-profile.tf index 2af14a3cde..68b8842d2d 100644 --- a/modules/compute-providers/ec2/instance-profile.tf +++ b/modules/compute-providers/ec2/instance-profile.tf @@ -1,9 +1,9 @@ # The common runner stack owns the role; EC2 owns the profile consumed by its # launch template. resource "aws_iam_instance_profile" "runner" { - count = var.iam_overrides.override_instance_profile ? 0 : 1 + count = var.config.instance_profile == null ? 1 : 0 name = "${var.prefix}-runner-profile" - role = var.runner_role.name + role = var.runner.iam.role.name path = local.instance_profile_path tags = local.provider_tags } diff --git a/modules/compute-providers/ec2/logging.tf b/modules/compute-providers/ec2/logging.tf index 5c67ef053d..00ae952e4d 100644 --- a/modules/compute-providers/ec2/logging.tf +++ b/modules/compute-providers/ec2/logging.tf @@ -1,8 +1,8 @@ # EC2 runner log collection and CloudWatch resources. locals { runner_log_files = ( - var.runner_log_files != null - ? var.runner_log_files + var.config.log_files != null + ? var.config.log_files : [ { "prefix_log_group" : true, @@ -14,28 +14,28 @@ locals { { "log_group_name" : "user_data", "prefix_log_group" : true, - "file_path" : var.runner_os == "windows" ? "C:/UserData.log" : "/var/log/user-data.log", + "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", + "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", + "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.enable_cloudwatch_agent ? [for l in local.runner_log_files : { + 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 @@ -56,20 +56,20 @@ locals { resource "aws_ssm_parameter" "cloudwatch_agent_config_runner" { - count = var.enable_cloudwatch_agent ? 1 : 0 - name = "${var.ssm_paths.root}/${var.ssm_paths.config}/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.cloudwatch_config != null ? var.cloudwatch_config : templatefile("${path.module}/templates/cloudwatch_config.json", { + value = var.config.cloudwatch_agent.config != null ? var.config.cloudwatch_agent.config : templatefile("${path.module}/templates/cloudwatch_config.json", { logfiles = jsonencode(local.logfiles) }) - tags = merge(local.provider_tags, var.ssm_parameter_tags) + 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.logging_retention_in_days - kms_key_id = var.logging_kms_key_id + 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 = merge(local.provider_tags, var.log_group_tags) + tags = local.log_group_tags } diff --git a/modules/compute-providers/ec2/runner-config.tf b/modules/compute-providers/ec2/runner-config.tf index 0425052890..f1d859581c 100644 --- a/modules/compute-providers/ec2/runner-config.tf +++ b/modules/compute-providers/ec2/runner-config.tf @@ -1,13 +1,13 @@ resource "aws_ssm_parameter" "runner_config_run_as" { - name = "${var.ssm_paths.root}/${var.ssm_paths.config}/run_as" + name = "${var.ssm.paths.root}/${var.ssm.paths.config}/run_as" type = "String" - value = var.runner_as_root ? "root" : var.runner_run_as - tags = merge(local.provider_tags, var.ssm_parameter_tags) + 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" + name = "${var.ssm.paths.root}/${var.ssm.paths.config}/enable_cloudwatch" type = "String" - value = var.enable_cloudwatch_agent - tags = merge(local.provider_tags, var.ssm_parameter_tags) + value = var.config.cloudwatch_agent.enabled + tags = local.ssm_parameter_tags } diff --git a/modules/compute-providers/ec2/runner-instances.tf b/modules/compute-providers/ec2/runner-instances.tf index 5eb64be0ea..a4cd44b4ba 100644 --- a/modules/compute-providers/ec2/runner-instances.tf +++ b/modules/compute-providers/ec2/runner-instances.tf @@ -8,29 +8,40 @@ locals { var.tags, ) - name_sg = var.overrides.name_sg == "" ? local.provider_tags["Name"] : var.overrides.name_sg - name_runner = var.overrides.name_runner == "" ? local.provider_tags["Name"] : var.overrides.name_runner + 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.runner_ec2_tags, + 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 + "ghr:ssm_config_path" = "${var.ssm.paths.root}/${var.ssm.paths.config}" + "ghr:runner_name_prefix" = var.runner.name_prefix }, ) - role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path - instance_profile_path = var.instance_profile_path == null ? "/${var.prefix}/" : var.instance_profile_path - userdata_template = var.userdata_template == null ? local.default_userdata_template[var.runner_os] : var.userdata_template - s3_location_runner_distribution = var.enable_runner_binaries_syncer ? "s3://${try(var.s3_runner_binaries.id, "")}/${try(var.s3_runner_binaries.key, "")}" : "" + 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.*"] } + "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 = { @@ -52,48 +63,48 @@ locals { } # Handle AMI configuration - ami_config = var.ami != null ? var.ami : { - filter = local.default_ami[var.runner_os] + 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_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.enable_userdata ? (var.userdata_content == null ? templatefile(local.userdata_template, { - enable_debug_logging = var.enable_user_data_debug_logging + 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.userdata_pre_install - install_runner = templatefile(local.userdata_install_runner[var.runner_os], { + 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 + RUNNER_ARCHITECTURE = var.runner.architecture }) - post_install = var.userdata_post_install - hook_job_started = var.runner_hook_job_started - hook_job_completed = var.runner_hook_job_completed - start_runner = templatefile(local.userdata_start_runner[var.runner_os], { - metadata_tags = var.metadata_options != null ? var.metadata_options.instance_metadata_tags : "enabled" + 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.ghes_url - ghes_ssl_verify = var.ghes_ssl_verify + 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.enable_cloudwatch_agent - ssm_key_cloudwatch_agent_config = var.enable_cloudwatch_agent ? aws_ssm_parameter.cloudwatch_agent_config_runner[0].name : "" - }) : var.userdata_content) : "" + 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) : + 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 ) } @@ -114,14 +125,14 @@ data "aws_ami" "runner" { 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" + name = "${var.ssm.paths.root}/${var.ssm.paths.config}/ami_id" type = "String" data_type = "aws:ec2:image" value = data.aws_ami.runner.id tags = merge( local.provider_tags, - var.ssm_parameter_tags, + local.ssm_parameter_tags, { # Remove parentheses from AMI name to comply with AWS tag constraints "ghr:ami_name" = replace(data.aws_ami.runner.name, "/[()]/", "") @@ -140,13 +151,13 @@ resource "aws_launch_template" "runner" { lifecycle { precondition { - condition = !var.enable_runner_binaries_syncer || var.s3_runner_binaries != null - error_message = "s3_runner_binaries must be set when enable_runner_binaries_syncer is true." + condition = !var.config.binaries_syncer.enabled || var.config.binaries_syncer.s3 != null + error_message = "config.binaries_syncer.s3 must be set when config.binaries_syncer.enabled is true." } } dynamic "block_device_mappings" { - for_each = var.block_device_mappings != null ? var.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 @@ -165,7 +176,7 @@ resource "aws_launch_template" "runner" { } dynamic "metadata_options" { - for_each = var.metadata_options != null ? [var.metadata_options] : [] + for_each = var.config.metadata_options != null ? [var.config.metadata_options] : [] content { http_endpoint = metadata_options.value.http_endpoint @@ -176,7 +187,7 @@ resource "aws_launch_template" "runner" { } dynamic "metadata_options" { - for_each = var.metadata_options != null ? [] : [0] + for_each = var.config.metadata_options != null ? [] : [0] content { instance_metadata_tags = "enabled" @@ -184,14 +195,14 @@ resource "aws_launch_template" "runner" { } dynamic "credit_specification" { - for_each = var.credit_specification != null ? [var.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.cpu_options != null ? [var.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) @@ -201,7 +212,7 @@ resource "aws_launch_template" "runner" { } dynamic "placement" { - for_each = var.placement != null ? [var.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) @@ -216,28 +227,28 @@ resource "aws_launch_template" "runner" { } dynamic "license_specification" { - for_each = var.license_specifications + for_each = var.config.license_specifications content { license_configuration_arn = license_specification.value.license_configuration_arn } } monitoring { - enabled = var.enable_runner_detailed_monitoring + enabled = var.config.detailed_monitoring_enabled } iam_instance_profile { - name = var.iam_overrides.override_instance_profile ? var.iam_overrides.instance_profile_name : aws_iam_instance_profile.runner[0].name + 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.key_name - ebs_optimized = var.ebs_optimized + key_name = var.config.key_name + ebs_optimized = var.config.ebs_optimized - vpc_security_group_ids = !var.associate_public_ipv4_address ? compact(concat( - var.enable_managed_runner_security_group ? [aws_security_group.runner_sg[0].id] : [], - var.runner_additional_security_group_ids, + 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 { @@ -255,7 +266,7 @@ resource "aws_launch_template" "runner" { # 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.instance_target_capacity_type == "spot" && length(var.enable_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 + for_each = var.config.instance_target_capacity_type == "spot" && length(var.config.enable_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 @@ -274,29 +285,29 @@ resource "aws_launch_template" "runner" { update_default_version = true dynamic "network_interfaces" { - for_each = var.associate_public_ipv4_address ? [var.associate_public_ipv4_address] : [] + 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.enable_managed_runner_security_group ? [aws_security_group.runner_sg[0].id] : [], - var.runner_additional_security_group_ids, + 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.enable_managed_runner_security_group ? 1 : 0 + 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.vpc_id + vpc_id = var.config.vpc_id ingress = [] dynamic "egress" { - for_each = var.egress_rules + for_each = var.config.egress_rules iterator = each content { diff --git a/modules/compute-providers/ec2/runner-role/README.md b/modules/compute-providers/ec2/runner-role/README.md index 3495b83afc..c7f7452916 100644 --- a/modules/compute-providers/ec2/runner-role/README.md +++ b/modules/compute-providers/ec2/runner-role/README.md @@ -6,14 +6,14 @@ This internal module builds only the EC2-specific IAM documents required by a ru ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -23,7 +23,7 @@ No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | | [aws_iam_policy_document.assume_role](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 | @@ -37,19 +37,16 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [aws\_partition](#input\_aws\_partition) | AWS partition used to build IAM and SSM ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region containing the runner configuration parameters. | `string` | n/a | yes | -| [enable\_cloudwatch\_agent](#input\_enable\_cloudwatch\_agent) | Include the CloudWatch agent policy in the runner role contract. | `bool` | n/a | yes | -| [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Include access to the runner distribution object in the runner role contract. | `bool` | n/a | yes | -| [enable\_ssm\_on\_runners](#input\_enable\_ssm\_on\_runners) | Include Session Manager permissions in the runner role contract. | `bool` | n/a | yes | -| [s3\_runner\_binaries](#input\_s3\_runner\_binaries) | S3 object containing the cached runner distribution; required when runner binary sync is enabled. |
object({
arn = string
key = string
})
| `null` | no | -| [ssm\_paths](#input\_ssm\_paths) | SSM paths used for runner tokens and configuration. |
object({
root = string
tokens = string
config = string
})
| n/a | yes | +| [config](#input\_config) | EC2 configuration that controls provider-owned runner policies.

- `cloudwatch_agent.enabled`: Includes the CloudWatch agent policy in the runner-role contract.
- `binaries_syncer.enabled`: Includes access to the synchronized runner distribution.
- `binaries_syncer.s3`: S3 object containing the runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: ARN of the runner-distribution bucket.
- `binaries_syncer.s3.key`: Object key of the runner distribution.
- `ssm_enabled`: Includes Session Manager permissions in the runner-role contract. |
object({
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
arn = string
key = string
}), null)
}), {})
ssm_enabled = optional(bool, false)
})
| n/a | yes | +| [ssm](#input\_ssm) | Parameter Store configuration used by the EC2 runner-role policies.

- `paths.root`: Root path for this runner stack.
- `paths.tokens`: Path segment containing registration tokens and just-in-time configuration.
- `paths.config`: Path segment containing persistent runner configuration. |
object({
paths = object({
root = string
tokens = string
config = string
})
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [assume\_role\_policy\_json](#output\_assume\_role\_policy\_json) | EC2 runner-role trust policy document. | | [inline\_policies](#output\_inline\_policies) | EC2 runner-role inline policies keyed by stable provider policy identifiers. | | [managed\_policy\_arns](#output\_managed\_policy\_arns) | EC2 provider-managed runner-role policy ARNs keyed by stable identifiers. | diff --git a/modules/compute-providers/ec2/runner-role/policies.tf b/modules/compute-providers/ec2/runner-role/policies.tf index c61f99a7b9..df5c5e74aa 100644 --- a/modules/compute-providers/ec2/runner-role/policies.tf +++ b/modules/compute-providers/ec2/runner-role/policies.tf @@ -3,7 +3,7 @@ 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}" + 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" } @@ -28,7 +28,7 @@ data "aws_iam_policy_document" "ssm_parameters" { "ssm:GetParameter", ] resources = [ - "${local.ssm_parameter_arn_prefix}${var.ssm_paths.root}/${var.ssm_paths.tokens}/*", + "${local.ssm_parameter_arn_prefix}${var.ssm.paths.root}/${var.ssm.paths.tokens}/*", ] condition { @@ -99,20 +99,13 @@ data "aws_iam_policy_document" "session_manager" { } data "aws_iam_policy_document" "distribution_bucket" { - count = var.enable_runner_binaries_syncer ? 1 : 0 - - lifecycle { - precondition { - condition = var.s3_runner_binaries != null - error_message = "s3_runner_binaries must be set when enable_runner_binaries_syncer is true." - } - } + count = var.config.binaries_syncer.enabled ? 1 : 0 statement { sid = "githubActionDist" effect = "Allow" actions = ["s3:GetObject", "s3:GetObjectAcl"] - resources = ["${try(var.s3_runner_binaries.arn, "")}/${try(var.s3_runner_binaries.key, "")}"] + resources = ["${try(var.config.binaries_syncer.s3.arn, "")}/${try(var.config.binaries_syncer.s3.key, "")}"] } } @@ -159,7 +152,7 @@ data "aws_iam_policy_document" "terminate_self" { } data "aws_iam_policy_document" "cloudwatch" { - count = var.enable_cloudwatch_agent ? 1 : 0 + count = var.config.cloudwatch_agent.enabled ? 1 : 0 statement { effect = "Allow" @@ -202,19 +195,19 @@ locals { policy_json = data.aws_iam_policy_document.terminate_self.json } }, - var.enable_ssm_on_runners ? { + var.config.ssm_enabled ? { session_manager = { name = "runner-ssm-session" policy_json = data.aws_iam_policy_document.session_manager.json } } : {}, - var.enable_runner_binaries_syncer ? { + var.config.binaries_syncer.enabled ? { distribution_bucket = { name = "distribution-bucket" policy_json = data.aws_iam_policy_document.distribution_bucket[0].json } } : {}, - var.enable_cloudwatch_agent ? { + var.config.cloudwatch_agent.enabled ? { cloudwatch = { name = "CloudWatchLogginAndMetrics" policy_json = data.aws_iam_policy_document.cloudwatch[0].json diff --git a/modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl b/modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl index b1f3f8c7af..792d4bc68f 100644 --- a/modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl +++ b/modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl @@ -20,20 +20,28 @@ override_data { } variables { - aws_region = "eu-west-1" - enable_cloudwatch_agent = true - enable_runner_binaries_syncer = true - enable_ssm_on_runners = true - - s3_runner_binaries = { - arn = "arn:aws:s3:::runner-distribution" - key = "runner.zip" + aws_region = "eu-west-1" + + config = { + cloudwatch_agent = { + enabled = true + } + binaries_syncer = { + enabled = true + s3 = { + arn = "arn:aws:s3:::runner-distribution" + key = "runner.zip" + } + } + ssm_enabled = true } - ssm_paths = { - root = "/github-runner/provider-test" - tokens = "tokens" - config = "config" + ssm = { + paths = { + root = "/github-runner/provider-test" + tokens = "tokens" + config = "config" + } } } @@ -73,10 +81,15 @@ run "omits_disabled_optional_policies" { command = plan variables { - enable_cloudwatch_agent = false - enable_runner_binaries_syncer = false - enable_ssm_on_runners = false - s3_runner_binaries = null + config = { + cloudwatch_agent = { + enabled = false + } + binaries_syncer = { + enabled = false + } + ssm_enabled = false + } } assert { @@ -89,9 +102,13 @@ run "requires_distribution_object_when_sync_is_enabled" { command = plan variables { - enable_runner_binaries_syncer = true - s3_runner_binaries = null + config = { + binaries_syncer = { + enabled = true + s3 = null + } + } } - expect_failures = [data.aws_iam_policy_document.distribution_bucket] + expect_failures = [var.config] } diff --git a/modules/compute-providers/ec2/runner-role/variables.tf b/modules/compute-providers/ec2/runner-role/variables.tf index aced38623e..c3be735c2a 100644 --- a/modules/compute-providers/ec2/runner-role/variables.tf +++ b/modules/compute-providers/ec2/runner-role/variables.tf @@ -9,35 +9,52 @@ variable "aws_region" { type = string } -variable "enable_cloudwatch_agent" { - description = "Include the CloudWatch agent policy in the runner role contract." - type = bool -} - -variable "enable_runner_binaries_syncer" { - description = "Include access to the runner distribution object in the runner role contract." - type = bool -} +variable "config" { + description = <<-EOT + EC2 configuration that controls provider-owned runner policies. -variable "enable_ssm_on_runners" { - description = "Include Session Manager permissions in the runner role contract." - type = bool -} + - `cloudwatch_agent.enabled`: Includes the CloudWatch agent policy in the runner-role contract. + - `binaries_syncer.enabled`: Includes access to the synchronized runner distribution. + - `binaries_syncer.s3`: S3 object containing the runner distribution. Required when synchronization is enabled. + - `binaries_syncer.s3.arn`: ARN of the runner-distribution bucket. + - `binaries_syncer.s3.key`: Object key of the runner distribution. + - `ssm_enabled`: Includes Session Manager permissions in the runner-role contract. + EOT -variable "s3_runner_binaries" { - description = "S3 object containing the cached runner distribution; required when runner binary sync is enabled." type = object({ - arn = string - key = string + cloudwatch_agent = optional(object({ + enabled = optional(bool, true) + }), {}) + binaries_syncer = optional(object({ + enabled = optional(bool, true) + s3 = optional(object({ + arn = string + key = string + }), null) + }), {}) + ssm_enabled = optional(bool, false) }) - default = null + + validation { + condition = !var.config.binaries_syncer.enabled || var.config.binaries_syncer.s3 != null + error_message = "config.binaries_syncer.s3 must be set when config.binaries_syncer.enabled is true." + } } -variable "ssm_paths" { - description = "SSM paths used for runner tokens and configuration." +variable "ssm" { + description = <<-EOT + Parameter Store configuration used by the EC2 runner-role policies. + + - `paths.root`: Root path for this runner stack. + - `paths.tokens`: Path segment containing registration tokens and just-in-time configuration. + - `paths.config`: Path segment containing persistent runner configuration. + EOT + type = object({ - root = string - tokens = string - config = string + paths = object({ + root = string + tokens = string + config = string + }) }) } diff --git a/modules/compute-providers/ec2/tests/provider.tftest.hcl b/modules/compute-providers/ec2/tests/provider.tftest.hcl index 77c4d4549a..93c8cc8939 100644 --- a/modules/compute-providers/ec2/tests/provider.tftest.hcl +++ b/modules/compute-providers/ec2/tests/provider.tftest.hcl @@ -30,40 +30,45 @@ override_data { } variables { - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - prefix = "provider-test" - - ami = { - filter = { state = ["available"] } - owners = ["amazon"] - id_ssm_parameter = { - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id" - } - kms_key = null - } - - instance_types = ["m5.large"] - - s3_runner_binaries = null - enable_runner_binaries_syncer = false - enable_cloudwatch_agent = false - enable_managed_runner_security_group = true + prefix = "provider-test" - iam_overrides = { - override_instance_profile = false - instance_profile_name = null + config = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id" + } + kms_key = null + } + binaries_syncer = { + enabled = false + s3 = null + } + cloudwatch_agent = { + enabled = false + } + managed_security_group_enabled = true } - runner_role = { - arn = "arn:aws:iam::123456789012:role/provider-test-runner" - name = "provider-test-runner" + runner = { + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/provider-test-runner" + name = "provider-test-runner" + } + } } - ssm_paths = { - root = "/github-runner/provider-test" - tokens = "tokens" - config = "config" + ssm = { + paths = { + root = "/github-runner/provider-test" + tokens = "tokens" + config = "config" + } } } @@ -167,11 +172,32 @@ run "accepts_partial_typed_compute_options" { command = plan variables { - overrides = { - name_runner = "custom-runner" - } - metadata_options = { - http_tokens = "optional" + config = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id" + } + kms_key = null + } + binaries_syncer = { + enabled = false + s3 = null + } + cloudwatch_agent = { + enabled = false + } + managed_security_group_enabled = true + overrides = { + name_runner = "custom-runner" + } + metadata_options = { + http_tokens = "optional" + } } } @@ -195,38 +221,71 @@ run "separates_provider_runner_and_ssm_tags" { command = plan variables { - ami = { - filter = { state = ["available"] } - owners = ["amazon"] - id_ssm_parameter = null - kms_key = null + config = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter = null + kms_key = null + } + binaries_syncer = { + enabled = false + s3 = null + } + cloudwatch_agent = { + enabled = true + } + managed_security_group_enabled = true + tags = { + Name = "runner-name" + Scope = "runner" + RunnerOnly = "runner" + "ghr:environment" = "runner-override" + "ghr:ssm_config_path" = "/runner/override" + "ghr:runner_name_prefix" = "runner-override" + } } tags = { Name = "provider-name" Scope = "provider" } - runner_ec2_tags = { - Name = "runner-name" - Scope = "runner" - RunnerOnly = "runner" - "ghr:environment" = "runner-override" - "ghr:ssm_config_path" = "/runner/override" - "ghr:runner_name_prefix" = "runner-override" + runner = { + name_prefix = "required-prefix" + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/provider-test-runner" + name = "provider-test-runner" + } + } } - runner_name_prefix = "required-prefix" - ssm_parameter_tags = { - Name = "ssm-name" - Scope = "ssm" - SsmOnly = "ssm" - "ghr:ami_name" = "ssm-override" - "ghr:ami_creation_date" = "ssm-override" - "ghr:ami_deprecation_time" = "ssm-override" + ssm = { + paths = { + root = "/github-runner/provider-test" + tokens = "tokens" + config = "config" + } + parameters = { + tags = { + Name = "ssm-name" + Scope = "ssm" + SsmOnly = "ssm" + "ghr:ami_name" = "ssm-override" + "ghr:ami_creation_date" = "ssm-override" + "ghr:ami_deprecation_time" = "ssm-override" + } + } } - enable_cloudwatch_agent = true - log_group_tags = { - Name = "log-name" - Scope = "log" - LogOnly = "log" + observability = { + logs = { + tags = { + Name = "log-name" + Scope = "log" + LogOnly = "log" + } + } } } @@ -306,9 +365,16 @@ run "requires_distribution_object_when_sync_is_enabled" { command = plan variables { - enable_runner_binaries_syncer = true - s3_runner_binaries = null + config = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + binaries_syncer = { + enabled = true + s3 = null + } + } } - expect_failures = [aws_launch_template.runner] + expect_failures = [var.config] } diff --git a/modules/compute-providers/ec2/variables.tf b/modules/compute-providers/ec2/variables.tf index a5d0045727..caee7f9b93 100644 --- a/modules/compute-providers/ec2/variables.tf +++ b/modules/compute-providers/ec2/variables.tf @@ -1,585 +1,396 @@ -variable "ami" { - description = <<-EOT - AMI selection and encryption configuration for runner instances. Null selects the default AMI configuration for `runner_os`. - - - `filter`: AMI filter names mapped to accepted values. These values are merged over the default filter for `runner_os`. - - `owners`: AWS account IDs or aliases allowed to own the selected AMI. - - `id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter from the selected AMI. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource. - - `id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply. - - `kms_key`: Optional customer-managed KMS key required to launch an encrypted AMI or snapshot. The wrapper's presence is the plan-time policy discriminator. - - `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply. - EOT - type = 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) - }) - default = null -} - -variable "vpc_id" { - description = "The VPC for the security groups." +variable "aws_partition" { + description = "AWS partition used to construct IAM ARNs." type = string + default = "aws" } -variable "subnet_ids" { - description = "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`." - type = list(string) -} - -variable "overrides" { - description = <<-EOT - Optional resource-name overrides. - - - `name_runner`: Name tag assigned to runner compute resources. An empty value uses the generated provider name. - - `name_sg`: Name tag assigned to the managed runner security group. An empty value uses the generated provider name. - EOT - type = object({ - name_runner = optional(string, "") - name_sg = optional(string, "") - }) - - default = {} -} - -variable "iam_overrides" { - description = <<-EOT - EC2 instance-profile ownership and selection. - - - `override_instance_profile`: Uses an externally managed instance profile when true; otherwise this module creates an instance profile for `runner_role`. - - `instance_profile_name`: Name of the externally managed instance profile used by the launch template. Required when `override_instance_profile` is true. - EOT - type = object({ - override_instance_profile = optional(bool, false) - instance_profile_name = optional(string, null) - }) - - default = { - override_instance_profile = false - instance_profile_name = null - } - - validation { - condition = !var.iam_overrides.override_instance_profile || var.iam_overrides.instance_profile_name != null - error_message = "instance_profile_name must be provided when override_instance_profile is true." - } -} - -variable "runner_role" { - description = <<-EOT - Runner IAM role created or selected by the common runner stack. - - - `arn`: Role ARN referenced by the EC2 control-plane policies. - - `name`: Role name associated with the provider-managed EC2 instance profile. - EOT - type = object({ - arn = string - name = string - }) +variable "prefix" { + description = "Prefix used to name EC2 provider resources." + type = string + default = "github-actions" } variable "tags" { - description = "Map of tags that will be added to created resources. By default resources will be tagged with name." - type = map(string) - default = {} -} - -variable "ssm_parameter_tags" { - description = "Map of tags that will be added to SSM parameters created by the EC2 provider. These tags override provider tags with the same key." - type = map(string) - default = {} -} - -variable "log_group_tags" { - description = "Map of tags that will be added to CloudWatch log groups created by the EC2 provider. These tags override provider tags with the same key." + description = "Base tags added to taggable EC2 provider resources. Nested SSM, log, and runner tags override this map within their documented scopes." type = map(string) default = {} } -variable "prefix" { - description = "The prefix used for naming resources" - type = string - default = "github-actions" -} - -variable "s3_runner_binaries" { +variable "config" { description = <<-EOT - S3 location of the synchronized GitHub runner distribution. - - - `arn`: Bucket ARN referenced by the runner IAM policy. - - `id`: Bucket name used to construct the runner-distribution S3 URI. - - `key`: Object key of the synchronized runner distribution. + EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner stack. + + - `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`: Provider runner-role setting consumed by the EC2 runner-role contract. + - `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 stack 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. + - `enable_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. EOT + type = object({ - arn = string - id = string - key = string + 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) + enable_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) }) -} -variable "block_device_mappings" { - description = <<-EOT - EBS block-device mappings added to the runner launch template. - - - `delete_on_termination`: Deletes the EBS volume when its runner instance terminates. - - `device_name`: Device name exposed to the runner instance. - - `encrypted`: Enables encryption for the EBS volume. - - `iops`: Provisioned IOPS for volume types that support configurable IOPS. - - `kms_key_id`: KMS key ID or ARN used to encrypt the EBS volume. - - `snapshot_id`: Snapshot used to initialize the EBS volume. - - `throughput`: Provisioned throughput in MiB/s for volume types that support configurable throughput. - - `volume_initialization_rate`: Fixed volume initialization rate in MiB/s for supported snapshot-backed volumes. - - `volume_size`: EBS volume size in GiB. - - `volume_type`: EBS volume type. - EOT - type = 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") - })) - default = [{ - volume_size = 30 - }] -} - -variable "ebs_optimized" { - description = "The EC2 EBS optimized configuration." - type = bool - default = false -} - -variable "instance_target_capacity_type" { - description = "Default lifecycle used runner instances, can be either `spot` or `on-demand`." - type = string - default = "spot" + nullable = false validation { - condition = contains(["spot", "on-demand"], var.instance_target_capacity_type) - error_message = "The instance target capacity should be either spot or on-demand." + condition = contains(["spot", "on-demand"], var.config.instance_target_capacity_type) + error_message = "config.instance_target_capacity_type must be spot or on-demand." } -} - -variable "instance_allocation_strategy" { - description = "The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`." - type = string - default = "lowest-price" validation { - condition = contains(["lowest-price", "diversified", "capacity-optimized", "capacity-optimized-prioritized", "price-capacity-optimized", "prioritized"], var.instance_allocation_strategy) - error_message = "The instance allocation strategy does not match the allowed values." + condition = contains(["lowest-price", "diversified", "capacity-optimized", "capacity-optimized-prioritized", "price-capacity-optimized", "prioritized"], var.config.instance_allocation_strategy) + error_message = "config.instance_allocation_strategy is not supported." } -} - -variable "instance_type_priorities" { - description = "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`." - type = map(number) - default = null -} - -variable "instance_max_spot_price" { - description = "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet." - type = string - default = null -} - -variable "runner_os" { - description = "The EC2 Operating System type to use for action runner instances (linux, osx, windows)." - type = string - default = "linux" validation { - condition = contains(["linux", "osx", "windows"], var.runner_os) - error_message = "Valid values for runner_os are (linux, osx, windows)." + condition = var.config.credit_specification == null ? true : contains(["standard", "unlimited"], var.config.credit_specification) + error_message = "config.credit_specification must be null, standard, or unlimited." } -} - -variable "instance_types" { - description = "List of EC2 instance types available when launching runner capacity." - type = list(string) - default = null -} - - -variable "enable_userdata" { - description = "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI" - type = bool - default = true -} - -variable "userdata_template" { - description = "Alternative user-data template file path replacing the default template. The template receives the standard bootstrap values, including `pre_install` and `post_install`; a custom template decides how to use them and must install the required runner software." - type = string - default = null -} - -variable "userdata_content" { - description = "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." - type = string - default = null -} - -variable "userdata_pre_install" { - description = "User-data script snippet to insert before GitHub action runner install" - type = string - default = "" -} - -variable "userdata_post_install" { - description = "User-data script snippet to insert after GitHub action runner install" - type = string - default = "" -} -variable "runner_hook_job_started" { - description = "Script to be ran in the runner environment at the beginning of every job" - type = string - default = "" -} - -variable "runner_hook_job_completed" { - description = "Script to be ran in the runner environment at the end of every job" - type = string - default = "" -} - -variable "runner_boot_time_in_minutes" { - description = "The minimum time for an EC2 runner to boot and register as a runner." - type = number - default = 5 -} - -variable "role_path" { - description = "The path that will be added to the role; if not set, the prefix will be used." - type = string - default = null -} - -variable "instance_profile_path" { - description = "The path that will be added to the instance_profile, if not set the prefix will be used." - type = string - default = null -} - -variable "runner_as_root" { - description = "Run the action runner under the root user. Variable `runner_run_as` will be ignored." - type = bool - default = false -} - -variable "runner_run_as" { - description = "Run the GitHub actions agent as user." - type = string - default = "ec2-user" -} - -variable "runner_architecture" { - description = "The platform architecture of the runner instance_type." - type = string - default = "x64" -} - -variable "logging_retention_in_days" { - description = "Number of days to retain events in the EC2 runner log groups. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653." - type = number - default = 180 -} - -variable "logging_kms_key_id" { - description = "Specifies the kms key id to encrypt the logs with" - type = string - default = null -} - -variable "create_service_linked_role_spot" { - description = "(optional) create the service linked role for spot instances that is required by the scale-up lambda." - type = bool - default = false -} - -variable "aws_partition" { - description = "(optional) partition for the base arn if not 'aws'" - type = string - default = "aws" -} - -variable "enable_cloudwatch_agent" { - description = "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`." - type = bool - default = true -} - -variable "enable_managed_runner_security_group" { - description = "Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`." - type = bool - default = true -} - -variable "cloudwatch_config" { - description = "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details." - type = string - default = null -} - -variable "runner_log_files" { - description = "(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`." - type = list(object({ - log_group_name = string - prefix_log_group = bool - file_path = string - log_stream_name = string - log_class = optional(string, "STANDARD") - })) - default = null -} - -variable "ghes_url" { - description = "GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com|" - type = string - default = null -} - -variable "ghes_ssl_verify" { - description = "GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure)." - type = bool - default = true -} - -variable "key_name" { - description = "Key pair name" - type = string - default = null -} - -variable "runner_additional_security_group_ids" { - description = "(optional) List of additional security groups IDs to apply to the runner" - type = list(string) - default = [] -} - -variable "enable_runner_detailed_monitoring" { - description = "Enable detailed monitoring for runners" - type = bool - default = false -} - -variable "egress_rules" { - description = <<-EOT - Egress rules created on the provider-managed runner security group. - - - `cidr_blocks`: IPv4 CIDR destinations allowed by the rule. - - `ipv6_cidr_blocks`: IPv6 CIDR destinations allowed by the rule. - - `prefix_list_ids`: AWS prefix-list destinations allowed by the rule. - - `from_port`: First destination port in the permitted range. - - `protocol`: IP protocol name or number. Use `-1` for all protocols. - - `security_groups`: Destination security-group IDs allowed by the rule. - - `self`: Allows traffic to the managed runner security group itself when true. - - `to_port`: Last destination port in the permitted range. - - `description`: Optional description assigned to the security-group rule. - EOT - type = 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 - })) - default = [{ - 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 - }] -} + validation { + condition = var.config.cpu_options == null ? true : ( + (var.config.cpu_options.amd_sev_snp == null || contains(["enabled", "disabled"], var.config.cpu_options.amd_sev_snp)) && + (var.config.cpu_options.nested_virtualization == null || contains(["enabled", "disabled"], var.config.cpu_options.nested_virtualization)) + ) + error_message = "config.cpu_options.amd_sev_snp and config.cpu_options.nested_virtualization must be enabled or disabled when set." + } -variable "runner_ec2_tags" { - description = "Tags added to runner instance, volume, network-interface, and eligible Spot-request tag specifications. These override module tags and the generated runner `Name`; provider-required `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` tags take final precedence." - type = map(string) - default = {} + validation { + condition = !var.config.binaries_syncer.enabled || var.config.binaries_syncer.s3 != null + error_message = "config.binaries_syncer.s3 must be set when config.binaries_syncer.enabled is true." + } } -variable "metadata_options" { +variable "runner" { description = <<-EOT - Instance Metadata Service configuration in the runner launch template. The default bootstrap flow reads runner configuration from instance tags, so disable metadata tags only when supplying a custom startup flow. - - - `instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when set to `enabled`. - - `http_endpoint`: Enables or disables the Instance Metadata Service endpoint. - - `http_tokens`: Controls whether IMDSv2 session tokens are optional or required. - - `http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses. + Provider-neutral runner settings consumed by EC2. + + - `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`. + - `architecture`: Runner distribution architecture. + - `boot_time_in_minutes`: Expected boot and registration duration used by scale-down and pool. + - `name_prefix`: Prefix added to registered runner names. + - `run_as_root`: Runs the runner service as root. + - `run_as`: Operating-system user used when `run_as_root` is false. + - `hooks.job_started`: Script installed as the runner job-started hook. + - `hooks.job_completed`: Script installed as the runner job-completed hook. + - `iam.role.arn`: Resolved runner-role ARN referenced by EC2 control-plane policies. + - `iam.role.name`: Resolved runner-role name used by the provider-managed instance profile. + - `iam.path`: IAM path used for provider-managed policies. Null derives the path from `prefix`. EOT type = 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) + os = optional(string, "linux") + architecture = optional(string, "x64") + boot_time_in_minutes = optional(number, 5) + 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 + }) + path = optional(string, null) + }) }) - default = {} -} - -variable "enable_runner_binaries_syncer" { - description = "Uses a synchronized GitHub runner distribution from `s3_runner_binaries` during bootstrap. Disable this when the runner distribution is already present in a prebuilt AMI. This module does not create the synchronization Lambda." - type = bool - default = true -} - -variable "enable_user_data_debug_logging" { - description = "Option to enable debug logging for user-data, this logs all secrets as well." - type = bool - default = false -} - -variable "ssm_paths" { - description = <<-EOT - Parameter Store paths used by the EC2 provider and runner bootstrap flow. - - `root`: Root Parameter Store path for this runner stack. - - `tokens`: Path segment under `root` used for registration tokens and just-in-time configuration. - - `config`: Path segment under `root` used for persistent runner and provider configuration. - EOT - type = object({ - root = string - tokens = string - config = string - }) -} + nullable = false -variable "runner_name_prefix" { - description = "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'." - type = string - default = "" validation { - condition = length(var.runner_name_prefix) <= 45 - error_message = "The prefix used for the GitHub runner name must be less than 32 characters. AWS instances id are 17 chars, https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/resource-ids.html" + condition = contains(["linux", "osx", "windows"], var.runner.os) + error_message = "runner.os must be linux, osx, or windows." } -} - -variable "credit_specification" { - description = "The credit option for CPU usage of a T instance. Can be unset, \"standard\" or \"unlimited\"." - type = string - default = null validation { - condition = var.credit_specification == null ? true : contains(["standard", "unlimited"], var.credit_specification) - error_message = "Valid values for credit_specification are (null, \"standard\", \"unlimited\")." + condition = length(var.runner.name_prefix) <= 45 + error_message = "runner.name_prefix must be at most 45 characters." } } -variable "cpu_options" { +variable "github" { description = <<-EOT - CPU topology and processor-feature configuration for runner instances. Not all instance types support these options. + GitHub Enterprise Server settings used to render runner bootstrap data. - - `core_count`: Number of CPU cores exposed to the runner instance. - - `threads_per_core`: Number of hardware threads exposed per CPU core. - - `amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types. - - `nested_virtualization`: Enables or disables nested virtualization on supported instance types. + - `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com. + - `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server. EOT type = object({ - core_count = optional(number) - threads_per_core = optional(number) - amd_sev_snp = optional(string) - nested_virtualization = optional(string) + enterprise_server = optional(object({ + url = optional(string, null) + ssl_verify = optional(bool, true) + }), {}) }) - default = null - - validation { - condition = var.cpu_options == null ? true : ( - (var.cpu_options.amd_sev_snp == null || contains(["enabled", "disabled"], var.cpu_options.amd_sev_snp)) && - (var.cpu_options.nested_virtualization == null || contains(["enabled", "disabled"], var.cpu_options.nested_virtualization)) - ) - error_message = "When set, cpu_options.amd_sev_snp and cpu_options.nested_virtualization must be one of: enabled, disabled." - } + default = {} + nullable = false } -variable "placement" { +variable "ssm" { description = <<-EOT - EC2 placement configuration for runner instances. + Parameter Store paths and tag scopes used by EC2 runner bootstrap resources. - - `affinity`: Dedicated Host affinity setting. - - `availability_zone`: Availability Zone in which runner instances are placed. - - `group_id`: Placement-group ID. - - `group_name`: Placement-group name. - - `host_id`: Dedicated Host ID. - - `host_resource_group_arn`: ARN of the host resource group used for placement. - - `spread_domain`: Spread-domain placement value. - - `tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`. - - `partition_number`: Placement-group partition number. + - `paths.root`: Root Parameter Store path for the runner stack. + - `paths.tokens`: Path segment used for registration tokens and just-in-time configuration. + - `paths.config`: Path segment used for persistent runner and provider configuration. + - `tags`: Shared SSM tags that override module-level `tags`. + - `parameters.tags`: Parameter-specific tags that override module-level and shared SSM tags. EOT type = 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) + paths = object({ + root = string + tokens = string + config = string + }) + tags = optional(map(string), {}) + parameters = optional(object({ + tags = optional(map(string), {}) + }), {}) }) - default = null + + nullable = false } -variable "license_specifications" { +variable "observability" { description = <<-EOT - License Manager configurations added to the runner launch template. These may be required for macOS dedicated-host runners when the host resource group uses a Mac dedicated-host license configuration. + CloudWatch Logs settings used by EC2 runner log groups. - - `license_configuration_arn`: ARN of an AWS License Manager license configuration. + - `logs.retention_in_days`: Retention period for EC2 runner log groups. + - `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner log groups. + - `logs.tags`: Shared log-group tags that override module-level `tags`. EOT - type = list(object({ - license_configuration_arn = string - })) - default = [] -} - -variable "associate_public_ipv4_address" { - description = "Associate public IPv4 with the runner. Only tested with IPv4" - type = bool - default = false -} - -variable "enable_on_demand_failover_for_errors" { - description = "Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later." - type = list(string) - default = [] -} - -variable "scale_errors" { - description = "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors" - type = list(string) - default = [ - "UnfulfillableCapacity", - "MaxSpotInstanceCountExceeded", - "TargetCapacityLimitExceededException", - "RequestLimitExceeded", - "ResourceLimitExceeded", - "MaxSpotInstanceCountExceeded", - "MaxSpotFleetRequestCountExceeded", - "InsufficientInstanceCapacity", - "InsufficientCapacityOnHost", - ] -} - -variable "use_dedicated_host" { - description = "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly." - type = bool - default = false + type = object({ + logs = optional(object({ + retention_in_days = optional(number, 180) + kms_key_id = optional(string, null) + tags = optional(map(string), {}) + }), {}) + }) + default = {} + nullable = false } diff --git a/modules/runner-stack/ec2.tf b/modules/runner-stack/ec2.tf index ea9d018428..ca7cc547c1 100644 --- a/modules/runner-stack/ec2.tf +++ b/modules/runner-stack/ec2.tf @@ -8,67 +8,17 @@ module "ec2" { count = local.provider_type == "ec2" ? 1 : 0 source = "../compute-providers/ec2" - ami = local.ec2.ami - vpc_id = local.ec2.vpc_id - subnet_ids = local.ec2.subnet_ids - overrides = local.ec2.overrides - iam_overrides = { - override_instance_profile = local.ec2.instance_profile != null - instance_profile_name = try(local.ec2.instance_profile.name, null) - } - runner_role = local.runner_role - tags = var.tags - prefix = var.prefix - s3_runner_binaries = local.ec2.binaries_syncer.s3 - block_device_mappings = local.ec2.block_device_mappings - ebs_optimized = local.ec2.ebs_optimized - instance_target_capacity_type = local.ec2.instance_target_capacity_type - instance_allocation_strategy = local.ec2.instance_allocation_strategy - instance_type_priorities = local.ec2.instance_type_priorities - instance_max_spot_price = local.ec2.instance_max_spot_price - runner_os = var.runner.os - instance_types = local.ec2.instance_types - enable_userdata = local.ec2.user_data.enabled - userdata_template = local.ec2.user_data.template - userdata_content = local.ec2.user_data.content - userdata_pre_install = local.ec2.user_data.pre_install - userdata_post_install = local.ec2.user_data.post_install - enable_user_data_debug_logging = local.ec2.user_data.debug_logging_enabled - runner_hook_job_started = var.runner.hooks.job_started - runner_hook_job_completed = var.runner.hooks.job_completed - runner_boot_time_in_minutes = var.runner.boot_time_in_minutes - role_path = var.runner.iam.path - instance_profile_path = local.ec2.instance_profile_path - runner_as_root = var.runner.run_as_root - runner_run_as = var.runner.run_as - runner_architecture = var.runner.architecture - logging_retention_in_days = var.observability.logs.retention_in_days - logging_kms_key_id = var.observability.logs.kms_key_id - log_group_tags = local.observability_log_tags - create_service_linked_role_spot = local.ec2.create_service_linked_role_spot - aws_partition = var.aws_partition - enable_cloudwatch_agent = local.ec2.cloudwatch_agent.enabled - enable_managed_runner_security_group = local.ec2.managed_security_group_enabled - cloudwatch_config = local.ec2.cloudwatch_agent.config - runner_log_files = local.ec2.log_files - ghes_url = var.github.enterprise_server.url - ghes_ssl_verify = var.github.enterprise_server.ssl_verify - key_name = local.ec2.key_name - runner_additional_security_group_ids = local.ec2.additional_security_group_ids - enable_runner_detailed_monitoring = local.ec2.detailed_monitoring_enabled - egress_rules = local.ec2.egress_rules - runner_ec2_tags = local.ec2.tags - metadata_options = local.ec2.metadata_options - enable_runner_binaries_syncer = local.ec2.binaries_syncer.enabled - ssm_paths = var.ssm.paths - ssm_parameter_tags = local.ssm_parameter_tags - runner_name_prefix = var.runner.name_prefix - credit_specification = local.ec2.credit_specification - cpu_options = local.ec2.cpu_options - placement = local.ec2.placement - license_specifications = local.ec2.license_specifications - associate_public_ipv4_address = local.ec2.associate_public_ipv4_address - enable_on_demand_failover_for_errors = local.ec2.enable_on_demand_failover_for_errors - scale_errors = local.ec2.scale_errors - use_dedicated_host = local.ec2.use_dedicated_host + aws_partition = var.aws_partition + prefix = var.prefix + tags = var.tags + + config = local.ec2 + runner = merge(var.runner, { + iam = merge(var.runner.iam, { + role = local.runner_role + }) + }) + github = var.github + ssm = var.ssm + observability = var.observability } diff --git a/modules/runner-stack/runner-role.tf b/modules/runner-stack/runner-role.tf index 0b9a8f916b..52eef677fe 100644 --- a/modules/runner-stack/runner-role.tf +++ b/modules/runner-stack/runner-role.tf @@ -4,13 +4,10 @@ module "ec2_runner_role" { count = local.provider_type == "ec2" ? 1 : 0 source = "../compute-providers/ec2/runner-role" - aws_partition = var.aws_partition - aws_region = var.aws_region - enable_cloudwatch_agent = local.ec2.cloudwatch_agent.enabled - enable_runner_binaries_syncer = local.ec2.binaries_syncer.enabled - enable_ssm_on_runners = local.ec2.ssm_enabled - s3_runner_binaries = local.ec2.binaries_syncer.s3 - ssm_paths = var.ssm.paths + aws_partition = var.aws_partition + aws_region = var.aws_region + config = local.ec2 + ssm = var.ssm } locals { From aee18422625f712b83910312fbbc8a054ccd7f54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:47:54 +0000 Subject: [PATCH 21/27] docs: auto update terraform docs --- modules/compute-providers/ec2/README.md | 10 +++++----- modules/compute-providers/ec2/runner-role/README.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index 86e145cb43..3cb131343f 100644 --- a/modules/compute-providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -10,14 +10,14 @@ EC2 is the only active compute provider. A future provider must implement the sa ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -27,7 +27,7 @@ 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 | @@ -47,7 +47,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM ARNs. | `string` | `"aws"` | no | | [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner stack.

- `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`: Provider runner-role setting consumed by the EC2 runner-role contract.
- `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 stack 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.
- `enable_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)
enable_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 used to render runner bootstrap data.

- `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server. |
object({
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
})
| `{}` | no | @@ -60,7 +60,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [control\_plane](#output\_control\_plane) | Provider-neutral control-plane contract containing the EC2 environment and IAM fragments. | | [resources](#output\_resources) | EC2-specific resources and bootstrap logging details. | diff --git a/modules/compute-providers/ec2/runner-role/README.md b/modules/compute-providers/ec2/runner-role/README.md index c7f7452916..2de15b4638 100644 --- a/modules/compute-providers/ec2/runner-role/README.md +++ b/modules/compute-providers/ec2/runner-role/README.md @@ -6,14 +6,14 @@ This internal module builds only the EC2-specific IAM documents required by a ru ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -23,7 +23,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | | [aws_iam_policy_document.assume_role](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 | @@ -37,7 +37,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to build IAM and SSM ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region containing the runner configuration parameters. | `string` | n/a | yes | | [config](#input\_config) | EC2 configuration that controls provider-owned runner policies.

- `cloudwatch_agent.enabled`: Includes the CloudWatch agent policy in the runner-role contract.
- `binaries_syncer.enabled`: Includes access to the synchronized runner distribution.
- `binaries_syncer.s3`: S3 object containing the runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: ARN of the runner-distribution bucket.
- `binaries_syncer.s3.key`: Object key of the runner distribution.
- `ssm_enabled`: Includes Session Manager permissions in the runner-role contract. |
object({
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
arn = string
key = string
}), null)
}), {})
ssm_enabled = optional(bool, false)
})
| n/a | yes | @@ -46,7 +46,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [assume\_role\_policy\_json](#output\_assume\_role\_policy\_json) | EC2 runner-role trust policy document. | | [inline\_policies](#output\_inline\_policies) | EC2 runner-role inline policies keyed by stable provider policy identifiers. | | [managed\_policy\_arns](#output\_managed\_policy\_arns) | EC2 provider-managed runner-role policy ARNs keyed by stable identifiers. | From bd0294b1a05247361dd7f20da13e7bcfa77e3a8b Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 16:59:44 +0200 Subject: [PATCH 22/27] refactor(multi-runner): isolate Lambda terminology changes --- .../src/pool/pool-contract.test.ts | 4 ++-- .../scale-runners/scale-down-contract.test.ts | 4 ++-- .../scale-runners/scale-up-contract.test.ts | 6 +++--- .../src/test/runner-provider-contracts/pool.ts | 8 ++++---- .../runner-provider-contracts/scale-down.ts | 8 ++++---- .../test/runner-provider-contracts/scale-up.ts | 18 +++++++++--------- .../templates/provider/README.md | 8 ++++---- .../templates/provider/provider.test.ts | 2 +- 8 files changed, 29 insertions(+), 29 deletions(-) diff --git a/lambdas/functions/control-plane/src/pool/pool-contract.test.ts b/lambdas/functions/control-plane/src/pool/pool-contract.test.ts index ec0c803839..e7c5eee21a 100644 --- a/lambdas/functions/control-plane/src/pool/pool-contract.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool-contract.test.ts @@ -35,7 +35,7 @@ const githubClient = { const cleanEnv = process.env; -const computeProviders = providerTypes.map((type) => ({ +const lanes = providerTypes.map((type) => ({ provider: { type, listRunners: vi.fn(), @@ -68,7 +68,7 @@ beforeEach(() => { definePoolContractTests({ adjust, - computeProviders, githubInstallationClient: githubClient, + lanes, resolveCapability: mockedResolveCapability, }); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down-contract.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down-contract.test.ts index 66b56c8087..ade06747e4 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down-contract.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down-contract.test.ts @@ -11,7 +11,7 @@ const mockedResolveCapability = vi.spyOn(controlPlaneProviderRegistry, 'capabili const cleanEnv = process.env; -const computeProviders = providerTypes.map((type) => ({ +const lanes = providerTypes.map((type) => ({ provider: { type, list: vi.fn(), @@ -28,7 +28,7 @@ beforeEach(() => { }); defineScaleDownContractTests({ - computeProviders, + lanes, resolveCapability: mockedResolveCapability, scaleDown, }); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts index 5c5bfa2a1d..257f9907ca 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts @@ -43,14 +43,14 @@ const payloads: ActionRequestMessageSQS[] = [ const cleanEnv = process.env; -const computeProviders = providerTypes.map((type) => ({ +const lanes = providerTypes.map((type) => ({ provider: { type, resolveLabelsForRunners: vi.fn(), getCurrentRunners: vi.fn(), createRunners: vi.fn(), } satisfies ScaleUpRunnerProvider, - state: { computeProvider: type }, + state: { lane: type }, })); beforeEach(() => { @@ -75,9 +75,9 @@ beforeEach(() => { }); defineScaleUpContractTests({ - computeProviders, createPayloads: () => structuredClone(payloads), githubInstallationClient: githubClient, + lanes, resolveCapability: mockedResolveCapability, scaleUp, }); diff --git a/lambdas/functions/control-plane/src/test/runner-provider-contracts/pool.ts b/lambdas/functions/control-plane/src/test/runner-provider-contracts/pool.ts index 5caa39b240..9997e51aba 100644 --- a/lambdas/functions/control-plane/src/test/runner-provider-contracts/pool.ts +++ b/lambdas/functions/control-plane/src/test/runner-provider-contracts/pool.ts @@ -6,24 +6,24 @@ import type { PoolRunnerProvider } from '../../pool/pool-provider'; type TestPoolProvider = Omit & { type: TType }; -export interface PoolContractProvider { +export interface PoolContractLane { provider: TestPoolProvider; } interface PoolContractOptions { adjust: (event: PoolEvent) => Promise; githubInstallationClient: Octokit; - computeProviders: readonly PoolContractProvider[]; + lanes: readonly PoolContractLane[]; resolveCapability: MockInstance<(type: TType, capability: 'pool') => () => Omit, 'type'>>; } export function definePoolContractTests({ adjust, - computeProviders, githubInstallationClient, + lanes, resolveCapability, }: PoolContractOptions): void { - describe.each(computeProviders.map((computeProvider) => [computeProvider.provider.type, computeProvider] as const))( + describe.each(lanes.map((lane) => [lane.provider.type, lane] as const))( '%s pool orchestration contract', (_, { provider }) => { beforeEach(() => { diff --git a/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-down.ts b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-down.ts index 8d76a755fd..451e33f9e1 100644 --- a/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-down.ts +++ b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-down.ts @@ -4,12 +4,12 @@ import type { ScaleDownRunnerProvider } from '../../scale-runners/types'; type TestScaleDownProvider = Omit & { type: TType }; -export interface ScaleDownContractProvider { +export interface ScaleDownContractLane { provider: TestScaleDownProvider; } interface ScaleDownContractOptions { - computeProviders: readonly ScaleDownContractProvider[]; + lanes: readonly ScaleDownContractLane[]; resolveCapability: MockInstance< (type: TType, capability: 'scaleDown') => () => Omit, 'type'> >; @@ -17,11 +17,11 @@ interface ScaleDownContractOptions { } export function defineScaleDownContractTests({ - computeProviders, + lanes, resolveCapability, scaleDown, }: ScaleDownContractOptions): void { - describe.each(computeProviders.map((computeProvider) => [computeProvider.provider.type, computeProvider] as const))( + describe.each(lanes.map((lane) => [lane.provider.type, lane] as const))( '%s scale-down orchestration contract', (_, { provider }) => { beforeEach(() => { diff --git a/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-up.ts b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-up.ts index ac45d49e10..804c8a2fbb 100644 --- a/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-up.ts +++ b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-up.ts @@ -5,15 +5,15 @@ import type { ActionRequestMessageSQS, ScaleUpRunnerProvider } from '../../scale type TestScaleUpProvider = Omit & { type: TType }; -export interface ScaleUpContractProvider { +export interface ScaleUpContractLane { provider: TestScaleUpProvider; state: unknown; } interface ScaleUpContractOptions { createPayloads: () => ActionRequestMessageSQS[]; - computeProviders: readonly ScaleUpContractProvider[]; githubInstallationClient: Octokit; + lanes: readonly ScaleUpContractLane[]; resolveCapability: MockInstance< (type: TType, capability: 'scaleUp') => () => Omit, 'type'> >; @@ -27,13 +27,13 @@ const createResult = { }; export function defineScaleUpContractTests({ - computeProviders, createPayloads, githubInstallationClient, + lanes, resolveCapability, scaleUp, }: ScaleUpContractOptions): void { - describe.each(computeProviders.map((computeProvider) => [computeProvider.provider.type, computeProvider] as const))( + describe.each(lanes.map((lane) => [lane.provider.type, lane] as const))( '%s scale-up orchestration contract', (_, { provider, state }) => { beforeEach(() => { @@ -47,14 +47,14 @@ export function defineScaleUpContractTests({ vi.mocked(provider.createRunners).mockResolvedValue(createResult); }); - it('forwards the prepared compute-provider state through runner lookup and creation', async () => { + it('forwards the prepared lane state through runner lookup and creation', async () => { const payloads = createPayloads(); - payloads[0].labels = ['compute-provider-label']; + payloads[0].labels = ['lane-label']; await scaleUp(payloads); expect(resolveCapability).toHaveBeenCalledWith(provider.type, 'scaleUp'); - expect(provider.resolveLabelsForRunners).toHaveBeenCalledWith(['compute-provider-label']); + expect(provider.resolveLabelsForRunners).toHaveBeenCalledWith(['lane-label']); expect(provider.getCurrentRunners).toHaveBeenCalledWith(state, { runnerOwner: payloads[0].repositoryOwner, runnerType: 'Org', @@ -68,7 +68,7 @@ export function defineScaleUpContractTests({ ); }); - it('does not query current runners when the compute provider has unlimited capacity', async () => { + it('does not query current runners when the lane has unlimited capacity', async () => { process.env.RUNNERS_MAXIMUM_COUNT = '-1'; const payloads = createPayloads(); payloads.push({ ...payloads[0], id: 2, messageId: 'message-2' }); @@ -79,7 +79,7 @@ export function defineScaleUpContractTests({ expect(provider.createRunners).toHaveBeenCalledWith(expect.objectContaining({ numberOfRunners: 2 })); }); - it('does not create runners when the compute provider has reached maximum capacity', async () => { + it('does not create runners when the lane has reached maximum capacity', async () => { process.env.RUNNERS_MAXIMUM_COUNT = '1'; vi.mocked(provider.getCurrentRunners).mockResolvedValue(1); diff --git a/lambdas/libs/runner-providers/templates/provider/README.md b/lambdas/libs/runner-providers/templates/provider/README.md index dce666020a..27fbc09086 100644 --- a/lambdas/libs/runner-providers/templates/provider/README.md +++ b/lambdas/libs/runner-providers/templates/provider/README.md @@ -1,17 +1,17 @@ # Runner provider template Copy this directory to the appropriate provider namespace, for example -`aws/codebuild`, and replace `template` with the new compute-provider type. +`aws/codebuild`, and replace `template` with the new lane type. The template is compile-checked but intentionally not registered. A provider has separate webhook and control-plane entry points so each Lambda bundles only -the code it uses. To enable a completed provider, add its compute-provider type to +the code it uses. To enable a completed provider, add its lane type to `provider-types.ts`, then register each entry point in its matching file: - `providers.config.webhook.ts` - `providers.config.control-plane.ts` -Each entry point exports its module as `provider`. Alias that export to the compute-provider +Each entry point exports its module as `provider`. Alias that export to the lane name when enabling it, for example: ```ts @@ -21,7 +21,7 @@ import { provider as codebuild } from './aws/codebuild/webhook'; Implement every capability before registering the provider: - `pool`: list managed runners, count available runners, and create runners. -- `scaleUp`: prepare compute-provider state, count current runners, and create runners. +- `scaleUp`: prepare lane state, count current runners, and create runners. - `scaleDown`: list, inspect, mark, unmark, and terminate runners. - `dynamicLabels`: select a webhook dispatch target for supported labels. diff --git a/lambdas/libs/runner-providers/templates/provider/provider.test.ts b/lambdas/libs/runner-providers/templates/provider/provider.test.ts index 4c1f099701..d449de3947 100644 --- a/lambdas/libs/runner-providers/templates/provider/provider.test.ts +++ b/lambdas/libs/runner-providers/templates/provider/provider.test.ts @@ -3,7 +3,7 @@ import { expect, it, vi } from 'vitest'; import { provider as controlPlaneProvider } from './control-plane'; import { provider as webhookProvider } from './webhook'; -it('exposes every runner provider capability from its compute-provider entry point', () => { +it('exposes every runner provider capability from its lane entry point', () => { const controlPlanePlugin = controlPlaneProvider.createPlugin(vi.fn(async () => [])); const pool = controlPlanePlugin.capabilities.pool(); const scaleUp = controlPlanePlugin.capabilities.scaleUp(); From 1bd07428b825732f92012505bdf0562e35cc31a7 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 17:06:54 +0200 Subject: [PATCH 23/27] docs: move compute provider refactor to internal navigation --- mkdocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yaml b/mkdocs.yaml index ae558a120c..7ebd445bfa 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -61,11 +61,11 @@ nav: - Runners (main): modules/runners.md - Submodules (public): - Multi Runners: modules/public/multi-runner.md - - Compute provider refactor (experimental): compute-provider-refactor.md - AMI Housekeeper: modules/public/ami-housekeeper.md - Lambda Downloader: modules/public/download-lambda.md - Setup IAM permissions: modules/public/setup-iam-permissions.md - Submodules (internal): + - Compute provider refactor (experimental): compute-provider-refactor.md - Runners: modules/internal/runners.md - Syncer: modules/internal/runner-binaries-syncer.md - SSM: modules/internal/ssm.md From 3396cfa9e7b2cdb4dde43ac5e06524fd3a29a479 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 17:23:28 +0200 Subject: [PATCH 24/27] refactor(runner-stack): nest component outputs --- docs/index.md | 2 +- .../internal}/compute-provider-refactor.md | 2 +- mkdocs.yaml | 2 +- modules/multi-runner/README.md | 4 +- modules/multi-runner/outputs.tf | 16 ++--- .../tests/provider-routing.tftest.hcl | 47 ++++++++------ modules/runner-stack/README.md | 14 ++--- modules/runner-stack/outputs.tf | 63 ++++++++----------- modules/runner-stack/pool/README.md | 4 +- modules/runner-stack/pool/outputs.tf | 17 +++-- .../pool/tests/provider.tftest.hcl | 5 ++ .../computed-iam-inputs.tf | 4 +- modules/runner-stack/tests/pool.tftest.hcl | 14 ++++- modules/runner-stack/tests/tags.tftest.hcl | 6 +- 14 files changed, 97 insertions(+), 103 deletions(-) rename docs/{ => modules/internal}/compute-provider-refactor.md (95%) diff --git a/docs/index.md b/docs/index.md index bc356a217d..002b37c692 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,7 +101,7 @@ Besides these permissions, the lambdas also need permission to CloudWatch (for l ## Terraform main modules -Currently we support two main modules. The existing `runners` module remains the stable EC2 implementation, and the `multi-runner` module creates multiple runner configurations in one deployment. Stable `multi_runner_config` entries continue to use the unchanged `runners` module. Entries under `experimental.multi_runner_config_v2` use the new provider-oriented `runner-stack`, where the common control plane owns scale-up, scale-down, pool, retry, housekeeper, Lambda roles, and the runner role and attachments. The EC2 provider supplies EC2-specific policy requirements and owns the instance profile, launch template, bootstrap resources, and runner log groups. Phase 1 supports non-overlapping v1 and v2 configurations together without moving legacy state; later releases will translate v1, ship state migration, and only then remove the v1 interface. See the [experimental compute-provider refactor](compute-provider-refactor.md) and [multi-runner v2 migration roadmap](modules/public/multi-runner.md#multi-runner-v2-migration-roadmap). EC2 is the only active Terraform-managed provider; microVM, CodeBuild, and other provider modules are future work. +Currently we support two main modules. The existing `runners` module remains the stable EC2 implementation, and the `multi-runner` module creates multiple runner configurations in one deployment. Stable `multi_runner_config` entries continue to use the unchanged `runners` module. Entries under `experimental.multi_runner_config_v2` use the new provider-oriented `runner-stack`, where the common control plane owns scale-up, scale-down, pool, retry, housekeeper, Lambda roles, and the runner role and attachments. The EC2 provider supplies EC2-specific policy requirements and owns the instance profile, launch template, bootstrap resources, and runner log groups. Phase 1 supports non-overlapping v1 and v2 configurations together without moving legacy state; later releases will translate v1, ship state migration, and only then remove the v1 interface. See the [experimental compute-provider refactor](modules/internal/compute-provider-refactor.md) and [multi-runner v2 migration roadmap](modules/public/multi-runner.md#multi-runner-v2-migration-roadmap). EC2 is the only active Terraform-managed provider; microVM, CodeBuild, and other provider modules are future work. Both modules are built on top of the same base modules. When using the multi-runner module you can deploy different runners with only one deployment. diff --git a/docs/compute-provider-refactor.md b/docs/modules/internal/compute-provider-refactor.md similarity index 95% rename from docs/compute-provider-refactor.md rename to docs/modules/internal/compute-provider-refactor.md index 69b117de05..0ddfead2d7 100644 --- a/docs/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -107,7 +107,7 @@ Tags follow the same ownership model. Module tags are defaults; shared Lambda, q Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and shared log-group tags. -Stable `runners_map` entries retain their flat output fields. Experimental entries expose common resources at the entry level and provider-specific resources under `provider.`. For EC2, launch-template and runner-log artifacts are under `runners_map["configuration"].provider.ec2`. +Stable `runners_map` entries retain their flat output fields. Experimental entries group common resources under `runner`, `scale_up`, `scale_down`, and `pool`; provider-specific resources remain under `provider.`. For example, the common runner role is available at `runners_map["configuration"].runner.role`, while EC2 launch-template and runner-log artifacts are under `runners_map["configuration"].provider.ec2`. The `pool` value is null when no pool configuration is supplied. ## Plan-time ownership wrappers diff --git a/mkdocs.yaml b/mkdocs.yaml index 7ebd445bfa..6ec2922a2c 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -65,7 +65,7 @@ nav: - Lambda Downloader: modules/public/download-lambda.md - Setup IAM permissions: modules/public/setup-iam-permissions.md - Submodules (internal): - - Compute provider refactor (experimental): compute-provider-refactor.md + - Compute provider refactor (experimental): modules/internal/compute-provider-refactor.md - Runners: modules/internal/runners.md - Syncer: modules/internal/runner-binaries-syncer.md - SSM: modules/internal/ssm.md diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 8d116f410f..e910b9964f 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -8,7 +8,7 @@ The module takes a configuration as input containing a matcher for the labels. T ## Provider boundary -See [Experimental compute-provider refactor](https://github-aws-runners.github.io/terraform-aws-github-runner/compute-provider-refactor/) for the motivation, ownership contract, opt-in flow, state guarantees, and migration phases. +See [Experimental compute-provider refactor](https://github-aws-runners.github.io/terraform-aws-github-runner/modules/internal/compute-provider-refactor/) for the motivation, ownership contract, opt-in flow, state guarantees, and migration phases. The multi-runner module owns provider-neutral runner-configuration normalization, queues, shared runner-binary discovery, and webhook routing. Stable `multi_runner_config` entries continue to use the existing `modules/runners` module at their historical `module.runners["configuration"]` addresses. @@ -22,7 +22,7 @@ The two input maps can be used in the same module instance during phase 1, provi For v2 runner configurations, top-level module `tags` are merged with configuration `tags`. Shared `lambda.tags`, `queue.tags`, and `observability.logs.tags` are then merged with component tags such as `runner.tags`, `scale_up.tags`, `scale_down.tags`, `pool.tags`, `job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags also apply to the configuration build queue and dead-letter queue owned by multi-runner. Stable v1 configurations keep their existing tag behavior unchanged. -Stable v1 entries in `runners_map` retain their existing flat output shape. Experimental v2 entries keep common control-plane Lambda and runner-role outputs at the runner-configuration level, while compute-provider resources are available only under `provider`. For EC2 configurations, use `runners_map["configuration"].provider.ec2.launch_template`, `.runners_log_groups`, and `.logfiles`; use `runners_map["configuration"].role_runner` for the common runner role. The corresponding flat EC2 attributes are intentionally not duplicated in v2 entries. +Stable v1 entries in `runners_map` retain their existing flat output shape. Experimental v2 entries group common resources under `runner`, `scale_up`, `scale_down`, and `pool`, while compute-provider resources are available only under `provider`. Use `runners_map["configuration"].runner.role` for the common runner role and `runners_map["configuration"].scale_up.lambda`, `.log_group`, and `.role` for the scale-up resources. The same resource shape is used for `scale_down` and an enabled `pool`; `pool` is null when it is disabled. For EC2 configurations, launch-template and runner-log resources remain under `runners_map["configuration"].provider.ec2`. The stable flat attributes are intentionally not duplicated in v2 entries. ### Multi-runner v2 migration roadmap diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index 2ae14c555d..99b4618350 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -21,17 +21,11 @@ output "runners_map" { } }, { for runner_key, runner in module.runner_stacks : runner_key => { - provider = runner.provider - lambda_up = runner.lambda_scale_up - lambda_up_log_group = runner.lambda_scale_up_log_group - lambda_down = runner.lambda_scale_down - lambda_down_log_group = runner.lambda_scale_down_log_group - lambda_pool = runner.lambda_pool - lambda_pool_log_group = runner.lambda_pool_log_group - role_runner = runner.role_runner - role_scale_up = runner.role_scale_up - role_scale_down = runner.role_scale_down - role_pool = runner.role_pool + runner = runner.runner + scale_up = runner.scale_up + scale_down = runner.scale_down + pool = runner.pool + provider = runner.provider } } ) diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 860b9af956..9291e96516 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -211,19 +211,23 @@ run "experimental_v2_routes_through_provider_stack" { condition = toset(keys(output.runners_map["linux"])) == toset( [ "provider", - "lambda_up", - "lambda_up_log_group", - "lambda_down", - "lambda_down_log_group", - "lambda_pool", - "lambda_pool_log_group", - "role_runner", - "role_scale_up", - "role_scale_down", - "role_pool", + "runner", + "scale_up", + "scale_down", + "pool", ] ) - error_message = "Experimental v2 runners_map entries must expose provider resources only through the nested provider object." + error_message = "Experimental v2 runners_map entries must group common and provider resources by owner." + } + + assert { + condition = ( + toset(keys(output.runners_map["linux"].runner)) == toset(["role"]) + && toset(keys(output.runners_map["linux"].scale_up)) == toset(["lambda", "log_group", "role"]) + && toset(keys(output.runners_map["linux"].scale_down)) == toset(["lambda", "log_group", "role"]) + && toset(keys(output.runners_map["linux"].pool)) == toset(["lambda", "log_group", "role"]) + ) + error_message = "Experimental v2 common resources must use the nested runner, scale-up, scale-down, and pool contracts." } assert { @@ -241,12 +245,12 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( !contains(keys(output.runners_map["linux"]), "launch_template_name") - && contains(keys(output.runners_map["linux"]), "role_runner") + && output.runners_map["linux"].runner.role != null && !contains(keys(output.runners_map["linux"].provider.ec2), "role_runner") && !contains(keys(output.runners_map["linux"]), "runners_log_groups") && !contains(keys(output.runners_map["linux"]), "logfiles") ) - error_message = "Experimental v2 must expose the common runner role at runner-configuration level without duplicating EC2 resources." + error_message = "Experimental v2 must expose the common runner role under runner without duplicating EC2 resources." } assert { @@ -380,7 +384,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].lambda_scale_up.tags == tomap({ + condition = module.runner_stacks["tagged"].scale_up.lambda.tags == tomap({ GlobalOnly = "global" RunnerConfigOnly = "runner-config" SharedLambdaOnly = "shared-lambda" @@ -392,7 +396,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].lambda_scale_up_log_group.tags == tomap({ + condition = module.runner_stacks["tagged"].scale_up.log_group.tags == tomap({ GlobalOnly = "global" RunnerConfigOnly = "runner-config" SharedLogOnly = "shared-log" @@ -403,7 +407,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].role_scale_up.tags == tomap({ + condition = module.runner_stacks["tagged"].scale_up.role.tags == tomap({ GlobalOnly = "global" RunnerConfigOnly = "runner-config" ScaleUpOnly = "scale-up" @@ -413,7 +417,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].role_runner[0].tags == tomap({ + condition = module.runner_stacks["tagged"].runner.role.tags == tomap({ GlobalOnly = "global" RunnerConfigOnly = "runner-config" RunnerOnly = "runner" @@ -423,7 +427,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].lambda_scale_down.tags == tomap({ + condition = module.runner_stacks["tagged"].scale_down.lambda.tags == tomap({ GlobalOnly = "global" RunnerConfigOnly = "runner-config" SharedLambdaOnly = "shared-lambda" @@ -435,7 +439,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].lambda_scale_down_log_group.tags == tomap({ + condition = module.runner_stacks["tagged"].scale_down.log_group.tags == tomap({ GlobalOnly = "global" RunnerConfigOnly = "runner-config" SharedLogOnly = "shared-log" @@ -444,6 +448,11 @@ run "experimental_v2_layers_shared_and_component_tags" { }) error_message = "Scale-down log-group tags must preserve shared log tags before applying scale-down component tags." } + + assert { + condition = output.runners_map["tagged"].pool == null + error_message = "Experimental v2 must expose a null pool object when no pool configuration is supplied." + } } run "stable_v1_and_experimental_v2_coexist" { diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index 85fed625af..4fa3ae0198 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -160,15 +160,9 @@ yarn run dist | Name | Description | |------|-------------| -| [lambda\_pool](#output\_lambda\_pool) | n/a | -| [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a | -| [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a | -| [lambda\_scale\_down\_log\_group](#output\_lambda\_scale\_down\_log\_group) | n/a | -| [lambda\_scale\_up](#output\_lambda\_scale\_up) | n/a | -| [lambda\_scale\_up\_log\_group](#output\_lambda\_scale\_up\_log\_group) | n/a | +| [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Selected compute provider type and its provider-specific resources. | -| [role\_pool](#output\_role\_pool) | n/a | -| [role\_runner](#output\_role\_runner) | Runner IAM role created by the common stack. Empty when an external runner role is used. | -| [role\_scale\_down](#output\_role\_scale\_down) | n/a | -| [role\_scale\_up](#output\_role\_scale\_up) | n/a | +| [runner](#output\_runner) | Common runner resources. The role is null when an external runner role is used. | +| [scale\_down](#output\_scale\_down) | Scale-down control-plane resources. | +| [scale\_up](#output\_scale\_up) | Scale-up control-plane resources. | diff --git a/modules/runner-stack/outputs.tf b/modules/runner-stack/outputs.tf index ffb6ba287e..89f3031cf2 100644 --- a/modules/runner-stack/outputs.tf +++ b/modules/runner-stack/outputs.tf @@ -1,48 +1,37 @@ -output "provider" { - description = "Selected compute provider type and its provider-specific resources." +output "runner" { + description = "Common runner resources. The role is null when an external runner role is used." value = { - type = local.provider.type - ec2 = module.ec2[0].resources + role = one(aws_iam_role.runner[*]) } } -output "lambda_scale_up" { - value = aws_lambda_function.scale_up -} - -output "lambda_scale_up_log_group" { - value = aws_cloudwatch_log_group.scale_up -} - -output "role_scale_up" { - value = aws_iam_role.scale_up -} - -output "lambda_scale_down" { - value = aws_lambda_function.scale_down -} - -output "lambda_scale_down_log_group" { - value = aws_cloudwatch_log_group.scale_down -} - -output "role_scale_down" { - value = aws_iam_role.scale_down -} - -output "lambda_pool" { - value = try(module.pool[0].lambda, null) +output "scale_up" { + description = "Scale-up control-plane resources." + value = { + lambda = aws_lambda_function.scale_up + log_group = aws_cloudwatch_log_group.scale_up + role = aws_iam_role.scale_up + } } -output "lambda_pool_log_group" { - value = try(module.pool[0].lambda_log_group, null) +output "scale_down" { + description = "Scale-down control-plane resources." + value = { + lambda = aws_lambda_function.scale_down + log_group = aws_cloudwatch_log_group.scale_down + role = aws_iam_role.scale_down + } } -output "role_pool" { - value = try(module.pool[0].role_pool, null) +output "pool" { + description = "Scheduled pool resources. Null when no pool configuration is supplied." + value = one(module.pool[*].pool) } -output "role_runner" { - description = "Runner IAM role created by the common stack. Empty when an external runner role is used." - value = aws_iam_role.runner +output "provider" { + description = "Selected compute provider type and its provider-specific resources." + value = { + type = local.provider.type + ec2 = one(module.ec2[*].resources) + } } diff --git a/modules/runner-stack/pool/README.md b/modules/runner-stack/pool/README.md index f45d83ad00..64553579ff 100644 --- a/modules/runner-stack/pool/README.md +++ b/modules/runner-stack/pool/README.md @@ -60,7 +60,5 @@ No modules. | Name | Description | |------|-------------| -| [lambda](#output\_lambda) | n/a | -| [lambda\_log\_group](#output\_lambda\_log\_group) | n/a | -| [role\_pool](#output\_role\_pool) | n/a | +| [pool](#output\_pool) | Scheduled pool Lambda resources. | diff --git a/modules/runner-stack/pool/outputs.tf b/modules/runner-stack/pool/outputs.tf index 7a4d70ca9f..cfc429ecce 100644 --- a/modules/runner-stack/pool/outputs.tf +++ b/modules/runner-stack/pool/outputs.tf @@ -1,11 +1,8 @@ -output "role_pool" { - value = aws_iam_role.pool -} - -output "lambda" { - value = aws_lambda_function.pool -} - -output "lambda_log_group" { - value = aws_cloudwatch_log_group.pool +output "pool" { + description = "Scheduled pool Lambda resources." + value = { + lambda = aws_lambda_function.pool + log_group = aws_cloudwatch_log_group.pool + role = aws_iam_role.pool + } } diff --git a/modules/runner-stack/pool/tests/provider.tftest.hcl b/modules/runner-stack/pool/tests/provider.tftest.hcl index fc42af2780..b352a03c26 100644 --- a/modules/runner-stack/pool/tests/provider.tftest.hcl +++ b/modules/runner-stack/pool/tests/provider.tftest.hcl @@ -93,6 +93,11 @@ variables { run "provider_supplies_only_compute_specific_pool_configuration" { command = plan + assert { + condition = toset(keys(output.pool)) == toset(["lambda", "log_group", "role"]) + error_message = "The pool module must expose its resources through one nested output." + } + assert { condition = aws_lambda_function.pool.environment[0].variables["RUNNER_OWNER"] == "example" error_message = "The pool module must continue to assemble common runner environment variables." diff --git a/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf b/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf index e0265a21b1..bab40b38d1 100644 --- a/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf +++ b/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -171,9 +171,9 @@ module "generated_policy" { } output "external_role_runner_count" { - value = length(module.external_iam.role_runner) + value = module.external_iam.runner.role == null ? 0 : 1 } output "generated_policy_role_runner_count" { - value = length(module.generated_policy.role_runner) + value = module.generated_policy.runner.role == null ? 0 : 1 } diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl index d1ddc99156..26718a9f2a 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -109,10 +109,18 @@ run "plan_with_pool_enabled" { } assert { - condition = length(aws_iam_role.runner) == 1 && length(output.role_runner) == 1 + condition = length(aws_iam_role.runner) == 1 && output.runner.role != null error_message = "The common runner stack must create and expose the runner role." } + assert { + condition = ( + output.pool != null + && toset(keys(output.pool)) == toset(["lambda", "log_group", "role"]) + ) + error_message = "An enabled pool must expose its Lambda, log group, and role through the nested pool output." + } + assert { condition = length(jsondecode(aws_lambda_function.scale_up.environment[0].variables["SSM_PARAMETER_STORE_TAGS"])) == 0 error_message = "Runtime Parameter Store tags must remain empty when no module or SSM tags are configured; EC2 bootstrap tags must not leak into them." @@ -178,8 +186,8 @@ run "external_runner_role_is_not_managed_by_common" { } assert { - condition = length(output.role_runner) == 0 - error_message = "The role_runner output must be empty when an external role is selected." + condition = output.runner.role == null + error_message = "The nested runner role output must be null when an external role is selected." } diff --git a/modules/runner-stack/tests/tags.tftest.hcl b/modules/runner-stack/tests/tags.tftest.hcl index 0702feff0c..4ecc72a971 100644 --- a/modules/runner-stack/tests/tags.tftest.hcl +++ b/modules/runner-stack/tests/tags.tftest.hcl @@ -267,17 +267,17 @@ run "layered_component_tags" { } assert { - condition = module.pool[0].lambda.tags == tomap({ + condition = module.pool[0].pool.lambda.tags == tomap({ precedence = "pool" module = "yes" lambda = "yes" pool = "yes" - }) && module.pool[0].lambda_log_group.tags == tomap({ + }) && module.pool[0].pool.log_group.tags == tomap({ precedence = "pool" module = "yes" log = "yes" pool = "yes" - }) && module.pool[0].role_pool.tags == tomap({ + }) && module.pool[0].pool.role.tags == tomap({ precedence = "pool" module = "yes" pool = "yes" From 0b1e8203efffa34712fc6123458ee505279d1bd7 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 19:35:00 +0200 Subject: [PATCH 25/27] refactor(runner-stack): isolate control-plane components --- .github/workflows/terraform.yml | 4 + docs/index.md | 2 +- .../internal/compute-provider-refactor.md | 19 +- modules/lambda/README.md | 2 +- modules/lambda/main.tf | 27 +- modules/lambda/variables.tf | 108 +++--- modules/multi-runner/README.md | 21 +- modules/multi-runner/outputs.tf | 59 ++-- .../tests/provider-routing.tftest.hcl | 74 +++-- modules/runner-stack/README.md | 67 +--- modules/runner-stack/job-retry/README.md | 27 +- .../runner-stack/job-retry/iam-policies.tf | 49 +++ modules/runner-stack/job-retry/job-retry.tf | 124 ++++++- modules/runner-stack/job-retry/outputs.tf | 10 +- .../job-retry/tests/job-retry.tftest.hcl | 95 +++++- modules/runner-stack/job-retry/variables.tf | 23 ++ modules/runner-stack/outputs.tf | 12 +- modules/runner-stack/scale-down.tf | 122 ------- modules/runner-stack/scale-runners.tf | 86 +++++ modules/runner-stack/scale-runners/README.md | 77 +++++ .../scale-runners/common-config.tf | 20 ++ .../lambda-iam-policies.tf} | 16 +- modules/runner-stack/scale-runners/outputs.tf | 17 + .../scale-down-iam-policies.tf | 9 +- .../runner-stack/scale-runners/scale-down.tf | 114 +++++++ .../scale-up-iam-policies.tf | 17 +- .../runner-stack/scale-runners/scale-up.tf | 145 ++++++++ .../tests/scale-runners.tftest.hcl | 312 ++++++++++++++++++ .../runner-stack/scale-runners/variables.tf | 231 +++++++++++++ .../runner-stack/scale-runners/versions.tf | 10 + modules/runner-stack/scale-up.tf | 152 --------- .../ssm-housekeeper-iam-policies.tf | 24 -- modules/runner-stack/ssm-housekeeper.tf | 157 +++------ .../runner-stack/ssm-housekeeper/README.md | 57 ++++ .../ssm-housekeeper/iam-policies.tf | 48 +++ .../runner-stack/ssm-housekeeper/outputs.tf | 8 + .../ssm-housekeeper/ssm-housekeeper.tf | 119 +++++++ .../tests/ssm-housekeeper.tftest.hcl | 240 ++++++++++++++ .../runner-stack/ssm-housekeeper/variables.tf | 88 +++++ .../runner-stack/ssm-housekeeper/versions.tf | 10 + modules/runner-stack/tests/pool.tftest.hcl | 19 +- modules/runner-stack/tests/tags.tftest.hcl | 33 +- 42 files changed, 2160 insertions(+), 694 deletions(-) delete mode 100644 modules/runner-stack/scale-down.tf create mode 100644 modules/runner-stack/scale-runners.tf create mode 100644 modules/runner-stack/scale-runners/README.md create mode 100644 modules/runner-stack/scale-runners/common-config.tf rename modules/runner-stack/{lambda-common-iam-policies.tf => scale-runners/lambda-iam-policies.tf} (57%) create mode 100644 modules/runner-stack/scale-runners/outputs.tf rename modules/runner-stack/{ => scale-runners}/scale-down-iam-policies.tf (74%) create mode 100644 modules/runner-stack/scale-runners/scale-down.tf rename modules/runner-stack/{ => scale-runners}/scale-up-iam-policies.tf (68%) create mode 100644 modules/runner-stack/scale-runners/scale-up.tf create mode 100644 modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl create mode 100644 modules/runner-stack/scale-runners/variables.tf create mode 100644 modules/runner-stack/scale-runners/versions.tf delete mode 100644 modules/runner-stack/scale-up.tf delete mode 100644 modules/runner-stack/ssm-housekeeper-iam-policies.tf create mode 100644 modules/runner-stack/ssm-housekeeper/README.md create mode 100644 modules/runner-stack/ssm-housekeeper/iam-policies.tf create mode 100644 modules/runner-stack/ssm-housekeeper/outputs.tf create mode 100644 modules/runner-stack/ssm-housekeeper/ssm-housekeeper.tf create mode 100644 modules/runner-stack/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl create mode 100644 modules/runner-stack/ssm-housekeeper/variables.tf create mode 100644 modules/runner-stack/ssm-housekeeper/versions.tf diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 0d497e8a97..8a25d1a57a 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -89,6 +89,8 @@ jobs: "compute-providers/ec2/runner-role", "runner-binaries-syncer", "runner-stack", + "runner-stack/scale-runners", + "runner-stack/ssm-housekeeper", "runners", "setup-iam-permissions", "ssm", @@ -220,6 +222,8 @@ jobs: - modules/multi-runner - modules/runner-stack - modules/runner-stack/pool + - modules/runner-stack/scale-runners + - modules/runner-stack/ssm-housekeeper - modules/compute-providers/ec2 - modules/compute-providers/ec2/runner-role defaults: diff --git a/docs/index.md b/docs/index.md index 002b37c692..f54cc07eb5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,7 +101,7 @@ Besides these permissions, the lambdas also need permission to CloudWatch (for l ## Terraform main modules -Currently we support two main modules. The existing `runners` module remains the stable EC2 implementation, and the `multi-runner` module creates multiple runner configurations in one deployment. Stable `multi_runner_config` entries continue to use the unchanged `runners` module. Entries under `experimental.multi_runner_config_v2` use the new provider-oriented `runner-stack`, where the common control plane owns scale-up, scale-down, pool, retry, housekeeper, Lambda roles, and the runner role and attachments. The EC2 provider supplies EC2-specific policy requirements and owns the instance profile, launch template, bootstrap resources, and runner log groups. Phase 1 supports non-overlapping v1 and v2 configurations together without moving legacy state; later releases will translate v1, ship state migration, and only then remove the v1 interface. See the [experimental compute-provider refactor](modules/internal/compute-provider-refactor.md) and [multi-runner v2 migration roadmap](modules/public/multi-runner.md#multi-runner-v2-migration-roadmap). EC2 is the only active Terraform-managed provider; microVM, CodeBuild, and other provider modules are future work. +Currently we support two main modules. The existing `runners` module remains the stable EC2 implementation, and the `multi-runner` module creates multiple runner configurations in one deployment. Stable `multi_runner_config` entries continue to use the unchanged `runners` module. Entries under `experimental.multi_runner_config_v2` use the new provider-oriented `runner-stack`. That stack coordinates internal provider-neutral modules for scale-up and scale-down, pool, retry, and SSM housekeeping, and owns the common runner role and attachments. The EC2 provider supplies EC2-specific policy requirements and owns the instance profile, launch template, bootstrap resources, and runner log groups. These child modules are implementation details of the experimental stack and are not standalone public entry points. Phase 1 supports non-overlapping v1 and v2 configurations together without moving legacy state; later releases will translate v1, ship state migration, and only then remove the v1 interface. See the [experimental compute-provider refactor](modules/internal/compute-provider-refactor.md) and [multi-runner v2 migration roadmap](modules/public/multi-runner.md#multi-runner-v2-migration-roadmap). EC2 is the only active Terraform-managed provider; microVM, CodeBuild, and other provider modules are future work. Both modules are built on top of the same base modules. When using the multi-runner module you can deploy different runners with only one deployment. diff --git a/docs/modules/internal/compute-provider-refactor.md b/docs/modules/internal/compute-provider-refactor.md index 0ddfead2d7..329a0abfda 100644 --- a/docs/modules/internal/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -12,16 +12,22 @@ The refactor introduces a provider boundary so a future microVM or other backend ## Ownership model -The implementation is split into three layers: +The implementation is split into orchestration, provider-neutral control-plane components, and compute-provider implementations: | Layer | Owns | | --- | --- | | `multi-runner` | Stable-to-canonical normalization, configuration keys, build queues, webhook matching, and runner-binary discovery. | -| `runner-stack` | Scale-up, scale-down, pool, job retry, SSM housekeeper, common Lambda roles and policies, the runner role, and provider dispatch. | +| `runner-stack` | Provider dispatch, internal component wiring, shared runner configuration in SSM, and the common runner role and policy attachments. | +| `runner-stack/scale-runners` | Provider-neutral scale-up and scale-down Lambdas, schedules and queue integration, and their execution roles and policies. | +| `runner-stack/pool` | Optional scheduled runner-pool resources and their Lambda and IAM wiring. | +| `runner-stack/job-retry` | Optional queued-job retry resources and their Lambda and IAM wiring. | +| `runner-stack/ssm-housekeeper` | Parameter Store cleanup Lambda, schedule, logging, and IAM resources. | | `compute-providers/` | Provider-specific resources, runner-role policy requirements, and the IAM and environment-variable fragments consumed by the common control plane. | The EC2 provider currently owns the instance profile, launch template, security group, AMI and bootstrap parameters, runner log groups, EC2 policy statements, and EC2 Lambda environment variables. EC2 is the only implemented Terraform compute provider today. +The modules below `runner-stack` are internal implementation boundaries, not standalone public modules. Callers opt into the experimental interface through `experimental.multi_runner_config_v2`; `multi-runner` calls `runner-stack`, which composes the internal modules. Their direct input and output contracts may change while v2 remains experimental. + `runner-stack` passes the canonical `compute_provider.ec2` configuration to the EC2 module as one nested `config` object. It also passes the provider-neutral `runner`, `github`, `ssm`, and `observability` objects without expanding them back into prefixed scalar inputs. The EC2 runner-role policy module consumes the same provider `config` and shared `ssm` boundaries. This keeps ownership visible at every module boundary and gives future compute providers an equivalent contract to implement. The common stack creates or selects the runner IAM role. A provider supplies the trust policy, inline policy documents, and optional managed-policy requirements; the common stack attaches them. This keeps role ownership provider-neutral while allowing each compute provider to define its permissions. @@ -37,7 +43,13 @@ flowchart TD Normalize --> Shared["Queues, webhook matching, binary discovery"] Stable --> Legacy["module.runners[configuration]"] Experimental --> Stack["module.runner_stacks[configuration]"] + Stack --> Scaling["runner-stack/scale-runners"] + Stack --> Pool["runner-stack/pool"] + Stack --> Retry["runner-stack/job-retry"] + Stack --> Housekeeper["runner-stack/ssm-housekeeper"] Stack --> Provider["compute-providers/ec2"] + Provider --> Scaling + Provider --> Pool ``` Stable input is translated once into the canonical internal shape so shared resources can consume one representation. That translation does not change stable runner dispatch: @@ -46,6 +58,7 @@ Stable input is translated once into the canonical internal shape so shared reso - The stable module call receives the original v1 values for compatibility-sensitive inputs. - Stable queue tagging and the flat `runners_map` output remain unchanged. - A key present in `experimental.multi_runner_config_v2` calls `modules/runner-stack` at `module.runner_stacks["configuration"]`. +- Experimental resources are exposed separately through the nested `runners_map_v2` output. - Duplicate keys are rejected instead of silently changing a module address or output shape. No state move is included in phase 1. Moving an existing key from the stable map to the experimental map changes its implementation address and must wait for the documented state-migration phase. @@ -107,7 +120,7 @@ Tags follow the same ownership model. Module tags are defaults; shared Lambda, q Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and shared log-group tags. -Stable `runners_map` entries retain their flat output fields. Experimental entries group common resources under `runner`, `scale_up`, `scale_down`, and `pool`; provider-specific resources remain under `provider.`. For example, the common runner role is available at `runners_map["configuration"].runner.role`, while EC2 launch-template and runner-log artifacts are under `runners_map["configuration"].provider.ec2`. The `pool` value is null when no pool configuration is supplied. +Stable entries remain exclusively in `runners_map` and retain their flat output fields. Experimental entries are exposed exclusively through `runners_map_v2`; common resources are grouped under `runner`, `scale_up`, `scale_down`, and `pool`, while provider-specific resources remain under `provider.`. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, while EC2 launch-template and runner-log artifacts are under `runners_map_v2["configuration"].provider.ec2`. The `pool` value is null when no pool configuration is supplied. Keeping the maps separate prevents consumers from having to handle mixed entry schemas when v1 and v2 coexist. ## Plan-time ownership wrappers diff --git a/modules/lambda/README.md b/modules/lambda/README.md index 279ff52376..19e9c2a072 100644 --- a/modules/lambda/README.md +++ b/modules/lambda/README.md @@ -39,7 +39,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [lambda](#input\_lambda) | Configuration for the lambda function.

- `aws_partition`: Partition for the base arn if not 'aws'
- `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
- `environment_variables`: Additional environment variables for the Lambda function.
- `handler`: The entrypoint for the lambda.
- `principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
- `principals[*].type`: IAM principal type, such as `Service` or `AWS`.
- `principals[*].identifiers`: IAM principal identifiers for the selected principal type.
- `lambda_tags`: Tags added specifically to the Lambda function. These override `tags` values with the same key.
- `log_group_tags`: Tags added specifically to the Lambda log group. These override `tags` values with the same key.
- `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
- `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
- `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.
- `log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`.
- `memory_size`: Memory size limit in MB of the lambda.
- `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
- `metrics_namespace`: Namespace for the metrics emitted by the lambda.
- `name`: The name of the lambda function.
- `prefix`: The prefix used for naming resources.
- `role_path`: The path that will be added to the role, if not set the environment name will be used.
- `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
- `runtime`: AWS Lambda runtime.
- `s3_bucket`: S3 bucket containing the Lambda deployment package. This is an alternative to `zip`.
- `s3_key`: Object key of the Lambda deployment package. Required when `s3_bucket` is set.
- `s3_object_version`: Optional version of the Lambda deployment-package object.
- `security_group_ids`: List of security group IDs associated with the Lambda function.
- `subnet_ids`: Subnets used for the Lambda VPC configuration.
- `tags`: Base tags added to the Lambda function, log group, and execution role. `lambda_tags` and `log_group_tags` override matching keys for their respective resources.
- `timeout`: Time out of the lambda in seconds.
- `tracing_config`: Configuration for lambda tracing.
- `tracing_config.mode`: AWS X-Ray tracing mode. A null value disables tracing.
- `tracing_config.capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests.
- `tracing_config.capture_error`: Whether Powertools tracing captures errors as tracing metadata.
- `zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, "aws")
architecture = optional(string, "arm64")
environment_variables = optional(map(string), {})
handler = string
lambda_tags = optional(map(string), {})
log_group_tags = optional(map(string), {})
log_level = optional(string, "info")
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, 180)
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, null)
metrics_namespace = optional(string, "GitHub Runners")
name = string
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, "nodejs24.x")
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
tags = optional(map(string), {})
timeout = optional(number, 60)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | +| [lambda](#input\_lambda) | Configuration for the lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`handler`: The entrypoint for the lambda.
`principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`.
`memory_size`: Memory size limit in MB of the lambda.
`metrics_namespace`: Namespace for the metrics emitted by the lambda.
`name`: The name of the lambda function.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`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.
`security_group_ids`: List of security group IDs associated with the Lambda function.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, "aws")
architecture = optional(string, "arm64")
environment_variables = optional(map(string), {})
handler = string
lambda_tags = optional(map(string), {})
log_level = optional(string, "info")
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, 180)
memory_size = optional(number, 256)
metrics_namespace = optional(string, "GitHub Runners")
name = string
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, "nodejs24.x")
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
tags = optional(map(string), {})
timeout = optional(number, 60)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs diff --git a/modules/lambda/main.tf b/modules/lambda/main.tf index 797e234028..7cc3094f28 100644 --- a/modules/lambda/main.tf +++ b/modules/lambda/main.tf @@ -17,19 +17,18 @@ locals { } resource "aws_lambda_function" "main" { - s3_bucket = var.lambda.s3_bucket != null ? var.lambda.s3_bucket : null - s3_key = var.lambda.s3_key != null ? var.lambda.s3_key : null - s3_object_version = var.lambda.s3_object_version != null ? var.lambda.s3_object_version : null - filename = var.lambda.s3_bucket == null ? var.lambda.zip : null - source_code_hash = var.lambda.s3_bucket == null ? filebase64sha256(var.lambda.zip) : null - function_name = "${var.lambda.prefix}-${var.lambda.name}" - role = aws_iam_role.main.arn - handler = var.lambda.handler - runtime = var.lambda.runtime - timeout = var.lambda.timeout - memory_size = var.lambda.memory_size - reserved_concurrent_executions = var.lambda.reserved_concurrent_executions - architectures = [var.lambda.architecture] + s3_bucket = var.lambda.s3_bucket != null ? var.lambda.s3_bucket : null + s3_key = var.lambda.s3_key != null ? var.lambda.s3_key : null + s3_object_version = var.lambda.s3_object_version != null ? var.lambda.s3_object_version : null + filename = var.lambda.s3_bucket == null ? var.lambda.zip : null + source_code_hash = var.lambda.s3_bucket == null ? filebase64sha256(var.lambda.zip) : null + function_name = "${var.lambda.prefix}-${var.lambda.name}" + role = aws_iam_role.main.arn + handler = var.lambda.handler + runtime = var.lambda.runtime + timeout = var.lambda.timeout + memory_size = var.lambda.memory_size + architectures = [var.lambda.architecture] environment { variables = local.environment_variable @@ -58,7 +57,7 @@ resource "aws_cloudwatch_log_group" "main" { retention_in_days = var.lambda.logging_retention_in_days kms_key_id = var.lambda.logging_kms_key_id log_group_class = var.lambda.log_class - tags = merge(var.lambda.tags, var.lambda.log_group_tags) + tags = var.lambda.tags } resource "aws_iam_role" "main" { diff --git a/modules/lambda/variables.tf b/modules/lambda/variables.tf index 61d34f4ca1..a6e27168fa 100644 --- a/modules/lambda/variables.tf +++ b/modules/lambda/variables.tf @@ -2,56 +2,47 @@ variable "lambda" { description = <<-EOF Configuration for the lambda function. - - `aws_partition`: Partition for the base arn if not 'aws' - - `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86_64' functions. - - `environment_variables`: Additional environment variables for the Lambda function. - - `handler`: The entrypoint for the lambda. - - `principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing. - - `principals[*].type`: IAM principal type, such as `Service` or `AWS`. - - `principals[*].identifiers`: IAM principal identifiers for the selected principal type. - - `lambda_tags`: Tags added specifically to the Lambda function. These override `tags` values with the same key. - - `log_group_tags`: Tags added specifically to the Lambda log group. These override `tags` values with the same key. - - `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. - - `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with - - `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. - - `log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. - - `memory_size`: Memory size limit in MB of the lambda. - - `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation. - - `metrics_namespace`: Namespace for the metrics emitted by the lambda. - - `name`: The name of the lambda function. - - `prefix`: The prefix used for naming resources. - - `role_path`: The path that will be added to the role, if not set the environment name will be used. - - `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda. - - `runtime`: AWS Lambda runtime. - - `s3_bucket`: S3 bucket containing the Lambda deployment package. This is an alternative to `zip`. - - `s3_key`: Object key of the Lambda deployment package. Required when `s3_bucket` is set. - - `s3_object_version`: Optional version of the Lambda deployment-package object. - - `security_group_ids`: List of security group IDs associated with the Lambda function. - - `subnet_ids`: Subnets used for the Lambda VPC configuration. - - `tags`: Base tags added to the Lambda function, log group, and execution role. `lambda_tags` and `log_group_tags` override matching keys for their respective resources. - - `timeout`: Time out of the lambda in seconds. - - `tracing_config`: Configuration for lambda tracing. - - `tracing_config.mode`: AWS X-Ray tracing mode. A null value disables tracing. - - `tracing_config.capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests. - - `tracing_config.capture_error`: Whether Powertools tracing captures errors as tracing metadata. - - `zip`: File location of the lambda zip file. + `aws_partition`: Partition for the base arn if not 'aws' + `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86_64' functions. + `environment_variables`: Environment variables for the lambda. + `handler`: The entrypoint for the lambda. + `principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing. + `lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment. + `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. + `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with + `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. + `log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. + `memory_size`: Memory size limit in MB of the lambda. + `metrics_namespace`: Namespace for the metrics emitted by the lambda. + `name`: The name of the lambda function. + `prefix`: The prefix used for naming resources. + `role_path`: The path that will be added to the role, if not set the environment name will be used. + `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda. + `runtime`: AWS Lambda runtime. + `s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly. + `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. + `security_group_ids`: List of security group IDs associated with the Lambda function. + `subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. + `tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment. + `timeout`: Time out of the lambda in seconds. + `tracing_config`: Configuration for lambda tracing. + `zip`: File location of the lambda zip file. EOF type = object({ - aws_partition = optional(string, "aws") - architecture = optional(string, "arm64") - environment_variables = optional(map(string), {}) - handler = string - lambda_tags = optional(map(string), {}) - log_group_tags = optional(map(string), {}) - log_level = optional(string, "info") - log_class = optional(string, "STANDARD") - logging_kms_key_id = optional(string, null) - logging_retention_in_days = optional(number, 180) - memory_size = optional(number, 256) - reserved_concurrent_executions = optional(number, null) - metrics_namespace = optional(string, "GitHub Runners") - name = string - prefix = optional(string, null) + aws_partition = optional(string, "aws") + architecture = optional(string, "arm64") + environment_variables = optional(map(string), {}) + handler = string + lambda_tags = optional(map(string), {}) + log_level = optional(string, "info") + log_class = optional(string, "STANDARD") + logging_kms_key_id = optional(string, null) + logging_retention_in_days = optional(number, 180) + memory_size = optional(number, 256) + metrics_namespace = optional(string, "GitHub Runners") + name = string + prefix = optional(string, null) principals = optional(list(object({ type = string identifiers = list(string) @@ -76,23 +67,20 @@ variable "lambda" { validation { condition = var.lambda.zip != null || (var.lambda.s3_bucket != null && var.lambda.s3_key != null) - error_message = "Either lambda.zip or both lambda.s3_bucket and lambda.s3_key must be provided." + error_message = "Either `lambda_zip` or `lambda_s3_bucket` and `lambda_s3_key` must be provided." } validation { condition = var.lambda.architecture == "arm64" || var.lambda.architecture == "x86_64" - error_message = "lambda.architecture must be arm64 or x86_64." + error_message = "`lambda_architecture` value is not valid, valid values are: `arm64` and `x86_64`." } validation { - condition = contains([ - "silly", - "trace", - "debug", - "info", - "warn", - "error", - "fatal", - ], var.lambda.log_level) - error_message = "lambda.log_level must be one of silly, trace, debug, info, warn, error, or fatal." + condition = anytrue([ + var.lambda.log_level == "debug", + var.lambda.log_level == "info", + var.lambda.log_level == "warn", + var.lambda.log_level == "error", + ]) + error_message = "`log_level` value not valid. Valid values are 'debug', 'info', 'warn', 'error'." } validation { diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index e910b9964f..1145f9d98e 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -12,7 +12,7 @@ See [Experimental compute-provider refactor](https://github-aws-runners.github.i The multi-runner module owns provider-neutral runner-configuration normalization, queues, shared runner-binary discovery, and webhook routing. Stable `multi_runner_config` entries continue to use the existing `modules/runners` module at their historical `module.runners["configuration"]` addresses. -Entries under `experimental.multi_runner_config_v2` opt into `modules/runner-stack` at `module.runner_stacks["configuration"]`. That stack owns common scale-up, scale-down, pool, retry, Lambda roles, and the runner role and attachments. It dispatches typed provider configuration through `compute_provider.type`; the EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. +Entries under `experimental.multi_runner_config_v2` opt into `modules/runner-stack` at `module.runner_stacks["configuration"]`. That stack coordinates internal provider-neutral modules for scale-up and scale-down, pool, job retry, and SSM housekeeping, and owns the common runner role and attachments. It dispatches typed provider configuration through `compute_provider.type`; the EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. The runner-stack child modules are implementation details rather than standalone public entry points. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. In v2, common runner-role configuration belongs under `runner.iam`; EC2's optional external instance-profile selection belongs under `compute_provider.ec2.instance_profile`. Provider policy documents are generated internally and attached by the common stack when it creates the role. An external role remains unmanaged and must already contain the required policies. @@ -22,7 +22,7 @@ The two input maps can be used in the same module instance during phase 1, provi For v2 runner configurations, top-level module `tags` are merged with configuration `tags`. Shared `lambda.tags`, `queue.tags`, and `observability.logs.tags` are then merged with component tags such as `runner.tags`, `scale_up.tags`, `scale_down.tags`, `pool.tags`, `job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags also apply to the configuration build queue and dead-letter queue owned by multi-runner. Stable v1 configurations keep their existing tag behavior unchanged. -Stable v1 entries in `runners_map` retain their existing flat output shape. Experimental v2 entries group common resources under `runner`, `scale_up`, `scale_down`, and `pool`, while compute-provider resources are available only under `provider`. Use `runners_map["configuration"].runner.role` for the common runner role and `runners_map["configuration"].scale_up.lambda`, `.log_group`, and `.role` for the scale-up resources. The same resource shape is used for `scale_down` and an enabled `pool`; `pool` is null when it is disabled. For EC2 configurations, launch-template and runner-log resources remain under `runners_map["configuration"].provider.ec2`. The stable flat attributes are intentionally not duplicated in v2 entries. +The output contracts are separated so a single map never contains two incompatible entry schemas. Stable v1 entries remain exclusively in `runners_map` with their existing flat shape. Experimental v2 entries are exposed exclusively through `runners_map_v2`, grouped under `runner`, `scale_up`, `scale_down`, `pool`, and `provider`. Use `runners_map_v2["configuration"].runner.role` for the common runner role and `runners_map_v2["configuration"].scale_up.lambda`, `.log_group`, and `.role` for the scale-up resources. The same resource shape is used for `scale_down` and an enabled `pool`; `pool` is null when it is disabled. For EC2 configurations, launch-template and runner-log resources remain under `runners_map_v2["configuration"].provider.ec2`. When only one input version is configured, the other output map is empty. ### Multi-runner v2 migration roadmap @@ -30,7 +30,7 @@ Here, v1 and v2 refer to `multi_runner_config` and `experimental.multi_runner_co #### Phase 1 — Add v2 alongside v1 (current) -Both input contracts are available in the same module release and can manage different runner configuration keys in one module instance. Existing `multi_runner_config` entries continue through the unchanged `modules/runners` implementation at `module.runners["configuration"]`, retaining their input contract, flat `runners_map` output, and Terraform addresses. Experimental `experimental.multi_runner_config_v2` entries use `module.runner_stacks["configuration"]` and the provider-oriented output shape. +Both input contracts are available in the same module release and can manage different runner configuration keys in one module instance. Existing `multi_runner_config` entries continue through the unchanged `modules/runners` implementation at `module.runners["configuration"]`, retaining their input contract, flat `runners_map` output, and Terraform addresses. Experimental `experimental.multi_runner_config_v2` entries use `module.runner_stacks["configuration"]` and the nested `runners_map_v2` output shape. Compatibility guarantee: upgrading without moving a runner configuration from `multi_runner_config` to `experimental.multi_runner_config_v2` requires no state migration and must not move or replace legacy runner resources. Moving an existing configuration key to v2 is deliberately deferred until phase 2 supplies the state mapping. @@ -121,7 +121,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -129,14 +129,14 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | | [random](#provider\_random) | ~> 3.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -148,7 +148,7 @@ module "multi-runner" { ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -159,7 +159,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | | [ami\_housekeeper\_lambda\_s3\_key](#input\_ami\_housekeeper\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | @@ -243,11 +243,12 @@ module "multi-runner" { ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | -| [runners\_map](#output\_runners\_map) | n/a | +| [runners\_map](#output\_runners\_map) | Stable v1 runner resources keyed by runner configuration. Entries retain the historical flat output shape. | +| [runners\_map\_v2](#output\_runners\_map\_v2) | Experimental v2 runner resources keyed by runner configuration and grouped by common or compute-provider ownership. | | [ssm\_parameters](#output\_ssm\_parameters) | n/a | | [webhook](#output\_webhook) | n/a | diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index 99b4618350..bae66faecf 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -1,34 +1,37 @@ output "runners_map" { - value = merge( - { for runner_key, runner in module.runners : runner_key => { - launch_template_name = runner.launch_template.name - launch_template_id = runner.launch_template.id - launch_template_version = runner.launch_template.latest_version - launch_template_ami_id = runner.launch_template.image_id - lambda_up = runner.lambda_scale_up - lambda_up_log_group = runner.lambda_scale_up_log_group - lambda_down = runner.lambda_scale_down - lambda_down_log_group = runner.lambda_scale_down_log_group - lambda_pool = runner.lambda_pool - lambda_pool_log_group = runner.lambda_pool_log_group - role_runner = runner.role_runner - role_scale_up = runner.role_scale_up - role_scale_down = runner.role_scale_down - role_pool = runner.role_pool - runners_log_groups = runner.runners_log_groups - logfiles = runner.logfiles - } - }, - { for runner_key, runner in module.runner_stacks : runner_key => { - runner = runner.runner - scale_up = runner.scale_up - scale_down = runner.scale_down - pool = runner.pool - provider = runner.provider - } + description = "Stable v1 runner resources keyed by runner configuration. Entries retain the historical flat output shape." + value = { for runner_key, runner in module.runners : runner_key => { + launch_template_name = runner.launch_template.name + launch_template_id = runner.launch_template.id + launch_template_version = runner.launch_template.latest_version + launch_template_ami_id = runner.launch_template.image_id + lambda_up = runner.lambda_scale_up + lambda_up_log_group = runner.lambda_scale_up_log_group + lambda_down = runner.lambda_scale_down + lambda_down_log_group = runner.lambda_scale_down_log_group + lambda_pool = runner.lambda_pool + lambda_pool_log_group = runner.lambda_pool_log_group + role_runner = runner.role_runner + role_scale_up = runner.role_scale_up + role_scale_down = runner.role_scale_down + role_pool = runner.role_pool + runners_log_groups = runner.runners_log_groups + logfiles = runner.logfiles } - ) + } +} + +output "runners_map_v2" { + description = "Experimental v2 runner resources keyed by runner configuration and grouped by common or compute-provider ownership." + value = { for runner_key, runner in module.runner_stacks : runner_key => { + runner = runner.runner + scale_up = runner.scale_up + scale_down = runner.scale_down + pool = runner.pool + provider = runner.provider + } + } } output "binaries_syncer_map" { diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 9291e96516..dfc72336ba 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -32,6 +32,15 @@ variables { syncer_lambda_s3_key = "runner-binaries-syncer.zip" } +run "empty_runner_configurations_return_empty_output_maps" { + command = plan + + assert { + condition = length(output.runners_map) == 0 && length(output.runners_map_v2) == 0 + error_message = "Stable and experimental runner outputs must both be empty when no runner configurations are supplied." + } +} + run "stable_v1_keeps_legacy_runner_module" { command = plan @@ -104,6 +113,11 @@ run "stable_v1_keeps_legacy_runner_module" { error_message = "Stable multi_runner_config must preserve the public runner map key." } + assert { + condition = length(output.runners_map_v2) == 0 + error_message = "Stable multi_runner_config must not add entries to the experimental runners_map_v2 output." + } + assert { condition = toset(keys(output.runners_map["linux"])) == toset( [ @@ -203,12 +217,17 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = keys(output.runners_map) == ["linux"] - error_message = "Experimental multi_runner_config_v2 must preserve the public runner map key." + condition = length(output.runners_map) == 0 + error_message = "Experimental multi_runner_config_v2 must not add nested entries to the stable runners_map output." } assert { - condition = toset(keys(output.runners_map["linux"])) == toset( + condition = keys(output.runners_map_v2) == ["linux"] + error_message = "Experimental multi_runner_config_v2 must expose its runner configuration key through runners_map_v2." + } + + assert { + condition = toset(keys(output.runners_map_v2["linux"])) == toset( [ "provider", "runner", @@ -217,40 +236,40 @@ run "experimental_v2_routes_through_provider_stack" { "pool", ] ) - error_message = "Experimental v2 runners_map entries must group common and provider resources by owner." + error_message = "Experimental v2 runners_map_v2 entries must group common and provider resources by owner." } assert { condition = ( - toset(keys(output.runners_map["linux"].runner)) == toset(["role"]) - && toset(keys(output.runners_map["linux"].scale_up)) == toset(["lambda", "log_group", "role"]) - && toset(keys(output.runners_map["linux"].scale_down)) == toset(["lambda", "log_group", "role"]) - && toset(keys(output.runners_map["linux"].pool)) == toset(["lambda", "log_group", "role"]) + toset(keys(output.runners_map_v2["linux"].runner)) == toset(["role"]) + && toset(keys(output.runners_map_v2["linux"].scale_up)) == toset(["lambda", "log_group", "role"]) + && toset(keys(output.runners_map_v2["linux"].scale_down)) == toset(["lambda", "log_group", "role"]) + && toset(keys(output.runners_map_v2["linux"].pool)) == toset(["lambda", "log_group", "role"]) ) error_message = "Experimental v2 common resources must use the nested runner, scale-up, scale-down, and pool contracts." } assert { condition = ( - output.runners_map["linux"].provider.type == "ec2" - && toset(keys(output.runners_map["linux"].provider.ec2)) == toset([ + output.runners_map_v2["linux"].provider.type == "ec2" + && toset(keys(output.runners_map_v2["linux"].provider.ec2)) == toset([ "launch_template", "runners_log_groups", "logfiles", ]) ) - error_message = "Experimental v2 must expose only EC2-owned resources under runners_map..provider.ec2." + error_message = "Experimental v2 must expose only EC2-owned resources under runners_map_v2..provider.ec2." } assert { condition = ( - !contains(keys(output.runners_map["linux"]), "launch_template_name") - && output.runners_map["linux"].runner.role != null - && !contains(keys(output.runners_map["linux"].provider.ec2), "role_runner") - && !contains(keys(output.runners_map["linux"]), "runners_log_groups") - && !contains(keys(output.runners_map["linux"]), "logfiles") + !contains(keys(output.runners_map_v2["linux"]), "launch_template_name") + && output.runners_map_v2["linux"].runner.role != null + && !contains(keys(output.runners_map_v2["linux"].provider.ec2), "role_runner") + && !contains(keys(output.runners_map_v2["linux"]), "runners_log_groups") + && !contains(keys(output.runners_map_v2["linux"]), "logfiles") ) - error_message = "Experimental v2 must expose the common runner role under runner without duplicating EC2 resources." + error_message = "Experimental v2 must expose only its nested schema through runners_map_v2 without legacy flat fields." } assert { @@ -450,7 +469,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = output.runners_map["tagged"].pool == null + condition = output.runners_map_v2["tagged"].pool == null error_message = "Experimental v2 must expose a null pool object when no pool configuration is supplied." } } @@ -527,8 +546,11 @@ run "stable_v1_and_experimental_v2_coexist" { } assert { - condition = toset(keys(output.runners_map)) == toset(["legacy", "experimental"]) - error_message = "The public runner map must expose both stable and experimental runner configuration keys." + condition = ( + keys(output.runners_map) == ["legacy"] + && keys(output.runners_map_v2) == ["experimental"] + ) + error_message = "Stable and experimental runner configuration keys must remain separated across runners_map and runners_map_v2." } assert { @@ -556,9 +578,19 @@ run "stable_v1_and_experimental_v2_coexist" { } assert { - condition = output.runners_map["experimental"].provider.type == "ec2" && contains(keys(output.runners_map["experimental"].provider.ec2), "launch_template") + condition = output.runners_map_v2["experimental"].provider.type == "ec2" && contains(keys(output.runners_map_v2["experimental"].provider.ec2), "launch_template") error_message = "A coexisting v2 runner configuration must retain its nested EC2 provider output." } + + assert { + condition = ( + !contains(keys(output.runners_map["legacy"]), "provider") + && !contains(keys(output.runners_map["legacy"]), "runner") + && !contains(keys(output.runners_map_v2["experimental"]), "launch_template_name") + && !contains(keys(output.runners_map_v2["experimental"]), "lambda_up") + ) + error_message = "Coexisting outputs must not mix the stable flat schema with the experimental nested schema." + } } run "duplicate_runner_configuration_keys_are_rejected" { diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index 4fa3ae0198..7ce734e00c 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -1,10 +1,12 @@ -# Module - Scale runners +# Runner stack module -> This module is treated as internal module, breaking changes will not trigger a major release bump. +> This module is treated as an internal module; breaking changes do not trigger a major release bump. -This module creates a provider-neutral runner control plane and selects its compute implementation with `compute_provider.type`. Provider-owned settings are typed and nested under the selected provider block; for example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.ec2`. EC2 is the only active provider today. +This internal module implements the experimental provider-neutral runner control plane selected by `experimental.multi_runner_config_v2`. It is composed by `multi-runner` and is not intended as a standalone public entry point. Its direct contract may change while v2 remains experimental. -The common layer owns scale-up, scale-down, pool, job retry, Lambda execution roles, the runner IAM role and policy attachments, shared SSM configuration, and the SSM housekeeper. [`../compute-providers/ec2/runner-role`](../compute-providers/ec2/runner-role) supplies the EC2 runner-role trust and permission documents without depending on the role. The common layer creates or selects the role and attaches those policies, then passes the role into [`../compute-providers/ec2`](../compute-providers/ec2), which owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and the EC2 environment and IAM fragments merged into the common Lambda resources. Future providers can implement the same two contracts without copying the control plane. +The stack coordinates internal modules for [`scale-runners`](./scale-runners), [`pool`](./pool), [`job-retry`](./job-retry), and [`ssm-housekeeper`](./ssm-housekeeper). These modules own their provider-neutral Lambda, scheduler, logging, and IAM resources. The stack also creates or selects the runner IAM role, manages shared runner configuration in SSM, and dispatches the selected compute provider. + +Provider-owned settings are typed and nested under the selected provider block; for example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.ec2`. [`../compute-providers/ec2/runner-role`](../compute-providers/ec2/runner-role) supplies the EC2 runner-role trust and permission documents without depending on the role. The common stack creates or selects the role and attaches those policies, then passes the role into [`../compute-providers/ec2`](../compute-providers/ec2), which owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and the EC2 environment and IAM fragments consumed by the internal control-plane modules. EC2 is the only active provider today; future providers can implement the same contracts without copying the control plane. ## Tagging @@ -61,85 +63,44 @@ yarn run dist ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [ec2\_runner\_role](#module\_ec2\_runner\_role) | ../compute-providers/ec2/runner-role | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | +| [scale\_runners](#module\_scale\_runners) | ./scale-runners | n/a | +| [ssm\_housekeeper](#module\_ssm\_housekeeper) | ./ssm-housekeeper | n/a | ## Resources | Name | Type | -|------|------| -| [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | -| [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | -| [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | -| [aws_cloudwatch_event_target.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | -| [aws_cloudwatch_log_group.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | -| [aws_cloudwatch_log_group.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | -| [aws_cloudwatch_log_group.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| ---- | ---- | | [aws_iam_role.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | -| [aws_iam_role.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | -| [aws_iam_role.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | -| [aws_iam_role.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | -| [aws_iam_role_policy.job_retry_sqs_publish](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.runner_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.scale_down_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.scale_down_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.scale_up_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.scale_up_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.service_linked_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.ssm_housekeeper_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.ssm_housekeeper_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy_attachment.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_iam_role_policy_attachment.scale_down_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_iam_role_policy_attachment.scale_up_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_lambda_event_source_mapping.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | -| [aws_lambda_function.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | -| [aws_lambda_function.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | -| [aws_lambda_function.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | -| [aws_lambda_permission.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | -| [aws_lambda_permission.scale_runners_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | -| [aws_lambda_permission.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | | [aws_ssm_parameter.disable_default_labels](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.jit_config_enabled](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.runner_agent_mode](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.token_path](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | -| [aws_iam_policy_document.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.lambda_xray](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_down_common](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.scale_down_logging](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.scale_up_common](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.scale_up_job_retry_publish](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.scale_up_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.ssm_housekeeper_logging](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 ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | | [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block.

- `type`: Compute-provider discriminator. The currently supported value is `ec2`.
- `ec2`: EC2 compute-provider configuration. This object is required when `type` is `ec2`.
- `ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults.
- `ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter.
- `ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource.
- `ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator.
- `ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `ec2.vpc_id`: VPC in which runner networking resources are created.
- `ec2.subnet_ids`: Subnets from which scale-up may launch runner instances.
- `ec2.overrides`: Optional resource-name overrides.
- `ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name.
- `ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name.
- `ec2.instance_profile`: Optional externally managed instance profile used by the launch template.
- `ec2.instance_profile.name`: Name of the externally managed instance profile.
- `ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the stack prefix.
- `ec2.binaries_syncer`: Runner-distribution synchronization configuration.
- `ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3.
- `ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies.
- `ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI.
- `ec2.binaries_syncer.s3.key`: Object key of the runner distribution.
- `ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it.
- `ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `ec2.block_device_mappings[].volume_type`: EBS volume type.
- `ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity.
- `ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `ec2.user_data`: Runner bootstrap user-data configuration.
- `ec2.user_data.enabled`: Enables launch-template user data.
- `ec2.user_data.template`: Optional path to a custom user-data template.
- `ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template.
- `ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances.
- `ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`.
- `ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group.
- `ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults.
- `ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing.
- `ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `ec2.key_name`: Optional EC2 key-pair name added to the launch template.
- `ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group.
- `ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `ec2.egress_rules`: Egress rules created on the managed runner security group.
- `ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `ec2.egress_rules[].from_port`: First destination port in the permitted range.
- `ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `ec2.egress_rules[].security_groups`: Destination security-group IDs.
- `ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `ec2.egress_rules[].to_port`: Last destination port in the permitted range.
- `ec2.egress_rules[].description`: Optional rule description.
- `ec2.tags`: Additional tags for runner instances, EBS volumes, network interfaces, and eligible Spot instance requests created from the launch template. They override module-level tags and the generated runner `Name`; the provider-managed `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` bootstrap tags take final precedence. These tags do not apply to static provider resources such as the launch template, security group, IAM resources, SSM parameters, or log groups.
- `ec2.metadata_options`: Instance Metadata Service configuration in the launch template.
- `ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`.
- `ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`.
- `ec2.cpu_options`: CPU topology and processor-feature configuration.
- `ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `ec2.placement`: EC2 placement configuration for runner instances.
- `ec2.placement.affinity`: Host affinity setting.
- `ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `ec2.placement.group_id`: Placement-group ID.
- `ec2.placement.group_name`: Placement-group name.
- `ec2.placement.host_id`: Dedicated Host ID.
- `ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `ec2.placement.spread_domain`: Spread-domain placement value.
- `ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `ec2.placement.partition_number`: Placement-group partition number.
- `ec2.license_specifications`: License Manager configurations added to the launch template.
- `ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. |
object({
type = string

ec2 = optional(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)
enable_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)
}), null)
})
| n/a | yes | @@ -159,7 +120,7 @@ yarn run dist ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Selected compute provider type and its provider-specific resources. | | [runner](#output\_runner) | Common runner resources. The role is null when an external runner role is used. | diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md index 8b543b13e1..478ba00f15 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/runner-stack/job-retry/README.md @@ -11,44 +11,51 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.21 | ## Modules -| Name | Source | Version | -|------|--------|---------| -| [job\_retry](#module\_job\_retry) | ../../lambda | n/a | +No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | +| [aws_cloudwatch_log_group.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.job_retry_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.job_retry_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.job_retry_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_lambda_event_source_mapping.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | +| [aws_lambda_function.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | | [aws_lambda_permission.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | | [aws_sqs_queue.job_retry_check_queue](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.job_retry_check_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | | [aws_iam_policy_document.deny_insecure_transport](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.job_retry_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [config](#input\_config) | Configuration for the job-retry Lambda and retry queue.

- `aws_partition`: Partition for the base ARN if not `aws`.
- `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
- `environment_variables`: Additional environment variables for the job-retry Lambda. Required job-retry variables override matching keys.
- `enable_organization_runners`: Enable organization runners.
- `ghes_url`: Optional GitHub Enterprise Server URL.
- `user_agent`: Optional User-Agent header for GitHub API requests.
- `github_app_parameters`: SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Metadata for the SSM parameter containing the base64-encoded GitHub App private key.
- `github_app_parameters.key_base64.name`: Name of the private-key parameter supplied to the job-retry Lambda.
- `github_app_parameters.key_base64.arn`: ARN of the private-key parameter used by the job-retry IAM policy.
- `github_app_parameters.id`: Metadata for the SSM parameter containing the GitHub App ID.
- `github_app_parameters.id.name`: Name of the App-ID parameter supplied to the job-retry Lambda.
- `github_app_parameters.id.arn`: ARN of the App-ID parameter used by the job-retry IAM policy.
- `kms_key`: Optional customer-managed KMS key used by the job-retry IAM policy. Object presence controls whether the KMS statement exists.
- `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply.
- `lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
- `lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
- `lambda_tags`: Tags added specifically to the job-retry Lambda function. These override `tags` values with the same key.
- `log_group_tags`: Tags added specifically to the job-retry Lambda log group. These override `tags` values with the same key.
- `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
- `log_class`: CloudWatch log-group class for the job-retry Lambda. Supported values are `STANDARD` and `INFREQUENT_ACCESS`.
- `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
- `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.
- `memory_size`: Memory size limit in MB of the lambda.
- `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
- `metrics`: Configuration to enable metrics creation by the lambda.
- `metrics.enable`: Whether the job-retry Lambda emits metrics.
- `metrics.namespace`: CloudWatch namespace for metrics emitted by the job-retry Lambda.
- `metrics.metric`: Controls individual metrics emitted by the job-retry Lambda.
- `metrics.metric.enable_github_app_rate_limit`: Whether to emit GitHub App rate-limit metrics.
- `metrics.metric.enable_job_retry`: Whether to emit job-retry metrics.
- `prefix`: The prefix used for naming resources.
- `principals`: Extra principals allowed to assume the job-retry Lambda execution role, for example during local testing.
- `principals[*].type`: IAM principal type, such as `Service` or `AWS`.
- `principals[*].identifiers`: IAM principal identifiers for the selected principal type.
- `queue_encryption`: Encryption configuration for the retry queue.
- `queue_encryption.kms_data_key_reuse_period_seconds`: Length of time, in seconds, for which SQS reuses a data key.
- `queue_encryption.kms_master_key_id`: KMS key ID used to encrypt the retry queue.
- `queue_encryption.sqs_managed_sse_enabled`: Whether SQS-managed server-side encryption is enabled.
- `role_path`: The path that will be added to the role, if not set the environment name will be used.
- `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
- `runner_name_prefix`: Prefix used to identify runners belonging to this runner configuration.
- `runtime`: AWS Lambda runtime.
- `s3_bucket`: S3 bucket containing the job-retry Lambda deployment package. This is an alternative to `zip`.
- `s3_key`: Object key of the job-retry Lambda deployment package. Required when `s3_bucket` is set.
- `s3_object_version`: Optional version of the job-retry Lambda deployment-package object.
- `security_group_ids`: List of security group IDs associated with the Lambda function.
- `sqs_build_queue`: SQS queue to which the job-retry Lambda republishes job requests.
- `sqs_build_queue.url`: URL of the build queue.
- `sqs_build_queue.arn`: ARN of the build queue.
- `queue_tags`: Map of tags that will be added to the retry queue and event-source mapping.
- `subnet_ids`: Subnets used for the job-retry Lambda VPC configuration.
- `tags`: Base component tags added to the Lambda function, log group, and execution role. Specialized Lambda and log-group tags override matching keys.
- `timeout`: Time out of the lambda in seconds.
- `tracing_config`: Configuration for lambda tracing.
- `tracing_config.mode`: AWS X-Ray tracing mode. A null value disables tracing.
- `tracing_config.capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests.
- `tracing_config.capture_error`: Whether Powertools tracing captures errors as tracing metadata.
- `zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key = optional(object({
arn = string
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_group_tags = optional(map(string), {})
log_level = optional(string, null)
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
reserved_concurrent_executions = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
queue_tags = optional(map(string), {})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| -| [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | n/a | -| [lambda](#output\_lambda) | n/a | +| ---- | ----------- | +| [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | Queue consumed by the job-retry Lambda. | +| [lambda](#output\_lambda) | Job-retry Lambda resources. | diff --git a/modules/runner-stack/job-retry/iam-policies.tf b/modules/runner-stack/job-retry/iam-policies.tf index fcba4469b5..d912fe339d 100644 --- a/modules/runner-stack/job-retry/iam-policies.tf +++ b/modules/runner-stack/job-retry/iam-policies.tf @@ -1,4 +1,53 @@ # IAM policies attached to the job-retry Lambda role. +data "aws_iam_policy_document" "lambda_assume_role" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + + dynamic "principals" { + for_each = var.config.principals + + content { + type = principals.value.type + identifiers = principals.value.identifiers + } + } + } +} + +data "aws_iam_policy_document" "job_retry_logging" { + statement { + effect = "Allow" + + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + + resources = ["${aws_cloudwatch_log_group.job_retry.arn}*"] + } +} + +data "aws_iam_policy_document" "lambda_xray" { + count = var.config.tracing_config.mode != null ? 1 : 0 + + statement { + sid = "AllowXRay" + effect = "Allow" + actions = [ + "xray:BatchGetTraces", + "xray:GetTraceSummaries", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments", + ] + resources = ["*"] + } +} + data "aws_iam_policy_document" "job_retry" { statement { effect = "Allow" diff --git a/modules/runner-stack/job-retry/job-retry.tf b/modules/runner-stack/job-retry/job-retry.tf index 1bb40fcf0a..dbe09ffdd6 100644 --- a/modules/runner-stack/job-retry/job-retry.tf +++ b/modules/runner-stack/job-retry/job-retry.tf @@ -1,9 +1,30 @@ -# Provider-neutral job-retry queue and Lambda wiring. +# Provider-neutral job-retry queue and Lambda resources. locals { - lambda_zip = var.config.zip == null ? "${path.module}/../../../lambdas/functions/control-plane/runners.zip" : var.config.zip - name = "job-retry" + name = "job-retry" + lambda_zip = var.config.zip == null ? "${path.module}/../../../lambdas/functions/control-plane/runners.zip" : var.config.zip + architecture = coalesce(var.config.architecture, "arm64") + aws_partition = coalesce(var.config.aws_partition, "aws") + log_level = coalesce(var.config.log_level, "info") + memory_size = coalesce(var.config.memory_size, 256) + metrics_namespace = coalesce(var.config.metrics.namespace, "GitHub Runners") + logging_retention_in_days = coalesce(var.config.logging_retention_in_days, 180) + runtime = coalesce(var.config.runtime, "nodejs24.x") + role_path = var.config.role_path == null ? "/${var.config.prefix}/" : var.config.role_path + vpc_enabled = length(var.config.subnet_ids) > 0 && length(var.config.security_group_ids) > 0 + + lambda_environment_variables = { + ENVIRONMENT = var.config.prefix + LOG_LEVEL = local.log_level + PREFIX = var.config.prefix + POWERTOOLS_LOGGER_LOG_EVENT = local.log_level == "debug" ? "true" : "false" + POWERTOOLS_SERVICE_NAME = local.name + POWERTOOLS_TRACE_ENABLED = var.config.tracing_config.mode != null + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error + POWERTOOLS_METRICS_NAMESPACE = local.metrics_namespace + } - environment_variables = { + job_retry_environment_variables = { ENABLE_ORGANIZATION_RUNNERS = var.config.enable_organization_runners ENABLE_METRIC_JOB_RETRY = var.config.metrics.enable && var.config.metrics.metric.enable_job_retry ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.metrics.enable && var.config.metrics.metric.enable_github_app_rate_limit @@ -15,13 +36,11 @@ locals { RUNNER_NAME_PREFIX = var.config.runner_name_prefix } - config = merge(var.config, { - name = local.name, - handler = "index.jobRetryCheck", - zip = local.lambda_zip, - environment_variables = merge(var.config.environment_variables, local.environment_variables) - metrics_namespace = var.config.metrics.namespace - }) + environment_variables = merge( + local.lambda_environment_variables, + var.config.environment_variables, + local.job_retry_environment_variables, + ) } resource "aws_sqs_queue_policy" "job_retry_check_queue_policy" { @@ -31,7 +50,7 @@ resource "aws_sqs_queue_policy" "job_retry_check_queue_policy" { resource "aws_sqs_queue" "job_retry_check_queue" { name = "${var.config.prefix}-job-retry" - visibility_timeout_seconds = local.config.timeout + visibility_timeout_seconds = var.config.timeout sqs_managed_sse_enabled = var.config.queue_encryption.sqs_managed_sse_enabled kms_master_key_id = var.config.queue_encryption.kms_master_key_id @@ -40,14 +59,83 @@ resource "aws_sqs_queue" "job_retry_check_queue" { tags = var.config.queue_tags } -module "job_retry" { - source = "../../lambda" - lambda = local.config +resource "aws_lambda_function" "job_retry" { + s3_bucket = var.config.s3_bucket + s3_key = var.config.s3_key + s3_object_version = var.config.s3_object_version + filename = var.config.s3_bucket == null ? local.lambda_zip : null + source_code_hash = var.config.s3_bucket == null ? filebase64sha256(local.lambda_zip) : null + function_name = "${var.config.prefix}-${local.name}" + role = aws_iam_role.job_retry.arn + handler = "index.jobRetryCheck" + runtime = local.runtime + timeout = var.config.timeout + memory_size = local.memory_size + reserved_concurrent_executions = var.config.reserved_concurrent_executions + architectures = [local.architecture] + + environment { + variables = local.environment_variables + } + + dynamic "vpc_config" { + for_each = local.vpc_enabled ? [true] : [] + + content { + security_group_ids = var.config.security_group_ids + subnet_ids = var.config.subnet_ids + } + } + + dynamic "tracing_config" { + for_each = var.config.tracing_config.mode != null ? [true] : [] + + content { + mode = var.config.tracing_config.mode + } + } + + tags = merge(var.config.tags, var.config.lambda_tags) +} + +resource "aws_cloudwatch_log_group" "job_retry" { + name = "/aws/lambda/${aws_lambda_function.job_retry.function_name}" + retention_in_days = local.logging_retention_in_days + kms_key_id = var.config.logging_kms_key_id + log_group_class = var.config.log_class + tags = merge(var.config.tags, var.config.log_group_tags) +} + +resource "aws_iam_role" "job_retry" { + name = "${substr("${var.config.prefix}-${local.name}", 0, 54)}-${substr(md5("${var.config.prefix}-${local.name}"), 0, 8)}" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json + path = local.role_path + permissions_boundary = var.config.role_permissions_boundary + tags = var.config.tags +} + +resource "aws_iam_role_policy" "job_retry_logging" { + name = "logging-policy" + role = aws_iam_role.job_retry.name + policy = data.aws_iam_policy_document.job_retry_logging.json +} + +resource "aws_iam_role_policy_attachment" "job_retry_vpc_execution_role" { + count = local.vpc_enabled ? 1 : 0 + role = aws_iam_role.job_retry.name + policy_arn = "arn:${local.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy" "job_retry_xray" { + count = var.config.tracing_config.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.job_retry.name } resource "aws_lambda_event_source_mapping" "job_retry" { event_source_arn = aws_sqs_queue.job_retry_check_queue.arn - function_name = module.job_retry.lambda.function.arn + function_name = aws_lambda_function.job_retry.arn batch_size = var.config.lambda_event_source_mapping_batch_size maximum_batching_window_in_seconds = var.config.lambda_event_source_mapping_maximum_batching_window_in_seconds tags = var.config.queue_tags @@ -56,14 +144,14 @@ resource "aws_lambda_event_source_mapping" "job_retry" { resource "aws_lambda_permission" "job_retry" { statement_id = "AllowExecutionFromSQS" action = "lambda:InvokeFunction" - function_name = module.job_retry.lambda.function.function_name + function_name = aws_lambda_function.job_retry.function_name principal = "sqs.amazonaws.com" source_arn = aws_sqs_queue.job_retry_check_queue.arn } resource "aws_iam_role_policy" "job_retry" { name = "job_retry-policy" - role = module.job_retry.lambda.role.name + role = aws_iam_role.job_retry.name policy = data.aws_iam_policy_document.job_retry.json } diff --git a/modules/runner-stack/job-retry/outputs.tf b/modules/runner-stack/job-retry/outputs.tf index 38bb0d6030..4f08cc4498 100644 --- a/modules/runner-stack/job-retry/outputs.tf +++ b/modules/runner-stack/job-retry/outputs.tf @@ -1,11 +1,13 @@ output "lambda" { + description = "Job-retry Lambda resources." value = { - function = module.job_retry.lambda - log_group = module.job_retry.lambda.log_group - role = module.job_retry.lambda.role + function = aws_lambda_function.job_retry + log_group = aws_cloudwatch_log_group.job_retry + role = aws_iam_role.job_retry } } output "job_retry_check_queue" { - value = aws_sqs_queue.job_retry_check_queue + description = "Queue consumed by the job-retry Lambda." + value = aws_sqs_queue.job_retry_check_queue } diff --git a/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl b/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl index 57f38cc742..0e8041a575 100644 --- a/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl +++ b/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl @@ -56,15 +56,24 @@ run "preserves_optional_lambda_configuration" { command = plan assert { - condition = output.lambda.function.function.environment[0].variables["CUSTOM_ENV"] == "preserved" + condition = output.lambda.function.environment[0].variables["CUSTOM_ENV"] == "preserved" error_message = "Caller-provided job-retry environment variables must be preserved." } assert { - condition = output.lambda.function.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "required-prefix-" + condition = output.lambda.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "required-prefix-" error_message = "Required job-retry environment variables must override caller-provided values." } + assert { + condition = ( + toset(keys(output.lambda)) == toset(["function", "log_group", "role"]) + && output.lambda.function.s3_bucket == "lambda-artifacts" + && output.lambda.function.s3_key == "job-retry.zip" + ) + error_message = "The job-retry module must expose its direct Lambda resources and preserve the S3 artifact configuration." + } + assert { condition = output.lambda.log_group.log_group_class == "INFREQUENT_ACCESS" error_message = "The job-retry log-group class must be preserved through the typed child-module boundary." @@ -75,3 +84,85 @@ run "preserves_optional_lambda_configuration" { error_message = "A present KMS key object must add the job-retry KMS policy statement." } } + +run "configures_role_tracing_and_complete_vpc" { + command = plan + + variables { + config = { + prefix = "job-retry-test" + enable_organization_runners = false + principals = [{ + type = "AWS" + identifiers = ["arn:aws:iam::123456789012:root"] + }] + security_group_ids = ["sg-12345678"] + subnet_ids = ["subnet-12345678"] + tracing_config = { + mode = "Active" + } + github_app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } + } + s3_bucket = "lambda-artifacts" + s3_key = "job-retry.zip" + sqs_build_queue = { + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + } + } + } + + assert { + condition = ( + length(aws_lambda_function.job_retry.vpc_config) == 1 + && length(aws_iam_role_policy_attachment.job_retry_vpc_execution_role) == 1 + && length(aws_iam_role_policy.job_retry_xray) == 1 + && length(data.aws_iam_policy_document.lambda_assume_role.statement[0].principals) == 2 + ) + error_message = "Complete VPC, tracing, and extra assume-role principal configuration must be applied to the direct Lambda resources." + } +} + +run "does_not_enable_partial_vpc_configuration" { + command = plan + + variables { + config = { + prefix = "job-retry-test" + enable_organization_runners = false + subnet_ids = ["subnet-12345678"] + github_app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } + } + s3_bucket = "lambda-artifacts" + s3_key = "job-retry.zip" + sqs_build_queue = { + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + } + } + } + + assert { + condition = ( + length(aws_lambda_function.job_retry.vpc_config) == 0 + && length(aws_iam_role_policy_attachment.job_retry_vpc_execution_role) == 0 + ) + error_message = "The VPC block and managed policy must both remain disabled until subnet and security-group lists are complete." + } +} diff --git a/modules/runner-stack/job-retry/variables.tf b/modules/runner-stack/job-retry/variables.tf index 120309a015..df6f1bccb8 100644 --- a/modules/runner-stack/job-retry/variables.tf +++ b/modules/runner-stack/job-retry/variables.tf @@ -127,4 +127,27 @@ variable "config" { }), {}) zip = optional(string, null) }) + + validation { + condition = contains(["arm64", "x86_64"], coalesce(var.config.architecture, "arm64")) + error_message = "config.architecture must be arm64 or x86_64." + } + + validation { + condition = contains([ + "silly", + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + ], coalesce(var.config.log_level, "info")) + error_message = "config.log_level must be one of silly, trace, debug, info, warn, error, or fatal." + } + + validation { + condition = var.config.prefix == null ? false : length(var.config.prefix) + length("job-retry") <= 63 + error_message = "config.prefix is required and its length plus job-retry must be less than or equal to 63." + } } diff --git a/modules/runner-stack/outputs.tf b/modules/runner-stack/outputs.tf index 89f3031cf2..8f2fe032fb 100644 --- a/modules/runner-stack/outputs.tf +++ b/modules/runner-stack/outputs.tf @@ -7,20 +7,12 @@ output "runner" { output "scale_up" { description = "Scale-up control-plane resources." - value = { - lambda = aws_lambda_function.scale_up - log_group = aws_cloudwatch_log_group.scale_up - role = aws_iam_role.scale_up - } + value = module.scale_runners.scale_up } output "scale_down" { description = "Scale-down control-plane resources." - value = { - lambda = aws_lambda_function.scale_down - log_group = aws_cloudwatch_log_group.scale_down - role = aws_iam_role.scale_down - } + value = module.scale_runners.scale_down } output "pool" { diff --git a/modules/runner-stack/scale-down.tf b/modules/runner-stack/scale-down.tf deleted file mode 100644 index 57094fa499..0000000000 --- a/modules/runner-stack/scale-down.tf +++ /dev/null @@ -1,122 +0,0 @@ -locals { - # Windows Runners can take their sweet time to do anything - # For an AWS vended AMI with an x86 Mac instance or an Apple silicon Mac instance, - # the launch time can range from approximately 6 minutes to 20 minutes. - min_runtime_defaults = { - "windows" = 15 - "linux" = 5 - "osx" = 20 - } -} -resource "aws_lambda_function" "scale_down" { - s3_bucket = var.lambda.s3.bucket != null ? var.lambda.s3.bucket : null - s3_key = var.lambda.s3.key != null ? var.lambda.s3.key : null - s3_object_version = var.lambda.s3.object_version != null ? var.lambda.s3.object_version : null - filename = var.lambda.s3.bucket == null ? local.lambda_zip : null - source_code_hash = var.lambda.s3.bucket == null ? filebase64sha256(local.lambda_zip) : null - function_name = "${var.prefix}-scale-down" - role = aws_iam_role.scale_down.arn - handler = "index.scaleDownHandler" - runtime = var.lambda.runtime - timeout = var.scale_down.timeout - tags = local.scale_down_lambda_tags - memory_size = var.scale_down.memory_size - architectures = [var.lambda.architecture] - - environment { - variables = merge(local.provider.scale_down.environment_variables, { - ENVIRONMENT = var.prefix - ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.observability.metrics.enable && var.observability.metrics.metric.enable_github_app_rate_limit - GHES_URL = var.github.enterprise_server.url - USER_AGENT = var.github.user_agent - LOG_LEVEL = upper(var.observability.logs.level) - MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner.os]) - NODE_TLS_REJECT_UNAUTHORIZED = var.github.enterprise_server.url != null && !var.github.enterprise_server.ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = var.github.app_parameters.id.name - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github.app_parameters.key_base64.name - POWERTOOLS_LOGGER_LOG_EVENT = var.observability.logs.level == "debug" ? "true" : "false" - SCALE_DOWN_CONFIG = jsonencode(var.scale_down.idle_config) - POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-down" - POWERTOOLS_METRICS_NAMESPACE = var.observability.metrics.namespace - POWERTOOLS_TRACE_ENABLED = var.observability.tracing.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.observability.tracing.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.observability.tracing.capture_error - RUNNER_PROVIDER_TYPE = local.provider.type - }) - } - - dynamic "vpc_config" { - for_each = var.lambda.subnet_ids != null && var.lambda.security_group_ids != null ? [true] : [] - content { - security_group_ids = var.lambda.security_group_ids - subnet_ids = var.lambda.subnet_ids - } - } - - dynamic "tracing_config" { - for_each = var.observability.tracing.mode != null ? [true] : [] - content { - mode = var.observability.tracing.mode - } - } -} - -resource "aws_cloudwatch_log_group" "scale_down" { - name = "/aws/lambda/${aws_lambda_function.scale_down.function_name}" - retention_in_days = var.observability.logs.retention_in_days - kms_key_id = var.observability.logs.kms_key_id - log_group_class = var.observability.logs.class - tags = local.scale_down_log_tags -} - -resource "aws_cloudwatch_event_rule" "scale_down" { - name = "${var.prefix}-scale-down-rule" - schedule_expression = var.scale_down.schedule_expression - tags = local.scale_down_tags -} - -resource "aws_cloudwatch_event_target" "scale_down" { - rule = aws_cloudwatch_event_rule.scale_down.name - arn = aws_lambda_function.scale_down.arn -} - -resource "aws_lambda_permission" "scale_down" { - statement_id = "AllowExecutionFromCloudWatch" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.scale_down.function_name - principal = "events.amazonaws.com" - source_arn = aws_cloudwatch_event_rule.scale_down.arn -} - -resource "aws_iam_role" "scale_down" { - name = "${substr("${var.prefix}-scale-down-lambda", 0, 54)}-${substr(md5("${var.prefix}-scale-down-lambda"), 0, 8)}" - assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json - path = local.lambda_role_path - permissions_boundary = var.lambda.role.permissions_boundary - tags = local.scale_down_tags -} - -resource "aws_iam_role_policy" "scale_down" { - name = "scale-down-policy" - role = aws_iam_role.scale_down.name - policy = data.aws_iam_policy_document.scale_down.json -} - -resource "aws_iam_role_policy" "scale_down_logging" { - name = "logging-policy" - role = aws_iam_role.scale_down.name - policy = data.aws_iam_policy_document.scale_down_logging.json -} - -resource "aws_iam_role_policy_attachment" "scale_down_vpc_execution_role" { - count = length(var.lambda.subnet_ids) > 0 ? 1 : 0 - role = aws_iam_role.scale_down.name - policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" -} - -resource "aws_iam_role_policy" "scale_down_xray" { - count = var.observability.tracing.mode != null ? 1 : 0 - name = "xray-policy" - policy = data.aws_iam_policy_document.lambda_xray[0].json - role = aws_iam_role.scale_down.name -} diff --git a/modules/runner-stack/scale-runners.tf b/modules/runner-stack/scale-runners.tf new file mode 100644 index 0000000000..5b358a58a7 --- /dev/null +++ b/modules/runner-stack/scale-runners.tf @@ -0,0 +1,86 @@ +module "scale_runners" { + source = "./scale-runners" + + aws_partition = var.aws_partition + + config = { + prefix = var.prefix + lambda = { + artifact = { + zip = local.lambda_zip + s3 = var.lambda.s3 + } + runtime = var.lambda.runtime + architecture = var.lambda.architecture + vpc = { + subnet_ids = var.lambda.subnet_ids + security_group_ids = var.lambda.security_group_ids + } + role = { + path = local.lambda_role_path + permissions_boundary = var.lambda.role.permissions_boundary + } + } + runner = var.runner + github = var.github + queue = { + build = var.queue.build + event_source_mapping = var.queue.event_source_mapping + } + ssm = { + token_path = local.token_path + config_path = "${var.ssm.paths.root}/${var.ssm.paths.config}" + config_path_arn = local.arn_ssm_parameters_path_config + kms_key = local.kms_key + parameter_store_tags = local.parameter_store_tags + } + observability = var.observability + scale_up = { + memory_size = var.scale_up.memory_size + timeout = var.scale_up.timeout + reserved_concurrent_executions = var.scale_up.reserved_concurrent_executions + job_queued_check_enabled = local.enable_job_queued_check + tags = { + resources = local.scale_up_tags + lambda = local.scale_up_lambda_tags + log_group = local.scale_up_log_tags + event_source_mapping = local.scale_up_queue_tags + } + } + scale_down = { + memory_size = var.scale_down.memory_size + timeout = var.scale_down.timeout + schedule_expression = var.scale_down.schedule_expression + minimum_running_time_in_minutes = var.scale_down.minimum_running_time_in_minutes + idle_config = var.scale_down.idle_config + tags = { + resources = local.scale_down_tags + lambda = local.scale_down_lambda_tags + log_group = local.scale_down_log_tags + } + } + job_retry = { + enabled = local.job_retry_enabled + max_attempts = var.job_retry.max_attempts + delay_in_seconds = var.job_retry.delay_in_seconds + delay_backoff = var.job_retry.delay_backoff + queue = one(module.job_retry[*].job_retry_check_queue) + } + } + + runner_provider = { + type = local.provider.type + scale_up = { + environment_variables = local.provider.scale_up.environment_variables + iam_policy_json = local.provider.scale_up.iam_policy_json + additional_iam_policy_json = local.provider.scale_up.additional_iam_policy_json + managed_policy = local.provider.scale_up.managed_policy_enabled ? { + arn = local.provider.scale_up.managed_policy_arn + } : null + } + scale_down = { + environment_variables = local.provider.scale_down.environment_variables + iam_policy_json = local.provider.scale_down.iam_policy_json + } + } +} diff --git a/modules/runner-stack/scale-runners/README.md b/modules/runner-stack/scale-runners/README.md new file mode 100644 index 0000000000..fafc500a82 --- /dev/null +++ b/modules/runner-stack/scale-runners/README.md @@ -0,0 +1,77 @@ +# Scale runners module + +> This module is treated as an internal module; breaking changes do not trigger a major release bump. + +This provider-neutral child module owns the scale-up and scale-down Lambda functions, their event sources and schedules, and their IAM and logging resources. `runner-stack` supplies common configuration together with the selected compute provider's environment and IAM fragments. + +The module is an implementation detail of the experimental runner stack. It is composed by `runner-stack` and is not intended to be called directly. + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | >= 6.33 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_log_group.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_cloudwatch_log_group.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.job_retry_sqs_publish](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_down_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_down_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_up_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.scale_up_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.service_linked_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.scale_down_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.scale_up_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambda_event_source_mapping.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource | +| [aws_lambda_function.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_function.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_permission.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_lambda_permission.scale_runners_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_iam_policy_document.lambda_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_xray](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_down_common](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.scale_down_logging](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.scale_up_common](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.scale_up_job_retry_publish](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.scale_up_logging](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 policy ARNs. | `string` | `"aws"` | no | +| [config](#input\_config) | Provider-neutral scale-up and scale-down configuration assembled by runner-stack.

- `prefix`: Prefix used to name scaling resources.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by both scaling Lambdas.
- `lambda.architecture`: Instruction-set architecture used by both scaling Lambdas.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the scaling Lambda roles.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the scaling Lambda roles.
- `runner.os`: Runner operating system used for the minimum-runtime default.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Enables or disables just-in-time runner configuration.
- `runner.labels`: Labels supplied when a runner is registered.
- `runner.group_name`: GitHub runner group used during registration.
- `runner.name_prefix`: Prefix added to registered runner names.
- `runner.maximum_count`: Maximum number of runners for this stack.
- `github.organization_runners`: Registers organization runners when true.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS verification for GitHub Enterprise Server.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Name and ARN of the GitHub App private-key parameter.
- `github.app_parameters.id`: Name and ARN of the GitHub App ID parameter.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per scale-up invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `ssm.token_path`: Parameter Store path used for registration tokens.
- `ssm.config_path`: Parameter Store path used for persistent runner configuration.
- `ssm.config_path_arn`: ARN of the persistent runner configuration path.
- `ssm.kms_key`: Optional KMS key used to decrypt shared parameters.
- `ssm.parameter_store_tags`: JSON-encoded tags applied to parameters created at runtime.
- `observability.logs`: Shared logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and GitHub rate-limit metric configuration.
- `scale_up`: Scale-up Lambda sizing, concurrency, queued-job behavior, and resolved resource tag maps.
- `scale_up.tags.resources`: Tags for the scale-up IAM role and other component resources.
- `scale_up.tags.lambda`: Tags for the scale-up Lambda function.
- `scale_up.tags.log_group`: Tags for the scale-up log group.
- `scale_up.tags.event_source_mapping`: Tags for the build-queue event-source mapping.
- `scale_down`: Scale-down Lambda sizing, schedule, idle configuration, minimum runtime, and resolved resource tag maps.
- `scale_down.tags.resources`: Tags for the scale-down IAM role and EventBridge rule.
- `scale_down.tags.lambda`: Tags for the scale-down Lambda function.
- `scale_down.tags.log_group`: Tags for the scale-down log group.
- `job_retry.enabled`: Enables publishing retry checks from scale-up.
- `job_retry.queue`: Retry queue ARN and URL. Required when job retry is enabled.
- `job_retry.max_attempts`: Maximum queued-job retry attempts.
- `job_retry.delay_in_seconds`: Initial delay before checking the queued job.
- `job_retry.delay_backoff`: Multiplier applied to subsequent delays. |
object({
prefix = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
})
})
runner = object({
os = string
auto_update_disabled = bool
ephemeral = bool
jit_config_enabled = optional(bool, null)
labels = list(string)
group_name = string
name_prefix = string
maximum_count = number
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = object({
name = string
arn = string
})
id = object({
name = string
arn = string
})
})
})
queue = object({
build = object({
arn = string
})
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key = optional(object({
arn = string
}), null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enable = bool
namespace = string
metric = object({
enable_github_app_rate_limit = bool
})
})
})
scale_up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = bool
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
event_source_mapping = map(string)
})
})
scale_down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
})
job_retry = object({
enabled = bool
max_attempts = number
delay_in_seconds = number
delay_backoff = number
queue = optional(object({
arn = string
url = string
}), null)
})
})
| n/a | yes | +| [runner\_provider](#input\_runner\_provider) | Selected compute-provider integration for the scaling control plane.

- `type`: Compute-provider discriminator supplied to both Lambdas.
- `scale_up.environment_variables`: Provider-specific scale-up environment variables.
- `scale_up.iam_policy_json`: Provider-specific IAM policy merged into the common scale-up policy.
- `scale_up.additional_iam_policy_json`: Optional additional provider policy attached separately to the scale-up role.
- `scale_up.managed_policy`: Optional provider-managed policy attachment. Object presence controls attachment creation.
- `scale_up.managed_policy.arn`: ARN of the provider-managed policy. The ARN may remain unknown until apply.
- `scale_down.environment_variables`: Provider-specific scale-down environment variables.
- `scale_down.iam_policy_json`: Provider-specific IAM policy merged into the common scale-down policy. |
object({
type = string
scale_up = object({
environment_variables = map(string)
iam_policy_json = string
additional_iam_policy_json = optional(string, null)
managed_policy = optional(object({
arn = string
}), null)
})
scale_down = object({
environment_variables = map(string)
iam_policy_json = string
})
})
| n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [scale\_down](#output\_scale\_down) | Scale-down Lambda resources. | +| [scale\_up](#output\_scale\_up) | Scale-up Lambda resources. | + diff --git a/modules/runner-stack/scale-runners/common-config.tf b/modules/runner-stack/scale-runners/common-config.tf new file mode 100644 index 0000000000..7c8a04d095 --- /dev/null +++ b/modules/runner-stack/scale-runners/common-config.tf @@ -0,0 +1,20 @@ +locals { + vpc_enabled = ( + length(var.config.lambda.vpc.subnet_ids) > 0 && + length(var.config.lambda.vpc.security_group_ids) > 0 + ) + + job_retry_config = var.config.job_retry.enabled ? { + enable = true + maxAttempts = var.config.job_retry.max_attempts + delayInSeconds = var.config.job_retry.delay_in_seconds + delayBackoff = var.config.job_retry.delay_backoff + queueUrl = var.config.job_retry.queue.url + } : {} + + min_runtime_defaults = { + windows = 15 + linux = 5 + osx = 20 + } +} diff --git a/modules/runner-stack/lambda-common-iam-policies.tf b/modules/runner-stack/scale-runners/lambda-iam-policies.tf similarity index 57% rename from modules/runner-stack/lambda-common-iam-policies.tf rename to modules/runner-stack/scale-runners/lambda-iam-policies.tf index d8d0f5968a..05922c734e 100644 --- a/modules/runner-stack/lambda-common-iam-policies.tf +++ b/modules/runner-stack/scale-runners/lambda-iam-policies.tf @@ -1,5 +1,4 @@ -# Shared IAM policies used by the control-plane Lambda functions. -data "aws_iam_policy_document" "lambda_assume_role_policy" { +data "aws_iam_policy_document" "lambda_assume_role" { statement { actions = ["sts:AssumeRole"] @@ -11,18 +10,17 @@ data "aws_iam_policy_document" "lambda_assume_role_policy" { } data "aws_iam_policy_document" "lambda_xray" { - count = var.observability.tracing.mode != null ? 1 : 0 + count = var.config.observability.tracing.mode != null ? 1 : 0 + statement { + sid = "AllowXRay" + effect = "Allow" actions = [ "xray:BatchGetTraces", "xray:GetTraceSummaries", "xray:PutTelemetryRecords", - "xray:PutTraceSegments" - ] - effect = "Allow" - resources = [ - "*" + "xray:PutTraceSegments", ] - sid = "AllowXRay" + resources = ["*"] } } diff --git a/modules/runner-stack/scale-runners/outputs.tf b/modules/runner-stack/scale-runners/outputs.tf new file mode 100644 index 0000000000..74d54d2101 --- /dev/null +++ b/modules/runner-stack/scale-runners/outputs.tf @@ -0,0 +1,17 @@ +output "scale_up" { + description = "Scale-up Lambda resources." + value = { + lambda = aws_lambda_function.scale_up + log_group = aws_cloudwatch_log_group.scale_up + role = aws_iam_role.scale_up + } +} + +output "scale_down" { + description = "Scale-down Lambda resources." + value = { + lambda = aws_lambda_function.scale_down + log_group = aws_cloudwatch_log_group.scale_down + role = aws_iam_role.scale_down + } +} diff --git a/modules/runner-stack/scale-down-iam-policies.tf b/modules/runner-stack/scale-runners/scale-down-iam-policies.tf similarity index 74% rename from modules/runner-stack/scale-down-iam-policies.tf rename to modules/runner-stack/scale-runners/scale-down-iam-policies.tf index 60a2aff111..c61e8dc68b 100644 --- a/modules/runner-stack/scale-down-iam-policies.tf +++ b/modules/runner-stack/scale-runners/scale-down-iam-policies.tf @@ -1,4 +1,3 @@ -# IAM policies attached to the scale-down Lambda role. data "aws_iam_policy_document" "scale_down_common" { statement { effect = "Allow" @@ -7,13 +6,13 @@ data "aws_iam_policy_document" "scale_down_common" { "ssm:GetParameters", ] resources = [ - var.github.app_parameters.key_base64.arn, - var.github.app_parameters.id.arn, + var.config.github.app_parameters.key_base64.arn, + var.config.github.app_parameters.id.arn, ] } dynamic "statement" { - for_each = local.kms_key == null ? [] : [local.kms_key] + for_each = var.config.ssm.kms_key == null ? [] : [var.config.ssm.kms_key] content { effect = "Allow" @@ -26,7 +25,7 @@ data "aws_iam_policy_document" "scale_down_common" { data "aws_iam_policy_document" "scale_down" { source_policy_documents = [ data.aws_iam_policy_document.scale_down_common.json, - local.provider.scale_down.iam_policy_json, + var.runner_provider.scale_down.iam_policy_json, ] } diff --git a/modules/runner-stack/scale-runners/scale-down.tf b/modules/runner-stack/scale-runners/scale-down.tf new file mode 100644 index 0000000000..f9fb7fcd3f --- /dev/null +++ b/modules/runner-stack/scale-runners/scale-down.tf @@ -0,0 +1,114 @@ +resource "aws_lambda_function" "scale_down" { + s3_bucket = var.config.lambda.artifact.s3.bucket + s3_key = var.config.lambda.artifact.s3.key + s3_object_version = var.config.lambda.artifact.s3.object_version + filename = var.config.lambda.artifact.s3.bucket == null ? var.config.lambda.artifact.zip : null + source_code_hash = var.config.lambda.artifact.s3.bucket == null ? filebase64sha256(var.config.lambda.artifact.zip) : null + function_name = "${var.config.prefix}-scale-down" + role = aws_iam_role.scale_down.arn + handler = "index.scaleDownHandler" + runtime = var.config.lambda.runtime + timeout = var.config.scale_down.timeout + tags = var.config.scale_down.tags.lambda + memory_size = var.config.scale_down.memory_size + architectures = [var.config.lambda.architecture] + + environment { + variables = merge(var.runner_provider.scale_down.environment_variables, { + ENVIRONMENT = var.config.prefix + ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.observability.metrics.enable && var.config.observability.metrics.metric.enable_github_app_rate_limit + GHES_URL = var.config.github.enterprise_server.url + USER_AGENT = var.config.github.user_agent + LOG_LEVEL = upper(var.config.observability.logs.level) + MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.config.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.config.runner.os]) + NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = var.config.github.app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github.app_parameters.key_base64.name + POWERTOOLS_LOGGER_LOG_EVENT = var.config.observability.logs.level == "debug" ? "true" : "false" + SCALE_DOWN_CONFIG = jsonencode(var.config.scale_down.idle_config) + POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-scale-down" + POWERTOOLS_METRICS_NAMESPACE = var.config.observability.metrics.namespace + POWERTOOLS_TRACE_ENABLED = var.config.observability.tracing.mode != null + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.observability.tracing.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.observability.tracing.capture_error + RUNNER_PROVIDER_TYPE = var.runner_provider.type + }) + } + + dynamic "vpc_config" { + for_each = local.vpc_enabled ? [true] : [] + + content { + security_group_ids = var.config.lambda.vpc.security_group_ids + subnet_ids = var.config.lambda.vpc.subnet_ids + } + } + + dynamic "tracing_config" { + for_each = var.config.observability.tracing.mode != null ? [true] : [] + + content { + mode = var.config.observability.tracing.mode + } + } +} + +resource "aws_cloudwatch_log_group" "scale_down" { + name = "/aws/lambda/${aws_lambda_function.scale_down.function_name}" + retention_in_days = var.config.observability.logs.retention_in_days + kms_key_id = var.config.observability.logs.kms_key_id + log_group_class = var.config.observability.logs.class + tags = var.config.scale_down.tags.log_group +} + +resource "aws_cloudwatch_event_rule" "scale_down" { + name = "${var.config.prefix}-scale-down-rule" + schedule_expression = var.config.scale_down.schedule_expression + tags = var.config.scale_down.tags.resources +} + +resource "aws_cloudwatch_event_target" "scale_down" { + rule = aws_cloudwatch_event_rule.scale_down.name + arn = aws_lambda_function.scale_down.arn +} + +resource "aws_lambda_permission" "scale_down" { + statement_id = "AllowExecutionFromCloudWatch" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.scale_down.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.scale_down.arn +} + +resource "aws_iam_role" "scale_down" { + name = "${substr("${var.config.prefix}-scale-down-lambda", 0, 54)}-${substr(md5("${var.config.prefix}-scale-down-lambda"), 0, 8)}" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json + path = var.config.lambda.role.path + permissions_boundary = var.config.lambda.role.permissions_boundary + tags = var.config.scale_down.tags.resources +} + +resource "aws_iam_role_policy" "scale_down" { + name = "scale-down-policy" + role = aws_iam_role.scale_down.name + policy = data.aws_iam_policy_document.scale_down.json +} + +resource "aws_iam_role_policy" "scale_down_logging" { + name = "logging-policy" + role = aws_iam_role.scale_down.name + policy = data.aws_iam_policy_document.scale_down_logging.json +} + +resource "aws_iam_role_policy_attachment" "scale_down_vpc_execution_role" { + count = local.vpc_enabled ? 1 : 0 + role = aws_iam_role.scale_down.name + policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy" "scale_down_xray" { + count = var.config.observability.tracing.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.scale_down.name +} diff --git a/modules/runner-stack/scale-up-iam-policies.tf b/modules/runner-stack/scale-runners/scale-up-iam-policies.tf similarity index 68% rename from modules/runner-stack/scale-up-iam-policies.tf rename to modules/runner-stack/scale-runners/scale-up-iam-policies.tf index 6a45e1db17..2e64d54876 100644 --- a/modules/runner-stack/scale-up-iam-policies.tf +++ b/modules/runner-stack/scale-runners/scale-up-iam-policies.tf @@ -1,4 +1,3 @@ -# IAM policies attached to the scale-up Lambda role. data "aws_iam_policy_document" "scale_up_common" { statement { effect = "Allow" @@ -16,9 +15,9 @@ data "aws_iam_policy_document" "scale_up_common" { "ssm:GetParameters", ] resources = [ - var.github.app_parameters.key_base64.arn, - var.github.app_parameters.id.arn, - "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm.paths.root}/${var.ssm.paths.config}/*", + var.config.github.app_parameters.key_base64.arn, + var.config.github.app_parameters.id.arn, + "${var.config.ssm.config_path_arn}/*", ] } @@ -29,11 +28,11 @@ data "aws_iam_policy_document" "scale_up_common" { "sqs:GetQueueAttributes", "sqs:DeleteMessage", ] - resources = [var.queue.build.arn] + resources = [var.config.queue.build.arn] } dynamic "statement" { - for_each = local.kms_key == null ? [] : [local.kms_key] + for_each = var.config.ssm.kms_key == null ? [] : [var.config.ssm.kms_key] content { effect = "Allow" @@ -46,7 +45,7 @@ data "aws_iam_policy_document" "scale_up_common" { data "aws_iam_policy_document" "scale_up" { source_policy_documents = [ data.aws_iam_policy_document.scale_up_common.json, - local.provider.scale_up.iam_policy_json, + var.runner_provider.scale_up.iam_policy_json, ] } @@ -62,7 +61,7 @@ data "aws_iam_policy_document" "scale_up_logging" { } data "aws_iam_policy_document" "scale_up_job_retry_publish" { - count = local.job_retry_enabled ? 1 : 0 + count = var.config.job_retry.enabled ? 1 : 0 statement { effect = "Allow" @@ -70,6 +69,6 @@ data "aws_iam_policy_document" "scale_up_job_retry_publish" { "sqs:SendMessage", "sqs:GetQueueAttributes", ] - resources = [module.job_retry[0].job_retry_check_queue.arn] + resources = [var.config.job_retry.queue.arn] } } diff --git a/modules/runner-stack/scale-runners/scale-up.tf b/modules/runner-stack/scale-runners/scale-up.tf new file mode 100644 index 0000000000..51267b82b2 --- /dev/null +++ b/modules/runner-stack/scale-runners/scale-up.tf @@ -0,0 +1,145 @@ +resource "aws_lambda_function" "scale_up" { + s3_bucket = var.config.lambda.artifact.s3.bucket + s3_key = var.config.lambda.artifact.s3.key + s3_object_version = var.config.lambda.artifact.s3.object_version + filename = var.config.lambda.artifact.s3.bucket == null ? var.config.lambda.artifact.zip : null + source_code_hash = var.config.lambda.artifact.s3.bucket == null ? filebase64sha256(var.config.lambda.artifact.zip) : null + function_name = "${var.config.prefix}-scale-up" + role = aws_iam_role.scale_up.arn + handler = "index.scaleUpHandler" + runtime = var.config.lambda.runtime + timeout = var.config.scale_up.timeout + reserved_concurrent_executions = var.config.scale_up.reserved_concurrent_executions + memory_size = var.config.scale_up.memory_size + tags = var.config.scale_up.tags.lambda + architectures = [var.config.lambda.architecture] + + environment { + variables = merge(var.runner_provider.scale_up.environment_variables, { + DISABLE_RUNNER_AUTOUPDATE = var.config.runner.auto_update_disabled + ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral + ENABLE_JIT_CONFIG = var.config.runner.jit_config_enabled + ENABLE_JOB_QUEUED_CHECK = var.config.scale_up.job_queued_check_enabled + ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.observability.metrics.enable && var.config.observability.metrics.metric.enable_github_app_rate_limit + ENABLE_ORGANIZATION_RUNNERS = var.config.github.organization_runners + ENVIRONMENT = var.config.prefix + GHES_URL = var.config.github.enterprise_server.url + USER_AGENT = var.config.github.user_agent + LOG_LEVEL = upper(var.config.observability.logs.level) + MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.config.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.config.runner.os]) + NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = var.config.github.app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github.app_parameters.key_base64.name + POWERTOOLS_LOGGER_LOG_EVENT = var.config.observability.logs.level == "debug" ? "true" : "false" + POWERTOOLS_METRICS_NAMESPACE = var.config.observability.metrics.namespace + POWERTOOLS_TRACE_ENABLED = var.config.observability.tracing.mode != null + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.observability.tracing.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.observability.tracing.capture_error + RUNNER_LABELS = lower(join(",", var.config.runner.labels)) + RUNNER_GROUP_NAME = var.config.runner.group_name + RUNNER_NAME_PREFIX = var.config.runner.name_prefix + RUNNER_PROVIDER_TYPE = var.runner_provider.type + RUNNERS_MAXIMUM_COUNT = var.config.runner.maximum_count + POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-scale-up" + SSM_TOKEN_PATH = var.config.ssm.token_path + SSM_CONFIG_PATH = var.config.ssm.config_path + SSM_PARAMETER_STORE_TAGS = var.config.ssm.parameter_store_tags + JOB_RETRY_CONFIG = jsonencode(local.job_retry_config) + }) + } + + dynamic "vpc_config" { + for_each = local.vpc_enabled ? [true] : [] + + content { + security_group_ids = var.config.lambda.vpc.security_group_ids + subnet_ids = var.config.lambda.vpc.subnet_ids + } + } + + dynamic "tracing_config" { + for_each = var.config.observability.tracing.mode != null ? [true] : [] + + content { + mode = var.config.observability.tracing.mode + } + } +} + +resource "aws_cloudwatch_log_group" "scale_up" { + name = "/aws/lambda/${aws_lambda_function.scale_up.function_name}" + retention_in_days = var.config.observability.logs.retention_in_days + kms_key_id = var.config.observability.logs.kms_key_id + log_group_class = var.config.observability.logs.class + tags = var.config.scale_up.tags.log_group +} + +resource "aws_lambda_event_source_mapping" "scale_up" { + event_source_arn = var.config.queue.build.arn + function_name = aws_lambda_function.scale_up.arn + function_response_types = ["ReportBatchItemFailures"] + batch_size = var.config.queue.event_source_mapping.batch_size + maximum_batching_window_in_seconds = var.config.queue.event_source_mapping.maximum_batching_window_in_seconds + tags = var.config.scale_up.tags.event_source_mapping +} + +resource "aws_lambda_permission" "scale_runners_lambda" { + statement_id = "AllowExecutionFromSQS" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.scale_up.function_name + principal = "sqs.amazonaws.com" + source_arn = var.config.queue.build.arn +} + +resource "aws_iam_role" "scale_up" { + name = "${substr("${var.config.prefix}-scale-up-lambda", 0, 54)}-${substr(md5("${var.config.prefix}-scale-up-lambda"), 0, 8)}" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json + path = var.config.lambda.role.path + permissions_boundary = var.config.lambda.role.permissions_boundary + tags = var.config.scale_up.tags.resources +} + +resource "aws_iam_role_policy" "scale_up" { + name = "scale-up-policy" + role = aws_iam_role.scale_up.name + policy = data.aws_iam_policy_document.scale_up.json +} + +resource "aws_iam_role_policy" "scale_up_logging" { + name = "logging-policy" + role = aws_iam_role.scale_up.name + policy = data.aws_iam_policy_document.scale_up_logging.json +} + +resource "aws_iam_role_policy" "service_linked_role" { + count = var.runner_provider.scale_up.additional_iam_policy_json != null ? 1 : 0 + name = "service_linked_role" + role = aws_iam_role.scale_up.name + policy = var.runner_provider.scale_up.additional_iam_policy_json +} + +resource "aws_iam_role_policy_attachment" "scale_up_vpc_execution_role" { + count = local.vpc_enabled ? 1 : 0 + role = aws_iam_role.scale_up.name + policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy_attachment" "provider" { + count = var.runner_provider.scale_up.managed_policy != null ? 1 : 0 + role = aws_iam_role.scale_up.name + policy_arn = var.runner_provider.scale_up.managed_policy.arn +} + +resource "aws_iam_role_policy" "scale_up_xray" { + count = var.config.observability.tracing.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.scale_up.name +} + +resource "aws_iam_role_policy" "job_retry_sqs_publish" { + count = var.config.job_retry.enabled ? 1 : 0 + name = "publish-retry-check-sqs-policy" + role = aws_iam_role.scale_up.name + policy = data.aws_iam_policy_document.scale_up_job_retry_publish[0].json +} diff --git a/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl b/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl new file mode 100644 index 0000000000..ef7040226d --- /dev/null +++ b/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl @@ -0,0 +1,312 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/scale-runners-test" + } + } +} + +variables { + aws_partition = "aws-us-gov" + + config = { + prefix = "scale-runners-test" + lambda = { + artifact = { + zip = "runners.zip" + s3 = { + bucket = "lambda-artifacts" + key = "runners.zip" + object_version = "test-version" + } + } + runtime = "nodejs24.x" + architecture = "arm64" + vpc = { + subnet_ids = ["subnet-12345678"] + security_group_ids = ["sg-12345678"] + } + role = { + path = "/scale-runners-test/" + permissions_boundary = "arn:aws-us-gov:iam::123456789012:policy/permissions-boundary" + } + } + runner = { + os = "windows" + auto_update_disabled = true + ephemeral = true + jit_config_enabled = true + labels = ["Self-Hosted", "MicroVM"] + group_name = "test-group" + name_prefix = "test-runner-" + maximum_count = 7 + } + github = { + organization_runners = true + enterprise_server = { + url = "https://github.example.com" + ssl_verify = false + } + user_agent = "scale-runners-test" + app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/app-id" + } + } + } + queue = { + build = { + arn = "arn:aws-us-gov:sqs:us-gov-west-1:123456789012:build-queue" + } + event_source_mapping = { + batch_size = 25 + maximum_batching_window_in_seconds = 5 + } + } + ssm = { + token_path = "/github-runner/tokens" + config_path = "/github-runner/config" + config_path_arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/config" + parameter_store_tags = jsonencode([{ + Key = "Environment" + Value = "test" + }]) + kms_key = { + arn = "arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/scale-runners-test" + } + } + observability = { + logs = { + level = "debug" + retention_in_days = 14 + kms_key_id = "arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/logs" + class = "INFREQUENT_ACCESS" + } + tracing = { + mode = "Active" + capture_http_requests = true + capture_error = true + } + metrics = { + enable = true + namespace = "ScaleRunnersTest" + metric = { + enable_github_app_rate_limit = true + } + } + } + scale_up = { + memory_size = 768 + timeout = 90 + reserved_concurrent_executions = 2 + job_queued_check_enabled = true + tags = { + resources = { Scope = "scale-up" } + lambda = { Scope = "scale-up-lambda" } + log_group = { Scope = "scale-up-log" } + event_source_mapping = { Scope = "scale-up-queue" } + } + } + scale_down = { + memory_size = 640 + timeout = 75 + schedule_expression = "rate(10 minutes)" + minimum_running_time_in_minutes = null + idle_config = [{ + cron = "* * * * *" + timeZone = "UTC" + idleCount = 2 + evictionStrategy = "oldest_first" + }] + tags = { + resources = { Scope = "scale-down" } + lambda = { Scope = "scale-down-lambda" } + log_group = { Scope = "scale-down-log" } + } + } + job_retry = { + enabled = true + max_attempts = 4 + delay_in_seconds = 120 + delay_backoff = 3 + queue = { + arn = "arn:aws-us-gov:sqs:us-gov-west-1:123456789012:job-retry" + url = "https://sqs.us-gov-west-1.amazonaws.com/123456789012/job-retry" + } + } + } + + runner_provider = { + type = "microvm" + scale_up = { + environment_variables = { + MICROVM_CLUSTER = "runner-cluster" + } + iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["microvm:CreateRunner"] + Resource = ["*"] + }] + }) + additional_iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["iam:CreateServiceLinkedRole"] + Resource = ["*"] + }] + }) + managed_policy = { + arn = "arn:aws-us-gov:iam::123456789012:policy/microvm-scale-up" + } + } + scale_down = { + environment_variables = { + MICROVM_CLUSTER = "runner-cluster" + } + iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["microvm:DeleteRunner"] + Resource = ["*"] + }] + }) + } + } +} + +run "assembles_provider_neutral_scaling_control_plane" { + command = plan + + assert { + condition = ( + toset(keys(output.scale_up)) == toset(["lambda", "log_group", "role"]) + && toset(keys(output.scale_down)) == toset(["lambda", "log_group", "role"]) + ) + error_message = "Scale runners must expose nested scale-up and scale-down Lambda resource contracts." + } + + assert { + condition = ( + aws_lambda_function.scale_up.environment[0].variables["RUNNER_PROVIDER_TYPE"] == "microvm" + && aws_lambda_function.scale_up.environment[0].variables["MICROVM_CLUSTER"] == "runner-cluster" + && aws_lambda_function.scale_down.environment[0].variables["MICROVM_CLUSTER"] == "runner-cluster" + && !contains(keys(aws_lambda_function.scale_up.environment[0].variables), "INSTANCE_TYPES") + ) + error_message = "The common scaling Lambdas must select the provider and merge only its environment fragments." + } + + assert { + condition = ( + aws_lambda_function.scale_up.environment[0].variables["LOG_LEVEL"] == "DEBUG" + && aws_lambda_function.scale_up.environment[0].variables["RUNNER_LABELS"] == "self-hosted,microvm" + && aws_lambda_function.scale_up.environment[0].variables["MINIMUM_RUNNING_TIME_IN_MINUTES"] == "15" + && aws_lambda_function.scale_down.environment[0].variables["MINIMUM_RUNNING_TIME_IN_MINUTES"] == "15" + && aws_lambda_function.scale_up.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" + && jsondecode(aws_lambda_function.scale_up.environment[0].variables["SSM_PARAMETER_STORE_TAGS"])[0].Value == "test" + ) + error_message = "Scale runners must assemble shared runner, logging, TLS, lifetime, and Parameter Store environment variables." + } + + assert { + condition = ( + jsondecode(aws_lambda_function.scale_up.environment[0].variables["JOB_RETRY_CONFIG"]).queueUrl == "https://sqs.us-gov-west-1.amazonaws.com/123456789012/job-retry" + && jsondecode(aws_lambda_function.scale_up.environment[0].variables["JOB_RETRY_CONFIG"]).maxAttempts == "4" + && jsondecode(aws_lambda_function.scale_down.environment[0].variables["SCALE_DOWN_CONFIG"])[0].idleCount == 2 + ) + error_message = "Scale runners must preserve job-retry and idle-runner configuration at the Lambda boundary." + } + + assert { + condition = ( + aws_lambda_function.scale_up.memory_size == 768 + && aws_lambda_function.scale_up.timeout == 90 + && aws_lambda_function.scale_up.reserved_concurrent_executions == 2 + && aws_lambda_function.scale_down.memory_size == 640 + && aws_lambda_function.scale_down.timeout == 75 + && aws_cloudwatch_log_group.scale_up.log_group_class == "INFREQUENT_ACCESS" + && aws_cloudwatch_log_group.scale_down.retention_in_days == 14 + ) + error_message = "The child module must preserve Lambda sizing and log-group configuration." + } + + assert { + condition = ( + aws_lambda_event_source_mapping.scale_up.event_source_arn == "arn:aws-us-gov:sqs:us-gov-west-1:123456789012:build-queue" + && aws_lambda_event_source_mapping.scale_up.batch_size == 25 + && aws_lambda_event_source_mapping.scale_up.maximum_batching_window_in_seconds == 5 + && aws_lambda_event_source_mapping.scale_up.tags["Scope"] == "scale-up-queue" + && aws_cloudwatch_event_rule.scale_down.schedule_expression == "rate(10 minutes)" + && aws_cloudwatch_event_rule.scale_down.tags["Scope"] == "scale-down" + ) + error_message = "Scale-up queue and scale-down schedule triggers must remain owned by the child module." + } + + assert { + condition = ( + aws_lambda_function.scale_up.tags["Scope"] == "scale-up-lambda" + && aws_cloudwatch_log_group.scale_up.tags["Scope"] == "scale-up-log" + && aws_iam_role.scale_up.tags["Scope"] == "scale-up" + && aws_lambda_function.scale_down.tags["Scope"] == "scale-down-lambda" + && aws_cloudwatch_log_group.scale_down.tags["Scope"] == "scale-down-log" + && aws_iam_role.scale_down.tags["Scope"] == "scale-down" + ) + error_message = "Resolved component tag maps must reach the resources owned by scale runners." + } + + assert { + condition = ( + length(aws_lambda_function.scale_up.vpc_config) == 1 + && length(aws_lambda_function.scale_down.vpc_config) == 1 + && length(aws_iam_role_policy_attachment.scale_up_vpc_execution_role) == 1 + && length(aws_iam_role_policy_attachment.scale_down_vpc_execution_role) == 1 + && aws_iam_role_policy_attachment.scale_up_vpc_execution_role[0].policy_arn == "arn:aws-us-gov:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" + ) + error_message = "A complete Lambda VPC configuration must configure both Lambdas and their partition-aware execution policies." + } + + assert { + condition = ( + length(aws_lambda_function.scale_up.tracing_config) == 1 + && length(aws_lambda_function.scale_down.tracing_config) == 1 + && length(aws_iam_role_policy.scale_up_xray) == 1 + && length(aws_iam_role_policy.scale_down_xray) == 1 + ) + error_message = "Active tracing must configure both Lambdas and attach their X-Ray policies." + } + + assert { + condition = ( + length(aws_iam_role_policy.service_linked_role) == 1 + && length(aws_iam_role_policy_attachment.provider) == 1 + && aws_iam_role_policy_attachment.provider[0].policy_arn == "arn:aws-us-gov:iam::123456789012:policy/microvm-scale-up" + && length(aws_iam_role_policy.job_retry_sqs_publish) == 1 + ) + error_message = "Optional compute-provider and job-retry IAM integrations must be attached to the scale-up role." + } + + assert { + condition = ( + length(data.aws_iam_policy_document.scale_up.source_policy_documents) == 2 + && length(data.aws_iam_policy_document.scale_down.source_policy_documents) == 2 + && length(data.aws_iam_policy_document.scale_up_common.statement) == 4 + && length(data.aws_iam_policy_document.scale_down_common.statement) == 2 + && length(data.aws_iam_policy_document.scale_up_job_retry_publish) == 1 + ) + error_message = "Common, provider, KMS, and retry IAM policy fragments must retain their conditional plan shape." + } +} diff --git a/modules/runner-stack/scale-runners/variables.tf b/modules/runner-stack/scale-runners/variables.tf new file mode 100644 index 0000000000..07146601de --- /dev/null +++ b/modules/runner-stack/scale-runners/variables.tf @@ -0,0 +1,231 @@ +variable "aws_partition" { + description = "AWS partition used to construct IAM policy ARNs." + type = string + default = "aws" +} + +variable "config" { + description = <<-EOT + Provider-neutral scale-up and scale-down configuration assembled by runner-stack. + + - `prefix`: Prefix used to name scaling resources. + - `lambda.artifact.zip`: Resolved local control-plane archive. + - `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive. + - `lambda.artifact.s3.key`: Object key of the Lambda archive. + - `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive. + - `lambda.runtime`: Runtime used by both scaling Lambdas. + - `lambda.architecture`: Instruction-set architecture used by both scaling Lambdas. + - `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration. + - `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration. + - `lambda.role.path`: IAM path used for the scaling Lambda roles. + - `lambda.role.permissions_boundary`: Optional permissions boundary for the scaling Lambda roles. + - `runner.os`: Runner operating system used for the minimum-runtime default. + - `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. + - `runner.ephemeral`: Registers runners in ephemeral mode. + - `runner.jit_config_enabled`: Enables or disables just-in-time runner configuration. + - `runner.labels`: Labels supplied when a runner is registered. + - `runner.group_name`: GitHub runner group used during registration. + - `runner.name_prefix`: Prefix added to registered runner names. + - `runner.maximum_count`: Maximum number of runners for this stack. + - `github.organization_runners`: Registers organization runners when true. + - `github.enterprise_server.url`: Optional GitHub Enterprise Server URL. + - `github.enterprise_server.ssl_verify`: Enables TLS verification for GitHub Enterprise Server. + - `github.user_agent`: Optional User-Agent sent to GitHub. + - `github.app_parameters.key_base64`: Name and ARN of the GitHub App private-key parameter. + - `github.app_parameters.id`: Name and ARN of the GitHub App ID parameter. + - `queue.build.arn`: ARN of the build queue consumed by scale-up. + - `queue.event_source_mapping.batch_size`: Maximum records delivered per scale-up invocation. + - `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window. + - `ssm.token_path`: Parameter Store path used for registration tokens. + - `ssm.config_path`: Parameter Store path used for persistent runner configuration. + - `ssm.config_path_arn`: ARN of the persistent runner configuration path. + - `ssm.kms_key`: Optional KMS key used to decrypt shared parameters. + - `ssm.parameter_store_tags`: JSON-encoded tags applied to parameters created at runtime. + - `observability.logs`: Shared logging level, retention, encryption, and log-class configuration. + - `observability.tracing`: Lambda X-Ray and tracing-helper configuration. + - `observability.metrics`: Metrics enablement, namespace, and GitHub rate-limit metric configuration. + - `scale_up`: Scale-up Lambda sizing, concurrency, queued-job behavior, and resolved resource tag maps. + - `scale_up.tags.resources`: Tags for the scale-up IAM role and other component resources. + - `scale_up.tags.lambda`: Tags for the scale-up Lambda function. + - `scale_up.tags.log_group`: Tags for the scale-up log group. + - `scale_up.tags.event_source_mapping`: Tags for the build-queue event-source mapping. + - `scale_down`: Scale-down Lambda sizing, schedule, idle configuration, minimum runtime, and resolved resource tag maps. + - `scale_down.tags.resources`: Tags for the scale-down IAM role and EventBridge rule. + - `scale_down.tags.lambda`: Tags for the scale-down Lambda function. + - `scale_down.tags.log_group`: Tags for the scale-down log group. + - `job_retry.enabled`: Enables publishing retry checks from scale-up. + - `job_retry.queue`: Retry queue ARN and URL. Required when job retry is enabled. + - `job_retry.max_attempts`: Maximum queued-job retry attempts. + - `job_retry.delay_in_seconds`: Initial delay before checking the queued job. + - `job_retry.delay_backoff`: Multiplier applied to subsequent delays. + EOT + + type = object({ + prefix = string + lambda = object({ + artifact = object({ + zip = string + s3 = object({ + bucket = optional(string, null) + key = optional(string, null) + object_version = optional(string, null) + }) + }) + runtime = string + architecture = string + vpc = object({ + subnet_ids = list(string) + security_group_ids = list(string) + }) + role = object({ + path = string + permissions_boundary = optional(string, null) + }) + }) + runner = object({ + os = string + auto_update_disabled = bool + ephemeral = bool + jit_config_enabled = optional(bool, null) + labels = list(string) + group_name = string + name_prefix = string + maximum_count = number + }) + github = object({ + organization_runners = bool + enterprise_server = object({ + url = optional(string, null) + ssl_verify = bool + }) + user_agent = optional(string, null) + app_parameters = object({ + key_base64 = object({ + name = string + arn = string + }) + id = object({ + name = string + arn = string + }) + }) + }) + queue = object({ + build = object({ + arn = string + }) + event_source_mapping = object({ + batch_size = number + maximum_batching_window_in_seconds = number + }) + }) + ssm = object({ + token_path = string + config_path = string + config_path_arn = string + parameter_store_tags = string + kms_key = optional(object({ + arn = string + }), null) + }) + observability = object({ + logs = object({ + level = string + retention_in_days = number + kms_key_id = optional(string, null) + class = string + }) + tracing = object({ + mode = optional(string, null) + capture_http_requests = bool + capture_error = bool + }) + metrics = object({ + enable = bool + namespace = string + metric = object({ + enable_github_app_rate_limit = bool + }) + }) + }) + scale_up = object({ + memory_size = number + timeout = number + reserved_concurrent_executions = number + job_queued_check_enabled = bool + tags = object({ + resources = map(string) + lambda = map(string) + log_group = map(string) + event_source_mapping = map(string) + }) + }) + scale_down = object({ + memory_size = number + timeout = number + schedule_expression = string + minimum_running_time_in_minutes = optional(number, null) + idle_config = list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = string + })) + tags = object({ + resources = map(string) + lambda = map(string) + log_group = map(string) + }) + }) + job_retry = object({ + enabled = bool + max_attempts = number + delay_in_seconds = number + delay_backoff = number + queue = optional(object({ + arn = string + url = string + }), null) + }) + }) + + nullable = false + + validation { + condition = !var.config.job_retry.enabled || var.config.job_retry.queue != null + error_message = "config.job_retry.queue must be set when config.job_retry.enabled is true." + } +} + +variable "runner_provider" { + description = <<-EOT + Selected compute-provider integration for the scaling control plane. + + - `type`: Compute-provider discriminator supplied to both Lambdas. + - `scale_up.environment_variables`: Provider-specific scale-up environment variables. + - `scale_up.iam_policy_json`: Provider-specific IAM policy merged into the common scale-up policy. + - `scale_up.additional_iam_policy_json`: Optional additional provider policy attached separately to the scale-up role. + - `scale_up.managed_policy`: Optional provider-managed policy attachment. Object presence controls attachment creation. + - `scale_up.managed_policy.arn`: ARN of the provider-managed policy. The ARN may remain unknown until apply. + - `scale_down.environment_variables`: Provider-specific scale-down environment variables. + - `scale_down.iam_policy_json`: Provider-specific IAM policy merged into the common scale-down policy. + EOT + + type = object({ + type = string + scale_up = object({ + environment_variables = map(string) + iam_policy_json = string + additional_iam_policy_json = optional(string, null) + managed_policy = optional(object({ + arn = string + }), null) + }) + scale_down = object({ + environment_variables = map(string) + iam_policy_json = string + }) + }) + + nullable = false +} diff --git a/modules/runner-stack/scale-runners/versions.tf b/modules/runner-stack/scale-runners/versions.tf new file mode 100644 index 0000000000..da9769f550 --- /dev/null +++ b/modules/runner-stack/scale-runners/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/runner-stack/scale-up.tf b/modules/runner-stack/scale-up.tf deleted file mode 100644 index cc9e6d3281..0000000000 --- a/modules/runner-stack/scale-up.tf +++ /dev/null @@ -1,152 +0,0 @@ -locals { - job_retry_config = local.job_retry_enabled ? { - enable = var.job_retry.enabled - maxAttempts = var.job_retry.max_attempts - delayInSeconds = var.job_retry.delay_in_seconds - delayBackoff = var.job_retry.delay_backoff - queueUrl = module.job_retry[0].job_retry_check_queue.url - } : {} -} - -resource "aws_lambda_function" "scale_up" { - s3_bucket = var.lambda.s3.bucket != null ? var.lambda.s3.bucket : null - s3_key = var.lambda.s3.key != null ? var.lambda.s3.key : null - s3_object_version = var.lambda.s3.object_version != null ? var.lambda.s3.object_version : null - filename = var.lambda.s3.bucket == null ? local.lambda_zip : null - source_code_hash = var.lambda.s3.bucket == null ? filebase64sha256(local.lambda_zip) : null - function_name = "${var.prefix}-scale-up" - role = aws_iam_role.scale_up.arn - handler = "index.scaleUpHandler" - runtime = var.lambda.runtime - timeout = var.scale_up.timeout - reserved_concurrent_executions = var.scale_up.reserved_concurrent_executions - memory_size = var.scale_up.memory_size - tags = local.scale_up_lambda_tags - architectures = [var.lambda.architecture] - environment { - variables = merge(local.provider.scale_up.environment_variables, { - DISABLE_RUNNER_AUTOUPDATE = var.runner.auto_update_disabled - ENABLE_EPHEMERAL_RUNNERS = var.runner.ephemeral - ENABLE_JIT_CONFIG = var.runner.jit_config_enabled - ENABLE_JOB_QUEUED_CHECK = local.enable_job_queued_check - ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.observability.metrics.enable && var.observability.metrics.metric.enable_github_app_rate_limit - ENABLE_ORGANIZATION_RUNNERS = var.github.organization_runners - ENVIRONMENT = var.prefix - GHES_URL = var.github.enterprise_server.url - USER_AGENT = var.github.user_agent - LOG_LEVEL = upper(var.observability.logs.level) - MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner.os]) - NODE_TLS_REJECT_UNAUTHORIZED = var.github.enterprise_server.url != null && !var.github.enterprise_server.ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = var.github.app_parameters.id.name - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github.app_parameters.key_base64.name - POWERTOOLS_LOGGER_LOG_EVENT = var.observability.logs.level == "debug" ? "true" : "false" - POWERTOOLS_METRICS_NAMESPACE = var.observability.metrics.namespace - POWERTOOLS_TRACE_ENABLED = var.observability.tracing.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.observability.tracing.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.observability.tracing.capture_error - RUNNER_LABELS = lower(join(",", var.runner.labels)) - RUNNER_GROUP_NAME = var.runner.group_name - RUNNER_NAME_PREFIX = var.runner.name_prefix - RUNNER_PROVIDER_TYPE = local.provider.type - RUNNERS_MAXIMUM_COUNT = var.runner.maximum_count - POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-up" - SSM_TOKEN_PATH = local.token_path - SSM_CONFIG_PATH = "${var.ssm.paths.root}/${var.ssm.paths.config}" - SSM_PARAMETER_STORE_TAGS = local.parameter_store_tags - JOB_RETRY_CONFIG = jsonencode(local.job_retry_config) - }) - } - - dynamic "vpc_config" { - for_each = var.lambda.subnet_ids != null && var.lambda.security_group_ids != null ? [true] : [] - content { - security_group_ids = var.lambda.security_group_ids - subnet_ids = var.lambda.subnet_ids - } - } - - dynamic "tracing_config" { - for_each = var.observability.tracing.mode != null ? [true] : [] - content { - mode = var.observability.tracing.mode - } - } -} - -resource "aws_cloudwatch_log_group" "scale_up" { - name = "/aws/lambda/${aws_lambda_function.scale_up.function_name}" - retention_in_days = var.observability.logs.retention_in_days - kms_key_id = var.observability.logs.kms_key_id - log_group_class = var.observability.logs.class - tags = local.scale_up_log_tags -} - -resource "aws_lambda_event_source_mapping" "scale_up" { - event_source_arn = var.queue.build.arn - function_name = aws_lambda_function.scale_up.arn - function_response_types = ["ReportBatchItemFailures"] - batch_size = var.queue.event_source_mapping.batch_size - maximum_batching_window_in_seconds = var.queue.event_source_mapping.maximum_batching_window_in_seconds - tags = local.scale_up_queue_tags -} - -resource "aws_lambda_permission" "scale_runners_lambda" { - statement_id = "AllowExecutionFromSQS" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.scale_up.function_name - principal = "sqs.amazonaws.com" - source_arn = var.queue.build.arn -} - -resource "aws_iam_role" "scale_up" { - name = "${substr("${var.prefix}-scale-up-lambda", 0, 54)}-${substr(md5("${var.prefix}-scale-up-lambda"), 0, 8)}" - assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json - path = local.lambda_role_path - permissions_boundary = var.lambda.role.permissions_boundary - tags = local.scale_up_tags -} - -resource "aws_iam_role_policy" "scale_up" { - name = "scale-up-policy" - role = aws_iam_role.scale_up.name - policy = data.aws_iam_policy_document.scale_up.json -} - -resource "aws_iam_role_policy" "scale_up_logging" { - name = "logging-policy" - role = aws_iam_role.scale_up.name - policy = data.aws_iam_policy_document.scale_up_logging.json -} - -resource "aws_iam_role_policy" "service_linked_role" { - count = local.provider.scale_up.additional_iam_policy_json != null ? 1 : 0 - name = "service_linked_role" - role = aws_iam_role.scale_up.name - policy = local.provider.scale_up.additional_iam_policy_json -} - -resource "aws_iam_role_policy_attachment" "scale_up_vpc_execution_role" { - count = length(var.lambda.subnet_ids) > 0 ? 1 : 0 - role = aws_iam_role.scale_up.name - policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" -} - -resource "aws_iam_role_policy_attachment" "ami_id_ssm_parameter_read" { - count = local.provider.scale_up.managed_policy_enabled ? 1 : 0 - role = aws_iam_role.scale_up.name - policy_arn = local.provider.scale_up.managed_policy_arn -} - -resource "aws_iam_role_policy" "scale_up_xray" { - count = var.observability.tracing.mode != null ? 1 : 0 - name = "xray-policy" - policy = data.aws_iam_policy_document.lambda_xray[0].json - role = aws_iam_role.scale_up.name -} - -resource "aws_iam_role_policy" "job_retry_sqs_publish" { - count = local.job_retry_enabled ? 1 : 0 - name = "publish-retry-check-sqs-policy" - role = aws_iam_role.scale_up.name - policy = data.aws_iam_policy_document.scale_up_job_retry_publish[0].json -} diff --git a/modules/runner-stack/ssm-housekeeper-iam-policies.tf b/modules/runner-stack/ssm-housekeeper-iam-policies.tf deleted file mode 100644 index 7ae3dc1c6e..0000000000 --- a/modules/runner-stack/ssm-housekeeper-iam-policies.tf +++ /dev/null @@ -1,24 +0,0 @@ -# IAM policies attached to the SSM housekeeper Lambda role. -data "aws_iam_policy_document" "ssm_housekeeper" { - statement { - effect = "Allow" - actions = [ - "ssm:DeleteParameter", - "ssm:GetParametersByPath", - ] - resources = [ - "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${local.token_path}*", - ] - } -} - -data "aws_iam_policy_document" "ssm_housekeeper_logging" { - statement { - effect = "Allow" - actions = [ - "logs:CreateLogStream", - "logs:PutLogEvents", - ] - resources = ["${aws_cloudwatch_log_group.ssm_housekeeper.arn}*"] - } -} diff --git a/modules/runner-stack/ssm-housekeeper.tf b/modules/runner-stack/ssm-housekeeper.tf index f831619e56..18912392d4 100644 --- a/modules/runner-stack/ssm-housekeeper.tf +++ b/modules/runner-stack/ssm-housekeeper.tf @@ -1,118 +1,57 @@ locals { - ssm_housekeeper = { - schedule_expression = var.ssm.housekeeper.schedule_expression - state = var.ssm.housekeeper.state - lambda_timeout = var.ssm.housekeeper.lambda.timeout - lambda_memory_size = var.ssm.housekeeper.lambda.memory_size - config = { - tokenPath = var.ssm.housekeeper.config.tokenPath == null ? local.token_path : var.ssm.housekeeper.config.tokenPath - minimumDaysOld = var.ssm.housekeeper.config.minimumDaysOld - dryRun = var.ssm.housekeeper.config.dryRun - } - } + ssm_housekeeper_token_path = coalesce(var.ssm.housekeeper.config.tokenPath, local.token_path) + ssm_housekeeper_parameter_path_arn = ( + "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${local.ssm_housekeeper_token_path}*" + ) } -resource "aws_lambda_function" "ssm_housekeeper" { - s3_bucket = var.lambda.s3.bucket != null ? var.lambda.s3.bucket : null - s3_key = var.lambda.s3.key != null ? var.lambda.s3.key : null - s3_object_version = var.lambda.s3.object_version != null ? var.lambda.s3.object_version : null - filename = var.lambda.s3.bucket == null ? local.lambda_zip : null - source_code_hash = var.lambda.s3.bucket == null ? filebase64sha256(local.lambda_zip) : null - function_name = "${var.prefix}-ssm-housekeeper" - role = aws_iam_role.ssm_housekeeper.arn - handler = "index.ssmHousekeeper" - runtime = var.lambda.runtime - timeout = local.ssm_housekeeper.lambda_timeout - tags = local.ssm_housekeeper_lambda_tags - memory_size = local.ssm_housekeeper.lambda_memory_size - architectures = [var.lambda.architecture] +module "ssm_housekeeper" { + source = "./ssm-housekeeper" - environment { - variables = { - ENVIRONMENT = var.prefix - LOG_LEVEL = upper(var.observability.logs.level) - SSM_CLEANUP_CONFIG = jsonencode(local.ssm_housekeeper.config) - POWERTOOLS_SERVICE_NAME = "${var.prefix}-ssm-housekeeper" - POWERTOOLS_TRACE_ENABLED = var.observability.tracing.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.observability.tracing.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.observability.tracing.capture_error + config = { + prefix = var.prefix + aws_partition = var.aws_partition + schedule = { + expression = var.ssm.housekeeper.schedule_expression + state = var.ssm.housekeeper.state } - } - - dynamic "vpc_config" { - for_each = var.lambda.subnet_ids != null && var.lambda.security_group_ids != null ? [true] : [] - content { - security_group_ids = var.lambda.security_group_ids - subnet_ids = var.lambda.subnet_ids + cleanup = { + token_path = local.ssm_housekeeper_token_path + parameter_path_arn = local.ssm_housekeeper_parameter_path_arn + minimum_days_old = var.ssm.housekeeper.config.minimumDaysOld + dry_run = var.ssm.housekeeper.config.dryRun } - } - - dynamic "tracing_config" { - for_each = var.observability.tracing.mode != null ? [true] : [] - content { - mode = var.observability.tracing.mode + lambda = { + artifact = { + zip = local.lambda_zip + s3 = var.lambda.s3 + } + runtime = var.lambda.runtime + architecture = var.lambda.architecture + memory_size = var.ssm.housekeeper.lambda.memory_size + timeout = var.ssm.housekeeper.lambda.timeout + vpc = { + subnet_ids = var.lambda.subnet_ids + security_group_ids = var.lambda.security_group_ids + } + role = { + path = local.lambda_role_path + permissions_boundary = var.lambda.role.permissions_boundary + } + } + observability = { + logs = { + level = var.observability.logs.level + retention_in_days = var.observability.logs.retention_in_days + kms_key_id = var.observability.logs.kms_key_id + class = var.observability.logs.class + } + tracing = var.observability.tracing + } + tags = { + resources = local.ssm_housekeeper_tags + lambda = local.ssm_housekeeper_lambda_tags + log_group = local.ssm_housekeeper_log_tags } } } - -resource "aws_cloudwatch_log_group" "ssm_housekeeper" { - name = "/aws/lambda/${aws_lambda_function.ssm_housekeeper.function_name}" - retention_in_days = var.observability.logs.retention_in_days - kms_key_id = var.observability.logs.kms_key_id - log_group_class = var.observability.logs.class - tags = local.ssm_housekeeper_log_tags -} - -resource "aws_cloudwatch_event_rule" "ssm_housekeeper" { - name = "${var.prefix}-ssm-housekeeper" - schedule_expression = local.ssm_housekeeper.schedule_expression - tags = local.ssm_housekeeper_tags - state = local.ssm_housekeeper.state -} - -resource "aws_cloudwatch_event_target" "ssm_housekeeper" { - rule = aws_cloudwatch_event_rule.ssm_housekeeper.name - arn = aws_lambda_function.ssm_housekeeper.arn -} - -resource "aws_lambda_permission" "ssm_housekeeper" { - statement_id = "AllowExecutionFromCloudWatch" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.ssm_housekeeper.function_name - principal = "events.amazonaws.com" - source_arn = aws_cloudwatch_event_rule.ssm_housekeeper.arn -} - -resource "aws_iam_role" "ssm_housekeeper" { - name = "${substr("${var.prefix}-ssm-hk-lambda", 0, 54)}-${substr(md5("${var.prefix}-ssm-hk-lambda"), 0, 8)}" - description = "Lambda role for SSM Housekeeper (${var.prefix})" - assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json - path = local.lambda_role_path - permissions_boundary = var.lambda.role.permissions_boundary - tags = local.ssm_housekeeper_tags -} - -resource "aws_iam_role_policy" "ssm_housekeeper" { - name = "ssm-policy" - role = aws_iam_role.ssm_housekeeper.name - policy = data.aws_iam_policy_document.ssm_housekeeper.json -} - -resource "aws_iam_role_policy" "ssm_housekeeper_logging" { - name = "logging-policy" - role = aws_iam_role.ssm_housekeeper.name - policy = data.aws_iam_policy_document.ssm_housekeeper_logging.json -} - -resource "aws_iam_role_policy_attachment" "ssm_housekeeper_vpc_execution_role" { - count = length(var.lambda.subnet_ids) > 0 ? 1 : 0 - role = aws_iam_role.ssm_housekeeper.name - policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" -} - -resource "aws_iam_role_policy" "ssm_housekeeper_xray" { - count = var.observability.tracing.mode != null ? 1 : 0 - name = "xray-policy" - policy = data.aws_iam_policy_document.lambda_xray[0].json - role = aws_iam_role.ssm_housekeeper.name -} diff --git a/modules/runner-stack/ssm-housekeeper/README.md b/modules/runner-stack/ssm-housekeeper/README.md new file mode 100644 index 0000000000..2a98b3d16d --- /dev/null +++ b/modules/runner-stack/ssm-housekeeper/README.md @@ -0,0 +1,57 @@ +# SSM housekeeper module + +> This module is treated as an internal module; breaking changes do not trigger a major release bump. + +This provider-neutral child module owns the Lambda function, EventBridge schedule, IAM policies, and CloudWatch log group used to remove expired runner registration parameters from Parameter Store. + +The module is an implementation detail of the experimental runner stack. It is composed by `runner-stack` and is not intended to be called directly. + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | >= 6.33 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_target.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_log_group.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ssm_housekeeper_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ssm_housekeeper_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambda_function.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_permission.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_iam_policy_document.lambda_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.ssm_housekeeper_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [config](#input\_config) | Provider-neutral SSM housekeeper configuration assembled by runner-stack.

- `prefix`: Prefix used to name the housekeeper resources.
- `aws_partition`: AWS partition used to construct IAM policy ARNs.
- `schedule.expression`: EventBridge schedule expression that invokes the housekeeper.
- `schedule.state`: State of the EventBridge rule.
- `cleanup.token_path`: Parameter Store token path supplied to the Lambda.
- `cleanup.parameter_path_arn`: IAM resource ARN matching `cleanup.token_path`.
- `cleanup.minimum_days_old`: Minimum parameter age before deletion.
- `cleanup.dry_run`: Reports eligible parameters without deleting them when true.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by the housekeeper Lambda.
- `lambda.architecture`: Instruction-set architecture used by the housekeeper Lambda.
- `lambda.memory_size`: Memory allocated to the housekeeper Lambda.
- `lambda.timeout`: Housekeeper Lambda timeout in seconds.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the housekeeper Lambda role.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the housekeeper role.
- `observability.logs`: Logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `tags.resources`: Tags for the housekeeper role and EventBridge rule.
- `tags.lambda`: Tags for the housekeeper Lambda function.
- `tags.log_group`: Tags for the housekeeper log group. |
object({
prefix = string
aws_partition = string
schedule = object({
expression = string
state = string
})
cleanup = object({
token_path = string
parameter_path_arn = string
minimum_days_old = number
dry_run = bool
})
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
})
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
})
| n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [housekeeper](#output\_housekeeper) | SSM housekeeper Lambda resources. | + diff --git a/modules/runner-stack/ssm-housekeeper/iam-policies.tf b/modules/runner-stack/ssm-housekeeper/iam-policies.tf new file mode 100644 index 0000000000..8599e378f6 --- /dev/null +++ b/modules/runner-stack/ssm-housekeeper/iam-policies.tf @@ -0,0 +1,48 @@ +data "aws_iam_policy_document" "lambda_assume_role" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "lambda_xray" { + count = var.config.observability.tracing.mode != null ? 1 : 0 + + statement { + sid = "AllowXRay" + effect = "Allow" + actions = [ + "xray:BatchGetTraces", + "xray:GetTraceSummaries", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments", + ] + resources = ["*"] + } +} + +data "aws_iam_policy_document" "ssm_housekeeper" { + statement { + effect = "Allow" + actions = [ + "ssm:DeleteParameter", + "ssm:GetParametersByPath", + ] + resources = [var.config.cleanup.parameter_path_arn] + } +} + +data "aws_iam_policy_document" "ssm_housekeeper_logging" { + statement { + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + resources = ["${aws_cloudwatch_log_group.ssm_housekeeper.arn}*"] + } +} diff --git a/modules/runner-stack/ssm-housekeeper/outputs.tf b/modules/runner-stack/ssm-housekeeper/outputs.tf new file mode 100644 index 0000000000..064f5a1ab1 --- /dev/null +++ b/modules/runner-stack/ssm-housekeeper/outputs.tf @@ -0,0 +1,8 @@ +output "housekeeper" { + description = "SSM housekeeper Lambda resources." + value = { + lambda = aws_lambda_function.ssm_housekeeper + log_group = aws_cloudwatch_log_group.ssm_housekeeper + role = aws_iam_role.ssm_housekeeper + } +} diff --git a/modules/runner-stack/ssm-housekeeper/ssm-housekeeper.tf b/modules/runner-stack/ssm-housekeeper/ssm-housekeeper.tf new file mode 100644 index 0000000000..bcafed201a --- /dev/null +++ b/modules/runner-stack/ssm-housekeeper/ssm-housekeeper.tf @@ -0,0 +1,119 @@ +locals { + vpc_enabled = ( + length(var.config.lambda.vpc.subnet_ids) > 0 && + length(var.config.lambda.vpc.security_group_ids) > 0 + ) + + cleanup_config = { + tokenPath = var.config.cleanup.token_path + minimumDaysOld = var.config.cleanup.minimum_days_old + dryRun = var.config.cleanup.dry_run + } +} + +resource "aws_lambda_function" "ssm_housekeeper" { + s3_bucket = var.config.lambda.artifact.s3.bucket + s3_key = var.config.lambda.artifact.s3.key + s3_object_version = var.config.lambda.artifact.s3.object_version + filename = var.config.lambda.artifact.s3.bucket == null ? var.config.lambda.artifact.zip : null + source_code_hash = var.config.lambda.artifact.s3.bucket == null ? filebase64sha256(var.config.lambda.artifact.zip) : null + function_name = "${var.config.prefix}-ssm-housekeeper" + role = aws_iam_role.ssm_housekeeper.arn + handler = "index.ssmHousekeeper" + runtime = var.config.lambda.runtime + timeout = var.config.lambda.timeout + tags = var.config.tags.lambda + memory_size = var.config.lambda.memory_size + architectures = [var.config.lambda.architecture] + + environment { + variables = { + ENVIRONMENT = var.config.prefix + LOG_LEVEL = upper(var.config.observability.logs.level) + SSM_CLEANUP_CONFIG = jsonencode(local.cleanup_config) + POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-ssm-housekeeper" + POWERTOOLS_TRACE_ENABLED = var.config.observability.tracing.mode != null + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.observability.tracing.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.observability.tracing.capture_error + } + } + + dynamic "vpc_config" { + for_each = local.vpc_enabled ? [true] : [] + + content { + security_group_ids = var.config.lambda.vpc.security_group_ids + subnet_ids = var.config.lambda.vpc.subnet_ids + } + } + + dynamic "tracing_config" { + for_each = var.config.observability.tracing.mode != null ? [true] : [] + + content { + mode = var.config.observability.tracing.mode + } + } +} + +resource "aws_cloudwatch_log_group" "ssm_housekeeper" { + name = "/aws/lambda/${aws_lambda_function.ssm_housekeeper.function_name}" + retention_in_days = var.config.observability.logs.retention_in_days + kms_key_id = var.config.observability.logs.kms_key_id + log_group_class = var.config.observability.logs.class + tags = var.config.tags.log_group +} + +resource "aws_cloudwatch_event_rule" "ssm_housekeeper" { + name = "${var.config.prefix}-ssm-housekeeper" + schedule_expression = var.config.schedule.expression + state = var.config.schedule.state + tags = var.config.tags.resources +} + +resource "aws_cloudwatch_event_target" "ssm_housekeeper" { + rule = aws_cloudwatch_event_rule.ssm_housekeeper.name + arn = aws_lambda_function.ssm_housekeeper.arn +} + +resource "aws_lambda_permission" "ssm_housekeeper" { + statement_id = "AllowExecutionFromCloudWatch" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.ssm_housekeeper.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.ssm_housekeeper.arn +} + +resource "aws_iam_role" "ssm_housekeeper" { + name = "${substr("${var.config.prefix}-ssm-hk-lambda", 0, 54)}-${substr(md5("${var.config.prefix}-ssm-hk-lambda"), 0, 8)}" + description = "Lambda role for SSM Housekeeper (${var.config.prefix})" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json + path = var.config.lambda.role.path + permissions_boundary = var.config.lambda.role.permissions_boundary + tags = var.config.tags.resources +} + +resource "aws_iam_role_policy" "ssm_housekeeper" { + name = "ssm-policy" + role = aws_iam_role.ssm_housekeeper.name + policy = data.aws_iam_policy_document.ssm_housekeeper.json +} + +resource "aws_iam_role_policy" "ssm_housekeeper_logging" { + name = "logging-policy" + role = aws_iam_role.ssm_housekeeper.name + policy = data.aws_iam_policy_document.ssm_housekeeper_logging.json +} + +resource "aws_iam_role_policy_attachment" "ssm_housekeeper_vpc_execution_role" { + count = local.vpc_enabled ? 1 : 0 + role = aws_iam_role.ssm_housekeeper.name + policy_arn = "arn:${var.config.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy" "ssm_housekeeper_xray" { + count = var.config.observability.tracing.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.ssm_housekeeper.name +} diff --git a/modules/runner-stack/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl b/modules/runner-stack/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl new file mode 100644 index 0000000000..38c04e14c5 --- /dev/null +++ b/modules/runner-stack/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl @@ -0,0 +1,240 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/ssm-housekeeper-test" + } + } + + mock_resource "aws_lambda_function" { + defaults = { + arn = "arn:aws:lambda:eu-west-1:123456789012:function:ssm-housekeeper-test" + } + } + + mock_resource "aws_cloudwatch_event_rule" { + defaults = { + arn = "arn:aws:events:eu-west-1:123456789012:rule/ssm-housekeeper-test" + } + } + + mock_resource "aws_cloudwatch_log_group" { + defaults = { + arn = "arn:aws:logs:eu-west-1:123456789012:log-group:/aws/lambda/ssm-housekeeper-test" + } + } +} + +variables { + config = { + prefix = "ssm-housekeeper-test" + aws_partition = "aws-us-gov" + schedule = { + expression = "rate(6 hours)" + state = "DISABLED" + } + cleanup = { + token_path = "/custom/runner/tokens" + parameter_path_arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/custom/runner/tokens*" + minimum_days_old = 7 + dry_run = true + } + lambda = { + artifact = { + zip = "unused-with-s3.zip" + s3 = { + bucket = "lambda-artifacts" + key = "control-plane/runners.zip" + object_version = "version-1" + } + } + runtime = "nodejs24.x" + architecture = "arm64" + memory_size = 384 + timeout = 45 + vpc = { + subnet_ids = [] + security_group_ids = [] + } + role = { + path = "/runner-stack/" + permissions_boundary = null + } + } + observability = { + logs = { + level = "debug" + retention_in_days = 30 + kms_key_id = null + class = "STANDARD" + } + tracing = { + mode = null + capture_http_requests = false + capture_error = false + } + } + tags = { + resources = { + Scope = "housekeeper" + } + lambda = { + Scope = "housekeeper" + Resource = "lambda" + } + log_group = { + Scope = "housekeeper" + Resource = "logs" + } + } + } +} + +run "configures_schedule_cleanup_and_outputs" { + command = plan + + assert { + condition = ( + aws_cloudwatch_event_rule.ssm_housekeeper.schedule_expression == "rate(6 hours)" && + aws_cloudwatch_event_rule.ssm_housekeeper.state == "DISABLED" + ) + error_message = "The housekeeper EventBridge rule must use the configured schedule and state." + } + + assert { + condition = ( + jsondecode(aws_lambda_function.ssm_housekeeper.environment[0].variables["SSM_CLEANUP_CONFIG"]).tokenPath == "/custom/runner/tokens" && + jsondecode(aws_lambda_function.ssm_housekeeper.environment[0].variables["SSM_CLEANUP_CONFIG"]).minimumDaysOld == 7 && + jsondecode(aws_lambda_function.ssm_housekeeper.environment[0].variables["SSM_CLEANUP_CONFIG"]).dryRun + ) + error_message = "The Lambda cleanup configuration must preserve the configured path override, age, and dry-run setting." + } + + assert { + condition = contains( + data.aws_iam_policy_document.ssm_housekeeper.statement[0].resources, + "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/custom/runner/tokens*", + ) + error_message = "The housekeeper IAM policy must authorize the same overridden Parameter Store path supplied to the Lambda." + } + + assert { + condition = toset(keys(output.housekeeper)) == toset(["lambda", "log_group", "role"]) + error_message = "The module must expose Lambda, log-group, and role resources through one nested housekeeper output." + } + + assert { + condition = ( + output.housekeeper.lambda.tags == tomap({ + Scope = "housekeeper" + Resource = "lambda" + }) && + output.housekeeper.log_group.tags == tomap({ + Scope = "housekeeper" + Resource = "logs" + }) && + output.housekeeper.role.tags == tomap({ + Scope = "housekeeper" + }) + ) + error_message = "Each nested output resource must retain its resolved component tags." + } + + assert { + condition = ( + length(aws_lambda_function.ssm_housekeeper.vpc_config) == 0 && + length(aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role) == 0 && + length(aws_lambda_function.ssm_housekeeper.tracing_config) == 0 && + length(aws_iam_role_policy.ssm_housekeeper_xray) == 0 + ) + error_message = "Empty VPC configuration and disabled tracing must not create their optional Lambda or IAM configuration." + } +} + +run "enables_vpc_and_xray_together" { + command = plan + + variables { + config = { + prefix = "ssm-housekeeper-vpc-test" + aws_partition = "aws-us-gov" + schedule = { + expression = "rate(1 day)" + state = "ENABLED" + } + cleanup = { + token_path = "/github-runner/tokens" + parameter_path_arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/tokens*" + minimum_days_old = 1 + dry_run = false + } + lambda = { + artifact = { + zip = "unused-with-s3.zip" + s3 = { + bucket = "lambda-artifacts" + key = "control-plane/runners.zip" + } + } + runtime = "nodejs24.x" + architecture = "arm64" + memory_size = 512 + timeout = 60 + vpc = { + subnet_ids = ["subnet-12345678"] + security_group_ids = ["sg-12345678"] + } + role = { + path = "/runner-stack/" + permissions_boundary = null + } + } + observability = { + logs = { + level = "info" + retention_in_days = 14 + kms_key_id = null + class = "STANDARD" + } + tracing = { + mode = "Active" + capture_http_requests = true + capture_error = true + } + } + tags = { + resources = {} + lambda = {} + log_group = {} + } + } + } + + assert { + condition = ( + length(aws_lambda_function.ssm_housekeeper.vpc_config) == 1 && + aws_lambda_function.ssm_housekeeper.vpc_config[0].subnet_ids == toset(["subnet-12345678"]) && + aws_lambda_function.ssm_housekeeper.vpc_config[0].security_group_ids == toset(["sg-12345678"]) && + length(aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role) == 1 && + aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role[0].policy_arn == "arn:aws-us-gov:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" + ) + error_message = "A complete VPC configuration must configure the Lambda and attach the partition-aware VPC execution policy." + } + + assert { + condition = ( + length(aws_lambda_function.ssm_housekeeper.tracing_config) == 1 && + aws_lambda_function.ssm_housekeeper.tracing_config[0].mode == "Active" && + length(aws_iam_role_policy.ssm_housekeeper_xray) == 1 && + aws_lambda_function.ssm_housekeeper.environment[0].variables["POWERTOOLS_TRACE_ENABLED"] == "true" && + aws_lambda_function.ssm_housekeeper.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" && + aws_lambda_function.ssm_housekeeper.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "true" + ) + error_message = "Active tracing must configure Lambda tracing, X-Ray IAM permissions, and tracing-helper environment variables." + } +} diff --git a/modules/runner-stack/ssm-housekeeper/variables.tf b/modules/runner-stack/ssm-housekeeper/variables.tf new file mode 100644 index 0000000000..792b7d75bb --- /dev/null +++ b/modules/runner-stack/ssm-housekeeper/variables.tf @@ -0,0 +1,88 @@ +variable "config" { + description = <<-EOT + Provider-neutral SSM housekeeper configuration assembled by runner-stack. + + - `prefix`: Prefix used to name the housekeeper resources. + - `aws_partition`: AWS partition used to construct IAM policy ARNs. + - `schedule.expression`: EventBridge schedule expression that invokes the housekeeper. + - `schedule.state`: State of the EventBridge rule. + - `cleanup.token_path`: Parameter Store token path supplied to the Lambda. + - `cleanup.parameter_path_arn`: IAM resource ARN matching `cleanup.token_path`. + - `cleanup.minimum_days_old`: Minimum parameter age before deletion. + - `cleanup.dry_run`: Reports eligible parameters without deleting them when true. + - `lambda.artifact.zip`: Resolved local control-plane archive. + - `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive. + - `lambda.artifact.s3.key`: Object key of the Lambda archive. + - `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive. + - `lambda.runtime`: Runtime used by the housekeeper Lambda. + - `lambda.architecture`: Instruction-set architecture used by the housekeeper Lambda. + - `lambda.memory_size`: Memory allocated to the housekeeper Lambda. + - `lambda.timeout`: Housekeeper Lambda timeout in seconds. + - `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration. + - `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration. + - `lambda.role.path`: IAM path used for the housekeeper Lambda role. + - `lambda.role.permissions_boundary`: Optional permissions boundary for the housekeeper role. + - `observability.logs`: Logging level, retention, encryption, and log-class configuration. + - `observability.tracing`: Lambda X-Ray and tracing-helper configuration. + - `tags.resources`: Tags for the housekeeper role and EventBridge rule. + - `tags.lambda`: Tags for the housekeeper Lambda function. + - `tags.log_group`: Tags for the housekeeper log group. + EOT + + type = object({ + prefix = string + aws_partition = string + schedule = object({ + expression = string + state = string + }) + cleanup = object({ + token_path = string + parameter_path_arn = string + minimum_days_old = number + dry_run = bool + }) + lambda = object({ + artifact = object({ + zip = string + s3 = object({ + bucket = optional(string, null) + key = optional(string, null) + object_version = optional(string, null) + }) + }) + runtime = string + architecture = string + memory_size = number + timeout = number + vpc = object({ + subnet_ids = list(string) + security_group_ids = list(string) + }) + role = object({ + path = string + permissions_boundary = optional(string, null) + }) + }) + observability = object({ + logs = object({ + level = string + retention_in_days = number + kms_key_id = optional(string, null) + class = string + }) + tracing = object({ + mode = optional(string, null) + capture_http_requests = bool + capture_error = bool + }) + }) + tags = object({ + resources = map(string) + lambda = map(string) + log_group = map(string) + }) + }) + + nullable = false +} diff --git a/modules/runner-stack/ssm-housekeeper/versions.tf b/modules/runner-stack/ssm-housekeeper/versions.tf new file mode 100644 index 0000000000..da9769f550 --- /dev/null +++ b/modules/runner-stack/ssm-housekeeper/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl index 26718a9f2a..2fe31cb426 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -122,7 +122,7 @@ run "plan_with_pool_enabled" { } assert { - condition = length(jsondecode(aws_lambda_function.scale_up.environment[0].variables["SSM_PARAMETER_STORE_TAGS"])) == 0 + condition = length(jsondecode(module.scale_runners.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"])) == 0 error_message = "Runtime Parameter Store tags must remain empty when no module or SSM tags are configured; EC2 bootstrap tags must not leak into them." } @@ -145,23 +145,26 @@ run "plan_with_pool_enabled" { } assert { - condition = aws_lambda_function.scale_up.environment[0].variables["RUNNER_PROVIDER_TYPE"] == "ec2" + condition = module.scale_runners.scale_up.lambda.environment[0].variables["RUNNER_PROVIDER_TYPE"] == "ec2" error_message = "Scale-up must receive the provider type from the selected provider." } assert { - condition = aws_lambda_function.scale_up.environment[0].variables["INSTANCE_TYPES"] == "m5.large" + condition = module.scale_runners.scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" error_message = "Scale-up must merge the EC2 environment fragment." } assert { - condition = aws_lambda_function.scale_down.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" + condition = module.scale_runners.scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" error_message = "Scale-down must merge the EC2 environment fragment." } assert { - condition = length(aws_iam_role_policy_attachment.ami_id_ssm_parameter_read) == 1 - error_message = "An external AMI SSM parameter must plan the scale-up policy attachment even when its policy ARN is not known yet." + condition = ( + toset(keys(module.scale_runners.scale_up)) == toset(["lambda", "log_group", "role"]) + && toset(keys(module.scale_runners.scale_down)) == toset(["lambda", "log_group", "role"]) + ) + error_message = "The scale-runners child module must forward the nested scale-up and scale-down resource contracts." } } @@ -345,12 +348,12 @@ run "job_retry_uses_common_runner_configuration_identity" { } assert { - condition = module.job_retry[0].lambda.function.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "provider-neutral-" + condition = module.job_retry[0].lambda.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "provider-neutral-" error_message = "Job retry must receive the common runner-configuration name prefix." } assert { - condition = module.job_retry[0].lambda.function.function.reserved_concurrent_executions == 2 + condition = module.job_retry[0].lambda.function.reserved_concurrent_executions == 2 error_message = "Job retry must apply its configured Lambda reserved concurrency." } } diff --git a/modules/runner-stack/tests/tags.tftest.hcl b/modules/runner-stack/tests/tags.tftest.hcl index 4ecc72a971..534cf1887d 100644 --- a/modules/runner-stack/tests/tags.tftest.hcl +++ b/modules/runner-stack/tests/tags.tftest.hcl @@ -166,27 +166,22 @@ run "layered_component_tags" { command = plan assert { - condition = aws_lambda_function.scale_up.environment[0].variables["LOG_LEVEL"] == "DEBUG" + condition = module.scale_runners.scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "DEBUG" error_message = "The nested observability.logs.level value must configure the control-plane functions." } assert { - condition = aws_lambda_function.scale_up.tags == tomap({ + condition = module.scale_runners.scale_up.lambda.tags == tomap({ precedence = "scale-up" module = "yes" lambda = "yes" scale_up = "yes" - }) && aws_cloudwatch_log_group.scale_up.tags == tomap({ + }) && module.scale_runners.scale_up.log_group.tags == tomap({ precedence = "scale-up" module = "yes" log = "yes" scale_up = "yes" - }) && aws_lambda_event_source_mapping.scale_up.tags == tomap({ - precedence = "scale-up" - module = "yes" - queue = "yes" - scale_up = "yes" - }) && aws_iam_role.scale_up.tags == tomap({ + }) && module.scale_runners.scale_up.role.tags == tomap({ precedence = "scale-up" module = "yes" scale_up = "yes" @@ -195,21 +190,17 @@ run "layered_component_tags" { } assert { - condition = aws_lambda_function.scale_down.tags == tomap({ + condition = module.scale_runners.scale_down.lambda.tags == tomap({ precedence = "scale-down" module = "yes" lambda = "yes" scale_down = "yes" - }) && aws_cloudwatch_log_group.scale_down.tags == tomap({ + }) && module.scale_runners.scale_down.log_group.tags == tomap({ precedence = "scale-down" module = "yes" log = "yes" scale_down = "yes" - }) && aws_cloudwatch_event_rule.scale_down.tags == tomap({ - precedence = "scale-down" - module = "yes" - scale_down = "yes" - }) && aws_iam_role.scale_down.tags == tomap({ + }) && module.scale_runners.scale_down.role.tags == tomap({ precedence = "scale-down" module = "yes" scale_down = "yes" @@ -233,7 +224,7 @@ run "layered_component_tags" { ssm = "yes" parameter = "yes" }) && tomap({ - for tag in jsondecode(aws_lambda_function.scale_up.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + for tag in jsondecode(module.scale_runners.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : tag.Key => tag.Value }) == tomap({ precedence = "ssm-parameter" @@ -245,19 +236,19 @@ run "layered_component_tags" { } assert { - condition = aws_lambda_function.ssm_housekeeper.tags == tomap({ + condition = module.ssm_housekeeper.housekeeper.lambda.tags == tomap({ precedence = "ssm-housekeeper" module = "yes" lambda = "yes" ssm = "yes" housekeeper = "yes" - }) && aws_cloudwatch_log_group.ssm_housekeeper.tags == tomap({ + }) && module.ssm_housekeeper.housekeeper.log_group.tags == tomap({ precedence = "ssm-housekeeper" module = "yes" log = "yes" ssm = "yes" housekeeper = "yes" - }) && aws_iam_role.ssm_housekeeper.tags == tomap({ + }) && module.ssm_housekeeper.housekeeper.role.tags == tomap({ precedence = "ssm-housekeeper" module = "yes" ssm = "yes" @@ -286,7 +277,7 @@ run "layered_component_tags" { } assert { - condition = module.job_retry[0].lambda.function.function.tags == tomap({ + condition = module.job_retry[0].lambda.function.tags == tomap({ precedence = "job-retry" module = "yes" lambda = "yes" From 92e2f65f228f6173797431222f42b685e1e31a45 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 21:06:47 +0200 Subject: [PATCH 26/27] refactor(multi-runner): decouple EC2 compute provider --- .github/workflows/terraform.yml | 4 +- .../internal/compute-provider-refactor.md | 78 +++-- modules/compute-providers/ec2/README.md | 28 +- modules/compute-providers/ec2/outputs.tf | 54 ++-- .../policies.tf => policies-runner.tf} | 17 +- .../ec2/runner-role/README.md | 53 ---- .../ec2/runner-role/outputs.tf | 14 - .../ec2/runner-role/tests/contract.tftest.hcl | 114 -------- .../ec2/runner-role/variables.tf | 60 ---- .../ec2/runner-role/versions.tf | 10 - .../ec2/tests/provider.tftest.hcl | 68 +++-- modules/compute-providers/ec2/variables.tf | 7 +- modules/multi-runner/README.md | 16 +- modules/multi-runner/main.tf | 7 - modules/multi-runner/multi-runner-config.tf | 44 +-- modules/multi-runner/queues.tf | 12 +- modules/multi-runner/runners.experimental.tf | 183 ++++++++++++ modules/multi-runner/runners.tf | 185 ------------ .../tests/provider-routing.tftest.hcl | 89 +++--- .../multi-runner/variables.experimental.tf | 23 +- modules/runner-stack/README.md | 6 +- modules/runner-stack/compute-provider.tf | 12 + modules/runner-stack/ec2.tf | 9 +- modules/runner-stack/job-retry.tf | 90 +++--- modules/runner-stack/job-retry/README.md | 2 +- .../runner-stack/job-retry/iam-policies.tf | 12 +- modules/runner-stack/job-retry/job-retry.tf | 112 ++++--- .../job-retry/tests/job-retry.tftest.hcl | 272 +++++++++++------ modules/runner-stack/job-retry/variables.tf | 273 +++++++++--------- modules/runner-stack/outputs.tf | 2 +- modules/runner-stack/pool.tf | 8 +- modules/runner-stack/runner-role.tf | 36 +-- modules/runner-stack/scale-runners.tf | 14 +- .../computed-iam-inputs.tf | 2 - modules/runner-stack/tests/pool.tftest.hcl | 18 +- modules/runner-stack/tests/tags.tftest.hcl | 1 - ...ables.tf => variables.compute-provider.tf} | 21 +- 37 files changed, 912 insertions(+), 1044 deletions(-) rename modules/compute-providers/ec2/{runner-role/policies.tf => policies-runner.tf} (94%) delete mode 100644 modules/compute-providers/ec2/runner-role/README.md delete mode 100644 modules/compute-providers/ec2/runner-role/outputs.tf delete mode 100644 modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl delete mode 100644 modules/compute-providers/ec2/runner-role/variables.tf delete mode 100644 modules/compute-providers/ec2/runner-role/versions.tf create mode 100644 modules/multi-runner/runners.experimental.tf create mode 100644 modules/runner-stack/compute-provider.tf rename modules/runner-stack/{compute-provider-variables.tf => variables.compute-provider.tf} (96%) diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 8a25d1a57a..e5c2a54425 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -86,9 +86,9 @@ jobs: "lambda", "multi-runner", "compute-providers/ec2", - "compute-providers/ec2/runner-role", "runner-binaries-syncer", "runner-stack", + "runner-stack/job-retry", "runner-stack/scale-runners", "runner-stack/ssm-housekeeper", "runners", @@ -221,11 +221,11 @@ jobs: - modules/runners - modules/multi-runner - modules/runner-stack + - modules/runner-stack/job-retry - modules/runner-stack/pool - modules/runner-stack/scale-runners - modules/runner-stack/ssm-housekeeper - modules/compute-providers/ec2 - - modules/compute-providers/ec2/runner-role defaults: run: working-directory: ${{ matrix.module }} diff --git a/docs/modules/internal/compute-provider-refactor.md b/docs/modules/internal/compute-provider-refactor.md index 329a0abfda..b3615217cd 100644 --- a/docs/modules/internal/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -2,7 +2,7 @@ !!! warning "Experimental opt-in" - The provider-oriented Terraform interface is experimental. It is enabled only by setting `experimental.multi_runner_config_v2`. Its schema can change before it becomes stable. Existing `multi_runner_config` deployments do not opt in and continue to use the legacy implementation. + The provider-oriented Terraform interface is experimental. It is enabled for the whole module instance when `experimental.multi_runner_config_v2` is non-empty. Its schema can change before it becomes stable. When that map is empty, existing `multi_runner_config` deployments continue to use the unchanged legacy implementation. When it is non-empty, only v2 configurations are used and `multi_runner_config` is ignored. ## Why this refactor exists @@ -16,33 +16,45 @@ The implementation is split into orchestration, provider-neutral control-plane c | Layer | Owns | | --- | --- | -| `multi-runner` | Stable-to-canonical normalization, configuration keys, build queues, webhook matching, and runner-binary discovery. | +| `multi-runner` | Module-level v1/v2 mode selection, canonical normalization, configuration keys, build queues, webhook matching, and runner-binary discovery. | | `runner-stack` | Provider dispatch, internal component wiring, shared runner configuration in SSM, and the common runner role and policy attachments. | | `runner-stack/scale-runners` | Provider-neutral scale-up and scale-down Lambdas, schedules and queue integration, and their execution roles and policies. | | `runner-stack/pool` | Optional scheduled runner-pool resources and their Lambda and IAM wiring. | | `runner-stack/job-retry` | Optional queued-job retry resources and their Lambda and IAM wiring. | | `runner-stack/ssm-housekeeper` | Parameter Store cleanup Lambda, schedule, logging, and IAM resources. | -| `compute-providers/` | Provider-specific resources, runner-role policy requirements, and the IAM and environment-variable fragments consumed by the common control plane. | +| `compute-providers/` | Provider-specific resources, runner-role policy requirements, and the IAM and environment-variable fragments consumed by the common control plane. | The EC2 provider currently owns the instance profile, launch template, security group, AMI and bootstrap parameters, runner log groups, EC2 policy statements, and EC2 Lambda environment variables. EC2 is the only implemented Terraform compute provider today. The modules below `runner-stack` are internal implementation boundaries, not standalone public modules. Callers opt into the experimental interface through `experimental.multi_runner_config_v2`; `multi-runner` calls `runner-stack`, which composes the internal modules. Their direct input and output contracts may change while v2 remains experimental. -`runner-stack` passes the canonical `compute_provider.ec2` configuration to the EC2 module as one nested `config` object. It also passes the provider-neutral `runner`, `github`, `ssm`, and `observability` objects without expanding them back into prefixed scalar inputs. The EC2 runner-role policy module consumes the same provider `config` and shared `ssm` boundaries. This keeps ownership visible at every module boundary and gives future compute providers an equivalent contract to implement. +`runner-stack` selects a compute provider from the single populated typed block under `compute_provider`. For example, `compute_provider = { ec2 = { ... } }` selects EC2; there is no separate `type` input that can disagree with the populated block. Exactly one provider block must be populated, and its presence must be known during planning because it determines the module graph. The stack passes `compute_provider.ec2` to the EC2 module as one nested `config` object. It also passes the provider-neutral `runner`, `github`, `ssm`, and `observability` objects without expanding them back into prefixed scalar inputs. This keeps ownership visible at the module boundary and gives future compute providers an equivalent contract to implement. -The common stack creates or selects the runner IAM role. A provider supplies the trust policy, inline policy documents, and optional managed-policy requirements; the common stack attaches them. This keeps role ownership provider-neutral while allowing each compute provider to define its permissions. +The common stack creates or selects the runner IAM role and owns the role trust relationship. The selected provider returns a single nested contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, along with component environment variables and provider resources. The common stack attaches those permission documents to the roles owned by the corresponding common components. A provider never creates or attaches a common IAM role. + +The trust relationship is deliberately resolved before the provider is called: + +1. `runner-stack` creates or selects the runner role using the service principal associated with the populated provider block. +2. The compute provider receives that role so it can create resources such as the EC2 instance profile and render `iam:PassRole` statements. +3. The provider returns its nested policy and environment-variable contract. +4. The common components attach the returned policies to the runner, scale-up, scale-down, and pool roles they own. + +Returning the runner trust policy from the same resource-bearing provider module would create a Terraform dependency cycle: the role would depend on the provider output while the provider already depends on the role input. Keeping trust establishment in `runner-stack` and attaching provider permissions afterward preserves a one-way graph. ## Phase 1 dispatch and compatibility -Phase 1 accepts stable and experimental configurations together, provided their keys do not overlap. +Phase 1 makes one module-level choice. An empty `experimental.multi_runner_config_v2` selects the stable v1 path; a non-empty map selects the experimental v2 path and ignores `multi_runner_config`. The maps are never merged, so one module instance cannot dispatch some configurations through v1 and others through v2. ```mermaid flowchart TD - Stable["multi_runner_config"] --> Normalize["Canonical internal configuration"] - Experimental["experimental.multi_runner_config_v2"] --> Normalize - Normalize --> Shared["Queues, webhook matching, binary discovery"] - Stable --> Legacy["module.runners[configuration]"] - Experimental --> Stack["module.runner_stacks[configuration]"] + Stable["multi_runner_config"] --> Select{"Is experimental.multi_runner_config_v2 non-empty?"} + Experimental["experimental.multi_runner_config_v2"] --> Select + Select -->|No| V1["Select and normalize v1"] + Select -->|Yes| V2["Select v2 and ignore v1"] + V1 --> Shared["Queues, webhook matching, binary discovery"] + V2 --> Shared + V1 --> Legacy["module.runners[configuration]"] + V2 --> Stack["module.runner_stacks[configuration]"] Stack --> Scaling["runner-stack/scale-runners"] Stack --> Pool["runner-stack/pool"] Stack --> Retry["runner-stack/job-retry"] @@ -52,41 +64,27 @@ flowchart TD Provider --> Pool ``` -Stable input is translated once into the canonical internal shape so shared resources can consume one representation. That translation does not change stable runner dispatch: +The selected input is normalized once so shared resources can consume one representation. Stable normalization does not change stable runner dispatch: -- A key present in `multi_runner_config` continues to call `modules/runners` at its historical `module.runners["configuration"]` address. +- When `experimental.multi_runner_config_v2` is empty, every key in `multi_runner_config` continues to call `modules/runners` at its historical `module.runners["configuration"]` address. - The stable module call receives the original v1 values for compatibility-sensitive inputs. - Stable queue tagging and the flat `runners_map` output remain unchanged. -- A key present in `experimental.multi_runner_config_v2` calls `modules/runner-stack` at `module.runner_stacks["configuration"]`. +- When `experimental.multi_runner_config_v2` is non-empty, every key in that map calls `modules/runner-stack` at `module.runner_stacks["configuration"]`; no resources are created from the ignored v1 map. - Experimental resources are exposed separately through the nested `runners_map_v2` output. -- Duplicate keys are rejected instead of silently changing a module address or output shape. +- The maps are not combined and duplicate keys do not need special precedence: v2 is the complete selected configuration whenever it is non-empty. -No state move is included in phase 1. Moving an existing key from the stable map to the experimental map changes its implementation address and must wait for the documented state-migration phase. +No state move is included in phase 1. Enabling v2 for a module instance that already manages v1 runners changes its implementation addresses; phase 1 does not migrate that state. Existing deployments should keep v2 empty until the documented state-migration phase. The current v2 path is intended for new or explicitly experimental deployments. ## Opting in -Only configurations inside the nested experimental object use the provider-oriented stack: +Set the complete runner configuration map inside the nested experimental object to use the provider-oriented stack: ```hcl module "multi_runner" { source = "github-aws-runners/github-runner/aws//modules/multi-runner" - # Existing configurations remain on modules/runners. - multi_runner_config = { - existing = { - runner_config = { - runner_os = "linux" - runner_architecture = "x64" - instance_types = ["m5.large"] - runners_maximum_count = 2 - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } - } - } - - # Setting this nested map is the explicit experimental opt-in. + # A non-empty v2 map is the module-level experimental opt-in. Any + # multi_runner_config value is ignored while this map is non-empty. experimental = { multi_runner_config_v2 = { arm = { @@ -97,7 +95,6 @@ module "multi_runner" { } compute_provider = { - type = "ec2" ec2 = { instance_types = ["m7g.large"] } @@ -114,15 +111,15 @@ module "multi_runner" { ## Inputs, tags, and outputs -The v2 object groups provider-neutral settings by owner: `runner`, `github`, `queue`, `lambda`, `scale_up`, `scale_down`, `pool`, `job_retry`, `ssm`, and `observability`. Backend settings live only under `compute_provider.`. +The v2 object groups provider-neutral settings by owner: `runner`, `github`, `queue`, `lambda`, `scale_up`, `scale_down`, `pool`, `job_retry`, `ssm`, and `observability`. Backend settings live only under `compute_provider.`. Exactly one typed provider block must be populated; that block selects the provider without a second discriminator field. Tags follow the same ownership model. Module tags are defaults; shared Lambda, queue, and log-group tags override those defaults; component and subcomponent tags are applied last. EC2 runtime tags belong under `compute_provider.ec2.tags`. The EC2 bootstrap tags required by the runner are protected inside the provider and are not propagated to common resources. Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and shared log-group tags. -Stable entries remain exclusively in `runners_map` and retain their flat output fields. Experimental entries are exposed exclusively through `runners_map_v2`; common resources are grouped under `runner`, `scale_up`, `scale_down`, and `pool`, while provider-specific resources remain under `provider.`. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, while EC2 launch-template and runner-log artifacts are under `runners_map_v2["configuration"].provider.ec2`. The `pool` value is null when no pool configuration is supplied. Keeping the maps separate prevents consumers from having to handle mixed entry schemas when v1 and v2 coexist. +In v1 mode, entries remain exclusively in `runners_map` and retain their flat output fields; `runners_map_v2` is empty. In v2 mode, entries are exposed exclusively through `runners_map_v2` and `runners_map` is empty. Common resources are grouped under `runner`, `scale_up`, `scale_down`, and `pool`, while provider-specific resources remain under `provider.`. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, while EC2 launch-template and runner-log artifacts are under `runners_map_v2["configuration"].provider.ec2`. The returned provider contract may also expose a computed `provider.type` derived from the populated input block; it is output metadata, not an input discriminator. The `pool` value is null when no pool configuration is supplied. -## Plan-time ownership wrappers +## Plan-time provider selection and ownership wrappers Terraform must know resource and dynamic-block shape during planning, even when an ARN is produced by another resource and remains unknown until apply. Optional inputs that enable IAM policies therefore use a caller-known object as the discriminator and keep the computed value in an `arn` leaf. The relevant configuration fragments are: @@ -134,7 +131,6 @@ ssm = { } compute_provider = { - type = "ec2" ec2 = { ami = { id_ssm_parameter = { @@ -148,15 +144,15 @@ compute_provider = { } ``` -The object literal tells Terraform that the corresponding policy exists; its `arn` may safely be computed. Values such as `observability.logs.kms_key_id`, which configure an existing resource without changing graph shape, remain nullable scalar inputs. +The populated `ec2` block tells Terraform which provider module exists and must therefore be known during planning. Within that block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. Values such as `observability.logs.kms_key_id`, which configure an existing resource without changing graph shape, remain nullable scalar inputs. For experimental multi-runner entries, set `ssm.kms_key` to the key that encrypts the shared GitHub App and runner parameters. The stable root `kms_key_arn` input continues to serve v1 and is not used as a graph-shape discriminator for v2. ## Migration phases -1. **Phase 1 — experimental opt-in:** Run stable and experimental configurations side by side. Stable resources and addresses do not move. +1. **Phase 1 — experimental opt-in:** Keep v1 unchanged when the v2 map is empty, or select v2 for the whole module instance when the v2 map is non-empty. Existing v1 deployments do not move and should not use the v2 switch as an in-place migration mechanism. 2. **Phase 2 — translate and migrate:** Deprecate the stable input, dispatch its translated representation through `runner-stack`, and provide tested `moved` blocks plus commands for addresses Terraform cannot move declaratively. 3. **Phase 3 — remove v1:** After a release window in which phase 2 is available, remove the stable input and flat output adapter in a breaking release. 4. **Future — retire `modules/runners`:** Handle direct consumers of the legacy module in a separate deprecation and migration effort. -A future compute provider must implement the same control-plane and runner-role contracts before it can be selected in Terraform. Adding a discriminator value without those resources is intentionally rejected. +A future compute provider must add a typed input block and return the same nested environment-variable, policy, and resource contract before it can be selected in Terraform. Populating more than one provider block, or selecting a block whose resources are not implemented, is intentionally rejected. diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index 3cb131343f..b734479b47 100644 --- a/modules/compute-providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -2,22 +2,22 @@ This internal module owns the EC2 compute implementation used by the common runner stack. It creates the runner launch template, security group, instance profile, EC2 bootstrap parameters, and runner log groups. -The module returns two deliberately separate outputs. `control_plane` contains the EC2-specific environment variables and IAM policy fragments required by scale-up, scale-down, and pool. `resources` contains only the EC2 launch template, runner log groups, and log-file configuration. The sibling `runner-role` module supplies the EC2 trust and runner-policy contract before the parent creates or selects the shared role. The parent module owns the shared runner role, policy attachments, Lambda functions, execution roles, schedules, queues, retry flow, and SSM housekeeper. +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 stack 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. A future provider must implement the same contracts before it can be selected by `compute_provider.type`. +EC2 is the only active compute provider. The parent stack selects it when `ec2` is the one populated typed block under `compute_provider`; no separate type input is required. A future provider must add its own typed block and implement the same contracts before it can be selected. ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -27,7 +27,7 @@ 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 | @@ -38,18 +38,27 @@ No modules. | [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 | | [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 | -| [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner stack.

- `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`: Provider runner-role setting consumed by the EC2 runner-role contract.
- `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 stack 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.
- `enable_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)
enable_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 | +| [aws\_region](#input\_aws\_region) | AWS region used to construct provider-owned runner policy ARNs. | `string` | n/a | yes | +| [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner stack.

- `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 stack 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.
- `enable_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)
enable_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 used to render runner bootstrap data.

- `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server. |
object({
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
})
| `{}` | no | | [observability](#input\_observability) | CloudWatch Logs settings used by EC2 runner log groups.

- `logs.retention_in_days`: Retention period for EC2 runner log groups.
- `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner log groups.
- `logs.tags`: Shared log-group tags that override module-level `tags`. |
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 name EC2 provider resources. | `string` | `"github-actions"` | no | @@ -60,7 +69,6 @@ No modules. ## Outputs | Name | Description | -|------|-------------| -| [control\_plane](#output\_control\_plane) | Provider-neutral control-plane contract containing the EC2 environment and IAM fragments. | -| [resources](#output\_resources) | EC2-specific resources and bootstrap logging details. | +| ---- | ----------- | +| [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-stack. | diff --git a/modules/compute-providers/ec2/outputs.tf b/modules/compute-providers/ec2/outputs.tf index d19c3ad7a2..558a8b9610 100644 --- a/modules/compute-providers/ec2/outputs.tf +++ b/modules/compute-providers/ec2/outputs.tf @@ -1,32 +1,36 @@ -output "control_plane" { - description = "Provider-neutral control-plane contract containing the EC2 environment and IAM fragments." +output "provider" { + description = "Nested EC2 compute-provider contract consumed by runner-stack." value = { type = "ec2" - scale_up = { - environment_variables = local.scale_up_environment_variables - 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 + environment_variables = { + scale_up = local.scale_up_environment_variables + scale_down = local.scale_down_environment_variables + pool = local.pool_environment_variables } - scale_down = { - environment_variables = local.scale_down_environment_variables - iam_policy_json = local.scale_down_iam_policy_json + policies = { + runner = { + inline_policies = local.runner_inline_policies + 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 + } } - pool = { - environment_variables = local.pool_environment_variables - 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 + resources = { + launch_template = aws_launch_template.runner + runners_log_groups = try(aws_cloudwatch_log_group.gh_runners, []) + logfiles = local.logfiles } } } - -output "resources" { - description = "EC2-specific resources and bootstrap logging details." - value = { - 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/ec2/runner-role/policies.tf b/modules/compute-providers/ec2/policies-runner.tf similarity index 94% rename from modules/compute-providers/ec2/runner-role/policies.tf rename to modules/compute-providers/ec2/policies-runner.tf index df5c5e74aa..785180cff3 100644 --- a/modules/compute-providers/ec2/runner-role/policies.tf +++ b/modules/compute-providers/ec2/policies-runner.tf @@ -1,4 +1,5 @@ -# EC2 runner-role trust and permission documents returned to runner-stack. +# EC2 runner permission documents returned to runner-stack for attachment to +# the common runner role. data "aws_caller_identity" "current" {} locals { @@ -7,18 +8,6 @@ locals { cloudwatch_config_arn = "${local.ssm_config_arn}/cloudwatch_agent_config_runner" } -data "aws_iam_policy_document" "assume_role" { - statement { - effect = "Allow" - actions = ["sts:AssumeRole"] - - principals { - type = "Service" - identifiers = ["ec2.amazonaws.com"] - } - } -} - data "aws_iam_policy_document" "ssm_parameters" { statement { effect = "Allow" @@ -176,7 +165,7 @@ data "aws_iam_policy_document" "cloudwatch" { } locals { - inline_policies = merge( + runner_inline_policies = merge( { ssm_parameters = { name = "runner-ssm-parameters" diff --git a/modules/compute-providers/ec2/runner-role/README.md b/modules/compute-providers/ec2/runner-role/README.md deleted file mode 100644 index 2de15b4638..0000000000 --- a/modules/compute-providers/ec2/runner-role/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# EC2 runner-role contract - -This internal module builds only the EC2-specific IAM documents required by a runner role. The common runner stack consumes these outputs before it creates or selects the shared role, then passes that role to the EC2 compute module. Keeping this module independent from the runner role and EC2 resources prevents a Terraform dependency cycle. - - -## Requirements - -| Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.3.0 | -| [aws](#requirement\_aws) | >= 6.33 | - -## Providers - -| Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | - -## Modules - -No modules. - -## Resources - -| Name | Type | -|------|------| -| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | -| [aws_iam_policy_document.assume_role](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.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 build IAM and SSM ARNs. | `string` | `"aws"` | no | -| [aws\_region](#input\_aws\_region) | AWS region containing the runner configuration parameters. | `string` | n/a | yes | -| [config](#input\_config) | EC2 configuration that controls provider-owned runner policies.

- `cloudwatch_agent.enabled`: Includes the CloudWatch agent policy in the runner-role contract.
- `binaries_syncer.enabled`: Includes access to the synchronized runner distribution.
- `binaries_syncer.s3`: S3 object containing the runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: ARN of the runner-distribution bucket.
- `binaries_syncer.s3.key`: Object key of the runner distribution.
- `ssm_enabled`: Includes Session Manager permissions in the runner-role contract. |
object({
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
arn = string
key = string
}), null)
}), {})
ssm_enabled = optional(bool, false)
})
| n/a | yes | -| [ssm](#input\_ssm) | Parameter Store configuration used by the EC2 runner-role policies.

- `paths.root`: Root path for this runner stack.
- `paths.tokens`: Path segment containing registration tokens and just-in-time configuration.
- `paths.config`: Path segment containing persistent runner configuration. |
object({
paths = object({
root = string
tokens = string
config = string
})
})
| n/a | yes | - -## Outputs - -| Name | Description | -|------|-------------| -| [assume\_role\_policy\_json](#output\_assume\_role\_policy\_json) | EC2 runner-role trust policy document. | -| [inline\_policies](#output\_inline\_policies) | EC2 runner-role inline policies keyed by stable provider policy identifiers. | -| [managed\_policy\_arns](#output\_managed\_policy\_arns) | EC2 provider-managed runner-role policy ARNs keyed by stable identifiers. | - diff --git a/modules/compute-providers/ec2/runner-role/outputs.tf b/modules/compute-providers/ec2/runner-role/outputs.tf deleted file mode 100644 index b516663286..0000000000 --- a/modules/compute-providers/ec2/runner-role/outputs.tf +++ /dev/null @@ -1,14 +0,0 @@ -output "assume_role_policy_json" { - description = "EC2 runner-role trust policy document." - value = data.aws_iam_policy_document.assume_role.json -} - -output "inline_policies" { - description = "EC2 runner-role inline policies keyed by stable provider policy identifiers." - value = local.inline_policies -} - -output "managed_policy_arns" { - description = "EC2 provider-managed runner-role policy ARNs keyed by stable identifiers." - value = {} -} diff --git a/modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl b/modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl deleted file mode 100644 index 792d4bc68f..0000000000 --- a/modules/compute-providers/ec2/runner-role/tests/contract.tftest.hcl +++ /dev/null @@ -1,114 +0,0 @@ -mock_provider "aws" { - mock_data "aws_caller_identity" { - defaults = { - account_id = "123456789012" - } - } - - mock_data "aws_iam_policy_document" { - defaults = { - json = "{}" - } - } -} - -override_data { - target = data.aws_iam_policy_document.assume_role - values = { - json = "{\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}" - } -} - -variables { - aws_region = "eu-west-1" - - config = { - cloudwatch_agent = { - enabled = true - } - binaries_syncer = { - enabled = true - s3 = { - arn = "arn:aws:s3:::runner-distribution" - key = "runner.zip" - } - } - ssm_enabled = true - } - - ssm = { - paths = { - root = "/github-runner/provider-test" - tokens = "tokens" - config = "config" - } - } -} - -run "exports_provider_owned_runner_role_contract" { - command = plan - - assert { - condition = strcontains(output.assume_role_policy_json, "ec2.amazonaws.com") - error_message = "The EC2 runner-role contract must expose its trust policy." - } - - assert { - condition = toset(keys(output.inline_policies)) == toset([ - "ssm_parameters", - "describe_tags", - "create_tags", - "terminate_self", - "session_manager", - "distribution_bucket", - "cloudwatch", - ]) - error_message = "The EC2 runner-role contract must expose stable keys for all enabled policies." - } - - assert { - condition = output.inline_policies.create_tags.name == "runner-create-tags" - error_message = "Policy names must be separate from stable contract keys." - } - - assert { - condition = length(output.managed_policy_arns) == 0 - error_message = "The EC2 role contract must not include common or user-managed policy attachments." - } -} - -run "omits_disabled_optional_policies" { - command = plan - - variables { - config = { - cloudwatch_agent = { - enabled = false - } - binaries_syncer = { - enabled = false - } - ssm_enabled = false - } - } - - assert { - condition = toset(keys(output.inline_policies)) == toset(["ssm_parameters", "describe_tags", "create_tags", "terminate_self"]) - error_message = "Disabled optional EC2 policies must be omitted without changing the stable base keys." - } -} - -run "requires_distribution_object_when_sync_is_enabled" { - command = plan - - variables { - config = { - binaries_syncer = { - enabled = true - s3 = null - } - } - } - - expect_failures = [var.config] -} diff --git a/modules/compute-providers/ec2/runner-role/variables.tf b/modules/compute-providers/ec2/runner-role/variables.tf deleted file mode 100644 index c3be735c2a..0000000000 --- a/modules/compute-providers/ec2/runner-role/variables.tf +++ /dev/null @@ -1,60 +0,0 @@ -variable "aws_partition" { - description = "AWS partition used to build IAM and SSM ARNs." - type = string - default = "aws" -} - -variable "aws_region" { - description = "AWS region containing the runner configuration parameters." - type = string -} - -variable "config" { - description = <<-EOT - EC2 configuration that controls provider-owned runner policies. - - - `cloudwatch_agent.enabled`: Includes the CloudWatch agent policy in the runner-role contract. - - `binaries_syncer.enabled`: Includes access to the synchronized runner distribution. - - `binaries_syncer.s3`: S3 object containing the runner distribution. Required when synchronization is enabled. - - `binaries_syncer.s3.arn`: ARN of the runner-distribution bucket. - - `binaries_syncer.s3.key`: Object key of the runner distribution. - - `ssm_enabled`: Includes Session Manager permissions in the runner-role contract. - EOT - - type = object({ - cloudwatch_agent = optional(object({ - enabled = optional(bool, true) - }), {}) - binaries_syncer = optional(object({ - enabled = optional(bool, true) - s3 = optional(object({ - arn = string - key = string - }), null) - }), {}) - ssm_enabled = optional(bool, false) - }) - - validation { - condition = !var.config.binaries_syncer.enabled || var.config.binaries_syncer.s3 != null - error_message = "config.binaries_syncer.s3 must be set when config.binaries_syncer.enabled is true." - } -} - -variable "ssm" { - description = <<-EOT - Parameter Store configuration used by the EC2 runner-role policies. - - - `paths.root`: Root path for this runner stack. - - `paths.tokens`: Path segment containing registration tokens and just-in-time configuration. - - `paths.config`: Path segment containing persistent runner configuration. - EOT - - type = object({ - paths = object({ - root = string - tokens = string - config = string - }) - }) -} diff --git a/modules/compute-providers/ec2/runner-role/versions.tf b/modules/compute-providers/ec2/runner-role/versions.tf deleted file mode 100644 index da9769f550..0000000000 --- a/modules/compute-providers/ec2/runner-role/versions.tf +++ /dev/null @@ -1,10 +0,0 @@ -terraform { - required_version = ">= 1.3.0" - - required_providers { - aws = { - source = "hashicorp/aws" - version = ">= 6.33" - } - } -} diff --git a/modules/compute-providers/ec2/tests/provider.tftest.hcl b/modules/compute-providers/ec2/tests/provider.tftest.hcl index 93c8cc8939..a6e50227c9 100644 --- a/modules/compute-providers/ec2/tests/provider.tftest.hcl +++ b/modules/compute-providers/ec2/tests/provider.tftest.hcl @@ -13,6 +13,12 @@ mock_provider "aws" { deprecation_time = "" } } + + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } } override_data { @@ -30,7 +36,8 @@ override_data { } variables { - prefix = "provider-test" + aws_region = "eu-west-1" + prefix = "provider-test" config = { vpc_id = "vpc-12345678" @@ -45,12 +52,17 @@ variables { kms_key = null } binaries_syncer = { - enabled = false - s3 = null + enabled = true + s3 = { + arn = "arn:aws:s3:::runner-distribution" + id = "runner-distribution" + key = "runner.zip" + } } cloudwatch_agent = { - enabled = false + enabled = true } + ssm_enabled = true managed_security_group_enabled = true } @@ -76,47 +88,47 @@ run "separates_control_plane_contract_from_ec2_resources" { command = plan assert { - condition = output.control_plane.type == "ec2" + condition = output.provider.type == "ec2" error_message = "The provider contract must identify EC2." } assert { - condition = output.control_plane.scale_up.environment_variables["INSTANCE_TYPES"] == "m5.large" + condition = output.provider.environment_variables.scale_up["INSTANCE_TYPES"] == "m5.large" error_message = "The provider contract must expose EC2 scale-up environment variables." } assert { - condition = output.control_plane.scale_down.environment_variables["RUNNER_BOOT_TIME_IN_MINUTES"] == 5 + condition = output.provider.environment_variables.scale_down["RUNNER_BOOT_TIME_IN_MINUTES"] == 5 error_message = "The provider contract must expose the EC2 scale-down boot grace period." } assert { - condition = strcontains(output.control_plane.scale_up.iam_policy_json, "ec2:RunInstances") + condition = strcontains(output.provider.policies.scale_up.iam_policy_json, "ec2:RunInstances") error_message = "The EC2 provider must own EC2 scale-up permissions." } assert { - condition = !strcontains(output.control_plane.scale_up.iam_policy_json, "sqs:ReceiveMessage") + condition = !strcontains(output.provider.policies.scale_up.iam_policy_json, "sqs:ReceiveMessage") error_message = "The EC2 provider must not own common build-queue permissions." } assert { - condition = strcontains(output.control_plane.pool.iam_policy_json, "iam:PassRole") + condition = strcontains(output.provider.policies.pool.iam_policy_json, "iam:PassRole") error_message = "The EC2 provider must expose pool permissions for its runner role." } assert { - condition = strcontains(output.control_plane.scale_up.iam_policy_json, "arn:aws:iam::123456789012:role/provider-test-runner") + condition = strcontains(output.provider.policies.scale_up.iam_policy_json, "arn:aws:iam::123456789012:role/provider-test-runner") error_message = "The EC2 provider must use the common runner role ARN for PassRole." } assert { - condition = output.control_plane.scale_up.managed_policy_enabled + condition = output.provider.policies.scale_up.managed_policy_enabled error_message = "An external AMI SSM parameter must enable the scale-up managed policy attachment at plan time." } assert { - condition = output.control_plane.pool.managed_policy_enabled + condition = output.provider.policies.pool.managed_policy_enabled error_message = "An external AMI SSM parameter must enable the pool managed policy attachment at plan time." } @@ -147,18 +159,26 @@ run "separates_control_plane_contract_from_ec2_resources" { } assert { - condition = !contains(keys(output.control_plane), "launch_template") - error_message = "The common control-plane contract must not expose EC2 resources." + condition = toset(keys(output.provider.policies)) == toset(["runner", "scale_up", "scale_down", "pool"]) + error_message = "The EC2 provider must expose policies grouped by their owning common component." } assert { - condition = toset(keys(output.resources)) == toset(["launch_template", "runners_log_groups", "logfiles"]) - error_message = "EC2-specific artifacts must be exposed only through the resources output." + condition = toset(keys(output.provider.policies.runner.inline_policies)) == toset([ + "ssm_parameters", + "describe_tags", + "create_tags", + "terminate_self", + "session_manager", + "distribution_bucket", + "cloudwatch", + ]) + error_message = "The EC2 provider must return the enabled runner permission documents." } assert { - condition = !contains(keys(output.resources), "scale_up") - error_message = "The EC2 resources output must not contain the common control-plane contract." + condition = toset(keys(output.provider.resources)) == toset(["launch_template", "runners_log_groups", "logfiles"]) + error_message = "EC2-specific artifacts must remain nested under provider resources." } assert { @@ -215,6 +235,16 @@ run "accepts_partial_typed_compute_options" { ) error_message = "Partial metadata options must retain typed defaults for omitted attributes." } + + assert { + condition = toset(keys(output.provider.policies.runner.inline_policies)) == toset([ + "ssm_parameters", + "describe_tags", + "create_tags", + "terminate_self", + ]) + error_message = "Disabled optional EC2 features must remove only their corresponding runner policies." + } } run "separates_provider_runner_and_ssm_tags" { diff --git a/modules/compute-providers/ec2/variables.tf b/modules/compute-providers/ec2/variables.tf index caee7f9b93..49ce691faa 100644 --- a/modules/compute-providers/ec2/variables.tf +++ b/modules/compute-providers/ec2/variables.tf @@ -4,6 +4,11 @@ variable "aws_partition" { default = "aws" } +variable "aws_region" { + description = "AWS region used to construct provider-owned runner policy ARNs." + type = string +} + variable "prefix" { description = "Prefix used to name EC2 provider resources." type = string @@ -63,7 +68,7 @@ variable "config" { - `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`: Provider runner-role setting consumed by the EC2 runner-role contract. + - `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. diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 1145f9d98e..05d340cb9b 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -12,31 +12,31 @@ See [Experimental compute-provider refactor](https://github-aws-runners.github.i The multi-runner module owns provider-neutral runner-configuration normalization, queues, shared runner-binary discovery, and webhook routing. Stable `multi_runner_config` entries continue to use the existing `modules/runners` module at their historical `module.runners["configuration"]` addresses. -Entries under `experimental.multi_runner_config_v2` opt into `modules/runner-stack` at `module.runner_stacks["configuration"]`. That stack coordinates internal provider-neutral modules for scale-up and scale-down, pool, job retry, and SSM housekeeping, and owns the common runner role and attachments. It dispatches typed provider configuration through `compute_provider.type`; the EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. The runner-stack child modules are implementation details rather than standalone public entry points. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. +When `experimental.multi_runner_config_v2` is non-empty, it opts the whole module instance into `modules/runner-stack` at `module.runner_stacks["configuration"]` and `multi_runner_config` is ignored. That stack coordinates internal provider-neutral modules for scale-up and scale-down, pool, job retry, and SSM housekeeping, and owns the common runner role and attachments. It selects the provider from the single populated typed block under `compute_provider`; the EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. The selected block must be known during planning because it determines the module graph. The runner-stack child modules are implementation details rather than standalone public entry points. EC2 remains the only Terraform-managed runner provider; microVM, CodeBuild, and other provider modules are future work. In v2, common runner-role configuration belongs under `runner.iam`; EC2's optional external instance-profile selection belongs under `compute_provider.ec2.instance_profile`. Provider policy documents are generated internally and attached by the common stack when it creates the role. An external role remains unmanaged and must already contain the required policies. -The two input maps can be used in the same module instance during phase 1, provided their runner configuration keys do not overlap. Shared queues, webhook routing, and runner-binary discovery use the union of both maps. A duplicate key is rejected so an existing configuration cannot silently switch module address or output contract. +Phase 1 uses exactly one input map per module instance. When `experimental.multi_runner_config_v2` is empty, `multi_runner_config` follows the unchanged legacy path. When it is non-empty, it is the complete selected runner map and `multi_runner_config` is ignored. The maps are not merged, so shared queues, webhook routing, binary discovery, runner modules, and outputs all use one consistent contract. ### V2 tagging For v2 runner configurations, top-level module `tags` are merged with configuration `tags`. Shared `lambda.tags`, `queue.tags`, and `observability.logs.tags` are then merged with component tags such as `runner.tags`, `scale_up.tags`, `scale_down.tags`, `pool.tags`, `job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags also apply to the configuration build queue and dead-letter queue owned by multi-runner. Stable v1 configurations keep their existing tag behavior unchanged. -The output contracts are separated so a single map never contains two incompatible entry schemas. Stable v1 entries remain exclusively in `runners_map` with their existing flat shape. Experimental v2 entries are exposed exclusively through `runners_map_v2`, grouped under `runner`, `scale_up`, `scale_down`, `pool`, and `provider`. Use `runners_map_v2["configuration"].runner.role` for the common runner role and `runners_map_v2["configuration"].scale_up.lambda`, `.log_group`, and `.role` for the scale-up resources. The same resource shape is used for `scale_down` and an enabled `pool`; `pool` is null when it is disabled. For EC2 configurations, launch-template and runner-log resources remain under `runners_map_v2["configuration"].provider.ec2`. When only one input version is configured, the other output map is empty. +The output contracts are separated so a single map never contains two incompatible entry schemas. In v1 mode, entries remain exclusively in `runners_map` with their existing flat shape and `runners_map_v2` is empty. In v2 mode, entries are exposed exclusively through `runners_map_v2`, grouped under `runner`, `scale_up`, `scale_down`, `pool`, and `provider`, while `runners_map` is empty. Use `runners_map_v2["configuration"].runner.role` for the common runner role and `runners_map_v2["configuration"].scale_up.lambda`, `.log_group`, and `.role` for the scale-up resources. The same resource shape is used for `scale_down` and an enabled `pool`; `pool` is null when it is disabled. For EC2 configurations, launch-template and runner-log resources remain under `runners_map_v2["configuration"].provider.ec2`. The provider output may expose a computed type derived from the populated provider block; it does not restore a separate input discriminator. ### Multi-runner v2 migration roadmap Here, v1 and v2 refer to `multi_runner_config` and `experimental.multi_runner_config_v2`, not module release versions. The migration is intentionally split across releases so configuration migration, state migration, and interface removal do not happen at the same time. -#### Phase 1 — Add v2 alongside v1 (current) +#### Phase 1 — Add v2 as a module-level opt-in (current) -Both input contracts are available in the same module release and can manage different runner configuration keys in one module instance. Existing `multi_runner_config` entries continue through the unchanged `modules/runners` implementation at `module.runners["configuration"]`, retaining their input contract, flat `runners_map` output, and Terraform addresses. Experimental `experimental.multi_runner_config_v2` entries use `module.runner_stacks["configuration"]` and the nested `runners_map_v2` output shape. +Both input contracts are available in the same module release, but only one is active in a module instance. An empty `experimental.multi_runner_config_v2` keeps every `multi_runner_config` entry on the unchanged `modules/runners` implementation at `module.runners["configuration"]`, retaining its input contract, flat `runners_map` output, and Terraform addresses. A non-empty v2 map selects only `module.runner_stacks["configuration"]` and the nested `runners_map_v2` output shape; any v1 map is ignored. -Compatibility guarantee: upgrading without moving a runner configuration from `multi_runner_config` to `experimental.multi_runner_config_v2` requires no state migration and must not move or replace legacy runner resources. Moving an existing configuration key to v2 is deliberately deferred until phase 2 supplies the state mapping. +Compatibility guarantee: upgrading while leaving `experimental.multi_runner_config_v2` empty requires no state migration and must not move or replace legacy runner resources. Phase 1 does not support mixing implementations or migrating an existing v1 deployment by enabling v2; existing deployments should wait for phase 2 state mapping. The v2 opt-in is for new or explicitly experimental deployments. #### Phase 2 — Translate v1 and migrate state -`multi_runner_config` remains accepted but is deprecated and translated to the v2 contract before dispatching through `runner-stack`. This phase will include tested `moved` blocks wherever Terraform can express the mapping and exact state-migration instructions for remaining addresses. The legacy flat output shape remains available as a compatibility adapter while users update configuration and output references. +`multi_runner_config` remains accepted but is deprecated and translated to the v2 contract before dispatching through `runner-stack`. Existing users can therefore migrate the implementation and state without first combining v1 and v2 inputs. This phase will include tested `moved` blocks wherever Terraform can express the mapping and exact state-migration instructions for remaining addresses. The legacy flat output shape remains available as a compatibility adapter while users update configuration and output references. Compatibility guarantee: users can migrate implementation state before rewriting their configuration. With equivalent inputs, the documented migration must produce a plan without unintended runner-resource destruction or replacement. @@ -174,7 +174,7 @@ module "multi-runner" { | [enable\_ami\_housekeeper](#input\_enable\_ami\_housekeeper) | Option to disable the lambda to clean up old AMIs. | `bool` | `false` | no | | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | | [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling. |
object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
})
| `{}` | no | -| [experimental](#input\_experimental) | Opt-in experimental features. Omit this object to retain only the stable `multi_runner_config` behavior. Experimental schemas can change before they become stable.

- `multi_runner_config_v2`: Provider-oriented runner configurations keyed by configuration name. Each entry is deployed with `runner-stack`; stable `multi_runner_config` entries continue to use the unchanged `runners` module.

Each `multi_runner_config_v2` entry supports the following nested fields:

- `tags`: Configuration-wide tags. These override module-level `tags`; narrower component and compute-provider tag maps take precedence for their resources.
- `runner.os`: Runner operating system.
- `runner.architecture`: Runner distribution architecture.
- `runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `runner.extra_labels`: Additional labels combined with `matcherConfig.labelMatchers`. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `runner.group_name`: GitHub runner group used during registration.
- `runner.name_prefix`: Prefix added to registered runner names.
- `runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `runner.run_as`: Operating-system user used when `run_as_root` is false.
- `runner.maximum_count`: Maximum number of runners for this configuration.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `ephemeral`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role.
- `runner.iam.path`: IAM path for the module-managed runner role.
- `runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `lambda.tags`: Shared tags for control-plane Lambda functions. Component tags override this map.
- `queue.delay_webhook_event`: Delay in seconds applied to webhook job messages.
- `queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds.
- `queue.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation. Null uses the module-level setting.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. Null uses the module-level setting.
- `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue.
- `queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue.
- `queue.tags`: Tags for configuration-owned queue resources. These override entry-level `tags`; component tags override this map.
- `scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null follows the runner mode default.
- `scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `scale_down.idle_config`: Time-based desired idle-runner configurations.
- `scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `pool.config[].size`: Desired number of runners for the schedule.
- `pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `ssm.tags`: Shared tags for SSM-related resources. These override entry-level `tags`.
- `ssm.kms_key`: Optional customer-managed KMS key used for temporary registration parameters. The wrapper's presence selects the KMS policy at plan time.
- `ssm.kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply.
- `ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `observability.logs.tags`: Shared tags for CloudWatch log groups. Component tags override this map.
- `compute_provider.type`: Compute-provider discriminator. The only currently implemented value is `ec2`.
- `compute_provider.ec2`: EC2-specific configuration. This object is required when `type` is `ec2`.
- `compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `compute_provider.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. The wrapper's presence selects external ownership at plan time.
- `compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `compute_provider.ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence selects the KMS policy at plan time.
- `compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `compute_provider.ec2.binaries_syncer.enabled`: Enables use of the module-level synchronized runner distribution from S3.
- `compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. Null uses the module-level value.
- `compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. Null uses the module-level value.
- `compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `compute_provider.ec2.placement.affinity`: Host affinity setting.
- `compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `compute_provider.ec2.placement.group_id`: Placement-group ID.
- `compute_provider.ec2.placement.group_name`: Placement-group name.
- `compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `compute_provider.ec2.tags`: Tags for runtime EC2 instances, volumes, network interfaces, and eligible Spot requests. These override entry-level tags and the generated runner `Name`; provider-required bootstrap tags take final precedence.
- `compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `matcherConfig.exactMatch`: Requires the job labels to exactly match a configured label group.
- `matcherConfig.bidirectionalLabelMatch`: Requires labels to match in both directions instead of allowing configured subsets.
- `matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels. |
object({
multi_runner_config_v2 = optional(map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = number
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

lambda = optional(object({
tags = optional(map(string), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
}), {})

scale_up = optional(object({
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
tags = optional(map(string), {})
}), {})

scale_down = optional(object({
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
tags = optional(map(string), {})
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
}), {})

pool = optional(object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

ssm = optional(object({
tags = optional(map(string), {})
kms_key = optional(object({
arn = string
}), null)
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

compute_provider = object({
type = string

ec2 = optional(object({
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)
}), {})
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)
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
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
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)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = 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
})), [])
use_dedicated_host = optional(bool, false)
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)
tags = optional(map(string), {})
}), null)

# Future provider references only. Do not uncomment until the Terraform
# resources for these compute providers are implemented.
#
# microvm = optional(object({
# environment_variables = optional(map(string), {})
# }), null)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
})), {})
})
| `{}` | no | +| [experimental](#input\_experimental) | Opt-in experimental features. Omit this object to retain only the stable `multi_runner_config` behavior. Experimental schemas can change before they become stable.

- `multi_runner_config_v2`: Provider-oriented runner configurations keyed by configuration name. A non-empty map selects v2 for the entire module and ignores `multi_runner_config`. When this map is empty, stable `multi_runner_config` entries continue to use the unchanged `runners` module.

Each `multi_runner_config_v2` entry supports the following nested fields:

- `tags`: Configuration-wide tags. These override module-level `tags`; narrower component and compute-provider tag maps take precedence for their resources.
- `runner.os`: Runner operating system.
- `runner.architecture`: Runner distribution architecture.
- `runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `runner.extra_labels`: Additional labels combined with `matcherConfig.labelMatchers`. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `runner.group_name`: GitHub runner group used during registration.
- `runner.name_prefix`: Prefix added to registered runner names.
- `runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `runner.run_as`: Operating-system user used when `run_as_root` is false.
- `runner.maximum_count`: Maximum number of runners for this configuration.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `ephemeral`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role.
- `runner.iam.path`: IAM path for the module-managed runner role.
- `runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `lambda.tags`: Shared tags for control-plane Lambda functions. Component tags override this map.
- `queue.delay_webhook_event`: Delay in seconds applied to webhook job messages.
- `queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds.
- `queue.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation. Null uses the module-level setting.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. Null uses the module-level setting.
- `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue.
- `queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue.
- `queue.tags`: Tags for configuration-owned queue resources. These override entry-level `tags`; component tags override this map.
- `scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null follows the runner mode default.
- `scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `scale_down.idle_config`: Time-based desired idle-runner configurations.
- `scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `pool.config[].size`: Desired number of runners for the schedule.
- `pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `ssm.tags`: Shared tags for SSM-related resources. These override entry-level `tags`.
- `ssm.kms_key`: Optional customer-managed KMS key used for temporary registration parameters. The wrapper's presence selects the KMS policy at plan time.
- `ssm.kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply.
- `ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `observability.logs.tags`: Shared tags for CloudWatch log groups. Component tags override this map.
- `compute_provider`: Typed compute-provider blocks. Exactly one block must be non-null, and the populated block selects the provider. Its presence must be known during planning; values inside it may remain unknown until apply.
- `compute_provider.ec2`: EC2-specific configuration. EC2 is the only provider currently implemented.
- `compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `compute_provider.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. The wrapper's presence selects external ownership at plan time.
- `compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `compute_provider.ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence selects the KMS policy at plan time.
- `compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `compute_provider.ec2.binaries_syncer.enabled`: Enables use of the module-level synchronized runner distribution from S3.
- `compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. Null uses the module-level value.
- `compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. Null uses the module-level value.
- `compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `compute_provider.ec2.placement.affinity`: Host affinity setting.
- `compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `compute_provider.ec2.placement.group_id`: Placement-group ID.
- `compute_provider.ec2.placement.group_name`: Placement-group name.
- `compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `compute_provider.ec2.tags`: Tags for runtime EC2 instances, volumes, network interfaces, and eligible Spot requests. These override entry-level tags and the generated runner `Name`; provider-required bootstrap tags take final precedence.
- `compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `matcherConfig.exactMatch`: Requires the job labels to exactly match a configured label group.
- `matcherConfig.bidirectionalLabelMatch`: Requires labels to match in both directions instead of allowing configured subsets.
- `matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels. |
object({
multi_runner_config_v2 = optional(map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = number
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

lambda = optional(object({
tags = optional(map(string), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
}), {})

scale_up = optional(object({
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
tags = optional(map(string), {})
}), {})

scale_down = optional(object({
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
tags = optional(map(string), {})
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
}), {})

pool = optional(object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

ssm = optional(object({
tags = optional(map(string), {})
kms_key = optional(object({
arn = string
}), null)
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

compute_provider = object({
ec2 = optional(object({
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)
}), {})
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)
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
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
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)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = 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
})), [])
use_dedicated_host = optional(bool, false)
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)
tags = optional(map(string), {})
}), null)

# Future provider references only. Do not uncomment until the Terraform
# resources for these compute providers are implemented.
#
# microvm = optional(object({
# environment_variables = optional(map(string), {})
# }), null)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
})), {})
})
| `{}` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. .However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | | [github\_app](#input\_github\_app) | GitHub app parameters, see your github app.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 66bb5d2bad..7465631949 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -16,11 +16,4 @@ resource "random_string" "random" { length = 24 special = false upper = false - - lifecycle { - precondition { - condition = length(local.duplicate_runner_config_keys) == 0 - error_message = "Runner configuration keys must be unique across multi_runner_config and experimental.multi_runner_config_v2. Duplicate keys: ${join(", ", sort(tolist(local.duplicate_runner_config_keys)))}." - } - } } diff --git a/modules/multi-runner/multi-runner-config.tf b/modules/multi-runner/multi-runner-config.tf index 58b1098d78..f60340b6f2 100644 --- a/modules/multi-runner/multi-runner-config.tf +++ b/modules/multi-runner/multi-runner-config.tf @@ -1,8 +1,12 @@ locals { + use_multi_runner_config_v2 = length(var.experimental.multi_runner_config_v2) > 0 + selected_multi_runner_config_v1 = local.use_multi_runner_config_v2 ? {} : var.multi_runner_config + selected_multi_runner_config_v2 = local.use_multi_runner_config_v2 ? var.experimental.multi_runner_config_v2 : {} + # Stable v1 remains an external flat contract. Normalize it once so common # multi-runner consumers can use the same ownership model as experimental v2. multi_runner_config_v1_as_v2 = { - for k, v in var.multi_runner_config : k => { + for k, v in local.selected_multi_runner_config_v1 : k => { tags = {} runner = { @@ -106,7 +110,6 @@ locals { } compute_provider = { - type = "ec2" ec2 = { metadata_options = v.runner_config.runner_metadata_options # Stable v1 keeps its nullable `id_ssm_parameter_arn` leaf. Translate @@ -169,15 +172,17 @@ locals { } } - duplicate_runner_config_keys = setintersection( - toset(keys(var.multi_runner_config)), - toset(keys(var.experimental.multi_runner_config_v2)), - ) + # A non-empty v2 map is a module-level opt-in. Never combine v1 and v2 in one + # deployment: this keeps module addresses and output contracts unambiguous. + multi_runner_config = local.use_multi_runner_config_v2 ? local.selected_multi_runner_config_v2 : local.multi_runner_config_v1_as_v2 - # Phase 1 keeps stable v1 and experimental v2 runner configurations side by - # side. A configuration key must belong to exactly one input so its module address and output - # contract remain unambiguous. - multi_runner_config = merge(local.multi_runner_config_v1_as_v2, var.experimental.multi_runner_config_v2) + sqs_tags = { + for k, v in local.multi_runner_config : k => merge( + var.tags, + v.tags, + v.queue.tags, + ) + } runner_extra_labels = { for k, v in local.multi_runner_config : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.extra_labels))) @@ -185,23 +190,26 @@ locals { runner_config = { for k, v in local.multi_runner_config : k => merge(v, { - id = aws_sqs_queue.queued_builds[k].id - arn = aws_sqs_queue.queued_builds[k].arn - url = aws_sqs_queue.queued_builds[k].url - runnerProvider = lower(trimspace(v.compute_provider.type)) - runner = merge(v.runner, { extra_labels = local.runner_extra_labels[k] }) + id = aws_sqs_queue.queued_builds[k].id + arn = aws_sqs_queue.queued_builds[k].arn + url = aws_sqs_queue.queued_builds[k].url + runnerProvider = one([ + for provider_type, provider_config in v.compute_provider : provider_type + if provider_config != null + ]) + runner = merge(v.runner, { extra_labels = local.runner_extra_labels[k] }) }) } # Preserve the exact stable v1 shape for the legacy module call. The v1-to-v2 # translation above is intentionally limited to shared multi-runner consumers. runner_extra_labels_v1 = { - for k, v in var.multi_runner_config : + for k, v in local.selected_multi_runner_config_v1 : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner_config.runner_extra_labels))) } runner_config_v1 = { - for k, v in var.multi_runner_config : k => merge( + for k, v in local.selected_multi_runner_config_v1 : k => merge( { id = aws_sqs_queue.queued_builds[k].id arn = aws_sqs_queue.queued_builds[k].arn @@ -217,7 +225,7 @@ locals { runner_config_v2 = { for k, v in local.runner_config : k => v - if contains(keys(var.experimental.multi_runner_config_v2), k) + if local.use_multi_runner_config_v2 } runner_matcher_config = { diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf index dd03cab8d2..8923d139fc 100644 --- a/modules/multi-runner/queues.tf +++ b/modules/multi-runner/queues.tf @@ -42,11 +42,7 @@ resource "aws_sqs_queue" "queued_builds" { kms_master_key_id = var.queue_encryption.kms_master_key_id kms_data_key_reuse_period_seconds = var.queue_encryption.kms_data_key_reuse_period_seconds - tags = contains(keys(var.experimental.multi_runner_config_v2), each.key) ? merge( - var.tags, - each.value.tags, - each.value.queue.tags, - ) : var.tags + tags = local.sqs_tags[each.key] } resource "aws_sqs_queue_policy" "build_queue_policy" { @@ -62,11 +58,7 @@ resource "aws_sqs_queue" "queued_builds_dlq" { sqs_managed_sse_enabled = var.queue_encryption.sqs_managed_sse_enabled kms_master_key_id = var.queue_encryption.kms_master_key_id kms_data_key_reuse_period_seconds = var.queue_encryption.kms_data_key_reuse_period_seconds - tags = contains(keys(var.experimental.multi_runner_config_v2), each.key) ? merge( - var.tags, - each.value.tags, - each.value.queue.tags, - ) : var.tags + tags = local.sqs_tags[each.key] } resource "aws_sqs_queue_policy" "build_queue_dlq_policy" { diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf new file mode 100644 index 0000000000..f230166fb1 --- /dev/null +++ b/modules/multi-runner/runners.experimental.tf @@ -0,0 +1,183 @@ +module "runner_stacks" { + source = "../runner-stack" + for_each = local.runner_config_v2 + + aws_region = var.aws_region + aws_partition = var.aws_partition + prefix = "${var.prefix}-${each.key}" + tags = merge(var.tags, each.value.tags) + + runner = { + os = each.value.runner.os + architecture = each.value.runner.architecture + boot_time_in_minutes = each.value.runner.boot_time_in_minutes + disable_default_labels = each.value.runner.disable_default_labels + labels = each.value.runner.disable_default_labels ? sort(distinct(each.value.runner.extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.os, each.value.runner.architecture], each.value.runner.extra_labels))) + group_name = each.value.runner.group_name + name_prefix = each.value.runner.name_prefix + run_as_root = each.value.runner.run_as_root + run_as = each.value.runner.run_as + maximum_count = each.value.runner.maximum_count + ephemeral = each.value.runner.ephemeral + jit_config_enabled = each.value.runner.jit_config_enabled + auto_update_disabled = each.value.runner.auto_update_disabled + tags = each.value.runner.tags + hooks = each.value.runner.hooks + iam = { + role = each.value.runner.iam.role + managed_policy_arns = each.value.runner.iam.managed_policy_arns + path = each.value.runner.iam.path != null ? each.value.runner.iam.path : var.role_path + permissions_boundary = each.value.runner.iam.permissions_boundary != null ? each.value.runner.iam.permissions_boundary : var.role_permissions_boundary + } + } + + github = { + app_parameters = local.github_app_parameters + organization_runners = each.value.github.organization_runners + enterprise_server = { + url = var.ghes_url + ssl_verify = var.ghes_ssl_verify + } + user_agent = var.user_agent + } + + queue = { + build = { + arn = each.value.arn + url = each.value.url + } + event_source_mapping = { + batch_size = coalesce(each.value.queue.event_source_mapping.batch_size, var.lambda_event_source_mapping_batch_size) + maximum_batching_window_in_seconds = coalesce(each.value.queue.event_source_mapping.maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) + } + tags = each.value.queue.tags + } + + lambda = { + zip = var.runners_lambda_zip + s3 = { + bucket = var.lambda_s3_bucket + key = var.runners_lambda_s3_key + object_version = var.runners_lambda_s3_object_version + } + runtime = var.lambda_runtime + architecture = var.lambda_architecture + subnet_ids = var.lambda_subnet_ids + security_group_ids = var.lambda_security_group_ids + tags = merge(var.lambda_tags, each.value.lambda.tags) + role = { + path = var.role_path + permissions_boundary = var.role_permissions_boundary + } + } + + scale_up = { + memory_size = var.scale_up_lambda_memory_size + timeout = var.runners_scale_up_lambda_timeout + reserved_concurrent_executions = each.value.scale_up.reserved_concurrent_executions + job_queued_check_enabled = each.value.scale_up.job_queued_check_enabled + tags = each.value.scale_up.tags + } + + scale_down = { + memory_size = var.scale_down_lambda_memory_size + timeout = var.runners_scale_down_lambda_timeout + schedule_expression = each.value.scale_down.schedule_expression + minimum_running_time_in_minutes = each.value.scale_down.minimum_running_time_in_minutes + idle_config = each.value.scale_down.idle_config + tags = each.value.scale_down.tags + } + + pool = { + config = each.value.pool.config + include_busy_runners = false + runner_owner = each.value.pool.runner_owner + tags = each.value.pool.tags + lambda = { + timeout = var.pool_lambda_timeout + reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions + } + } + + job_retry = each.value.job_retry + + ssm = { + paths = { + root = "${local.ssm_root_path}/${each.key}" + tokens = "${var.ssm_paths.runners}/tokens" + config = "${var.ssm_paths.runners}/config" + } + kms_key = each.value.ssm.kms_key + tags = each.value.ssm.tags + parameters = { + tags = merge(var.parameter_store_tags, each.value.ssm.parameters.tags) + } + housekeeper = { + schedule_expression = var.runners_ssm_housekeeper.schedule_expression + state = var.runners_ssm_housekeeper.enabled ? "ENABLED" : "DISABLED" + tags = each.value.ssm.housekeeper.tags + lambda = { + memory_size = var.runners_ssm_housekeeper.lambda_memory_size + timeout = var.runners_ssm_housekeeper.lambda_timeout + } + config = var.runners_ssm_housekeeper.config + } + } + + observability = { + logs = { + level = var.log_level + retention_in_days = var.logging_retention_in_days + kms_key_id = var.logging_kms_key_id + class = var.log_class + tags = each.value.observability.logs.tags + } + tracing = var.tracing_config + metrics = var.metrics + } + + compute_provider = { + ec2 = { + ami = each.value.compute_provider.ec2.ami + vpc_id = coalesce(each.value.compute_provider.ec2.vpc_id, var.vpc_id) + subnet_ids = coalesce(each.value.compute_provider.ec2.subnet_ids, var.subnet_ids) + instance_types = each.value.compute_provider.ec2.instance_types + instance_target_capacity_type = each.value.compute_provider.ec2.instance_target_capacity_type + instance_allocation_strategy = each.value.compute_provider.ec2.instance_allocation_strategy + instance_type_priorities = each.value.compute_provider.ec2.instance_type_priorities + instance_max_spot_price = each.value.compute_provider.ec2.instance_max_spot_price + block_device_mappings = each.value.compute_provider.ec2.block_device_mappings + ebs_optimized = each.value.compute_provider.ec2.ebs_optimized + instance_profile = each.value.compute_provider.ec2.instance_profile + instance_profile_path = var.instance_profile_path + enable_on_demand_failover_for_errors = each.value.compute_provider.ec2.enable_on_demand_failover_for_errors + scale_errors = each.value.compute_provider.ec2.scale_errors + managed_security_group_enabled = var.enable_managed_runner_security_group + detailed_monitoring_enabled = each.value.compute_provider.ec2.detailed_monitoring_enabled + ssm_enabled = each.value.compute_provider.ec2.ssm_enabled + egress_rules = var.runner_egress_rules + additional_security_group_ids = try(coalescelist(each.value.compute_provider.ec2.additional_security_group_ids, var.runner_additional_security_group_ids), []) + metadata_options = each.value.compute_provider.ec2.metadata_options + credit_specification = each.value.compute_provider.ec2.credit_specification + cpu_options = each.value.compute_provider.ec2.cpu_options + placement = each.value.compute_provider.ec2.placement + license_specifications = each.value.compute_provider.ec2.license_specifications + use_dedicated_host = each.value.compute_provider.ec2.use_dedicated_host + binaries_syncer = { + enabled = each.value.compute_provider.ec2.binaries_syncer.enabled + s3 = each.value.compute_provider.ec2.binaries_syncer.enabled ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.os}_${each.value.runner.architecture}"] : null + } + cloudwatch_agent = { + enabled = each.value.compute_provider.ec2.cloudwatch_agent.enabled + config = try(coalesce(each.value.compute_provider.ec2.cloudwatch_agent.config, var.cloudwatch_config), null) + } + log_files = each.value.compute_provider.ec2.log_files + user_data = each.value.compute_provider.ec2.user_data + key_name = var.key_name + tags = each.value.compute_provider.ec2.tags + + create_service_linked_role_spot = each.value.compute_provider.ec2.create_service_linked_role_spot + associate_public_ipv4_address = var.associate_public_ipv4_address + } + } +} diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 05b75f9851..410fb27969 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -129,188 +129,3 @@ module "runners" { metrics = var.metrics } - -module "runner_stacks" { - source = "../runner-stack" - for_each = local.runner_config_v2 - - aws_region = var.aws_region - aws_partition = var.aws_partition - prefix = "${var.prefix}-${each.key}" - tags = merge(var.tags, each.value.tags) - - runner = { - os = each.value.runner.os - architecture = each.value.runner.architecture - boot_time_in_minutes = each.value.runner.boot_time_in_minutes - disable_default_labels = each.value.runner.disable_default_labels - labels = each.value.runner.disable_default_labels ? sort(distinct(each.value.runner.extra_labels)) : sort(distinct(concat(["self-hosted", each.value.runner.os, each.value.runner.architecture], each.value.runner.extra_labels))) - group_name = each.value.runner.group_name - name_prefix = each.value.runner.name_prefix - run_as_root = each.value.runner.run_as_root - run_as = each.value.runner.run_as - maximum_count = each.value.runner.maximum_count - ephemeral = each.value.runner.ephemeral - jit_config_enabled = each.value.runner.jit_config_enabled - auto_update_disabled = each.value.runner.auto_update_disabled - tags = each.value.runner.tags - hooks = each.value.runner.hooks - iam = { - role = each.value.runner.iam.role - managed_policy_arns = each.value.runner.iam.managed_policy_arns - path = each.value.runner.iam.path != null ? each.value.runner.iam.path : var.role_path - permissions_boundary = each.value.runner.iam.permissions_boundary != null ? each.value.runner.iam.permissions_boundary : var.role_permissions_boundary - } - } - - github = { - app_parameters = local.github_app_parameters - organization_runners = each.value.github.organization_runners - enterprise_server = { - url = var.ghes_url - ssl_verify = var.ghes_ssl_verify - } - user_agent = var.user_agent - } - - queue = { - build = { - arn = each.value.arn - url = each.value.url - } - event_source_mapping = { - batch_size = coalesce(each.value.queue.event_source_mapping.batch_size, var.lambda_event_source_mapping_batch_size) - maximum_batching_window_in_seconds = coalesce(each.value.queue.event_source_mapping.maximum_batching_window_in_seconds, var.lambda_event_source_mapping_maximum_batching_window_in_seconds) - } - tags = each.value.queue.tags - } - - lambda = { - zip = var.runners_lambda_zip - s3 = { - bucket = var.lambda_s3_bucket - key = var.runners_lambda_s3_key - object_version = var.runners_lambda_s3_object_version - } - runtime = var.lambda_runtime - architecture = var.lambda_architecture - subnet_ids = var.lambda_subnet_ids - security_group_ids = var.lambda_security_group_ids - tags = merge(var.lambda_tags, each.value.lambda.tags) - role = { - path = var.role_path - permissions_boundary = var.role_permissions_boundary - } - } - - scale_up = { - memory_size = var.scale_up_lambda_memory_size - timeout = var.runners_scale_up_lambda_timeout - reserved_concurrent_executions = each.value.scale_up.reserved_concurrent_executions - job_queued_check_enabled = each.value.scale_up.job_queued_check_enabled - tags = each.value.scale_up.tags - } - - scale_down = { - memory_size = var.scale_down_lambda_memory_size - timeout = var.runners_scale_down_lambda_timeout - schedule_expression = each.value.scale_down.schedule_expression - minimum_running_time_in_minutes = each.value.scale_down.minimum_running_time_in_minutes - idle_config = each.value.scale_down.idle_config - tags = each.value.scale_down.tags - } - - pool = { - config = each.value.pool.config - include_busy_runners = false - runner_owner = each.value.pool.runner_owner - tags = each.value.pool.tags - lambda = { - timeout = var.pool_lambda_timeout - reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions - } - } - - job_retry = each.value.job_retry - - ssm = { - paths = { - root = "${local.ssm_root_path}/${each.key}" - tokens = "${var.ssm_paths.runners}/tokens" - config = "${var.ssm_paths.runners}/config" - } - kms_key = each.value.ssm.kms_key - tags = each.value.ssm.tags - parameters = { - tags = merge(var.parameter_store_tags, each.value.ssm.parameters.tags) - } - housekeeper = { - schedule_expression = var.runners_ssm_housekeeper.schedule_expression - state = var.runners_ssm_housekeeper.enabled ? "ENABLED" : "DISABLED" - tags = each.value.ssm.housekeeper.tags - lambda = { - memory_size = var.runners_ssm_housekeeper.lambda_memory_size - timeout = var.runners_ssm_housekeeper.lambda_timeout - } - config = var.runners_ssm_housekeeper.config - } - } - - observability = { - logs = { - level = var.log_level - retention_in_days = var.logging_retention_in_days - kms_key_id = var.logging_kms_key_id - class = var.log_class - tags = each.value.observability.logs.tags - } - tracing = var.tracing_config - metrics = var.metrics - } - - compute_provider = { - type = each.value.runnerProvider - ec2 = { - ami = each.value.compute_provider.ec2.ami - vpc_id = coalesce(each.value.compute_provider.ec2.vpc_id, var.vpc_id) - subnet_ids = coalesce(each.value.compute_provider.ec2.subnet_ids, var.subnet_ids) - instance_types = each.value.compute_provider.ec2.instance_types - instance_target_capacity_type = each.value.compute_provider.ec2.instance_target_capacity_type - instance_allocation_strategy = each.value.compute_provider.ec2.instance_allocation_strategy - instance_type_priorities = each.value.compute_provider.ec2.instance_type_priorities - instance_max_spot_price = each.value.compute_provider.ec2.instance_max_spot_price - block_device_mappings = each.value.compute_provider.ec2.block_device_mappings - ebs_optimized = each.value.compute_provider.ec2.ebs_optimized - instance_profile = each.value.compute_provider.ec2.instance_profile - instance_profile_path = var.instance_profile_path - enable_on_demand_failover_for_errors = each.value.compute_provider.ec2.enable_on_demand_failover_for_errors - scale_errors = each.value.compute_provider.ec2.scale_errors - managed_security_group_enabled = var.enable_managed_runner_security_group - detailed_monitoring_enabled = each.value.compute_provider.ec2.detailed_monitoring_enabled - ssm_enabled = each.value.compute_provider.ec2.ssm_enabled - egress_rules = var.runner_egress_rules - additional_security_group_ids = try(coalescelist(each.value.compute_provider.ec2.additional_security_group_ids, var.runner_additional_security_group_ids), []) - metadata_options = each.value.compute_provider.ec2.metadata_options - credit_specification = each.value.compute_provider.ec2.credit_specification - cpu_options = each.value.compute_provider.ec2.cpu_options - placement = each.value.compute_provider.ec2.placement - license_specifications = each.value.compute_provider.ec2.license_specifications - use_dedicated_host = each.value.compute_provider.ec2.use_dedicated_host - binaries_syncer = { - enabled = each.value.compute_provider.ec2.binaries_syncer.enabled - s3 = each.value.compute_provider.ec2.binaries_syncer.enabled ? local.runner_binaries_by_os_and_arch_map["${each.value.runner.os}_${each.value.runner.architecture}"] : null - } - cloudwatch_agent = { - enabled = each.value.compute_provider.ec2.cloudwatch_agent.enabled - config = try(coalesce(each.value.compute_provider.ec2.cloudwatch_agent.config, var.cloudwatch_config), null) - } - log_files = each.value.compute_provider.ec2.log_files - user_data = each.value.compute_provider.ec2.user_data - key_name = var.key_name - tags = each.value.compute_provider.ec2.tags - - create_service_linked_role_spot = each.value.compute_provider.ec2.create_service_linked_role_spot - associate_public_ipv4_address = var.associate_public_ipv4_address - } - } -} diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index dfc72336ba..af0f8f586c 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -180,7 +180,6 @@ run "experimental_v2_routes_through_provider_stack" { }] } compute_provider = { - type = "ec2" ec2 = { instance_types = ["m5.large"] binaries_syncer = { @@ -365,7 +364,6 @@ run "experimental_v2_layers_shared_and_component_tags" { } compute_provider = { - type = "ec2" ec2 = { instance_types = ["m5.large"] binaries_syncer = { @@ -474,7 +472,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } } -run "stable_v1_and_experimental_v2_coexist" { +run "experimental_v2_replaces_stable_v1" { command = plan variables { @@ -506,7 +504,6 @@ run "stable_v1_and_experimental_v2_coexist" { organization_runners = true } compute_provider = { - type = "ec2" ec2 = { instance_types = ["m7g.large"] binaries_syncer = { @@ -523,77 +520,51 @@ run "stable_v1_and_experimental_v2_coexist" { } assert { - condition = keys(local.runner_config_v1) == ["legacy"] && keys(local.runner_config_v2) == ["experimental"] - error_message = "Stable and experimental runner configurations must remain isolated in their respective configuration maps." + condition = length(local.runner_config_v1) == 0 && keys(local.runner_config_v2) == ["experimental"] + error_message = "A non-empty experimental configuration must select only the v2 configuration map." } assert { - condition = keys(module.runners) == ["legacy"] && keys(module.runner_stacks) == ["experimental"] - error_message = "Stable runner configurations must keep module.runners addresses while v2 configurations use module.runner_stacks." + condition = length(module.runners) == 0 && keys(module.runner_stacks) == ["experimental"] + error_message = "Selecting v2 must not create any legacy runner modules." } assert { condition = ( - toset(keys(aws_sqs_queue.queued_builds)) == toset(["legacy", "experimental"]) - && toset(keys(local.runner_matcher_config)) == toset(["legacy", "experimental"]) + keys(aws_sqs_queue.queued_builds) == ["experimental"] + && keys(local.runner_matcher_config) == ["experimental"] ) - error_message = "Queues and webhook routing must use the union of stable and experimental runner configuration keys." + error_message = "Queues and webhook routing must use only v2 runner configuration keys when v2 is selected." } assert { - condition = toset(keys(module.runner_binaries)) == toset(["linux_x64", "linux_arm64"]) - error_message = "Runner binary synchronization must include operating-system and architecture combinations from both input versions." + condition = keys(module.runner_binaries) == ["linux_arm64"] + error_message = "Runner binary synchronization must ignore stable v1 configurations when v2 is selected." } assert { condition = ( - keys(output.runners_map) == ["legacy"] + length(output.runners_map) == 0 && keys(output.runners_map_v2) == ["experimental"] ) - error_message = "Stable and experimental runner configuration keys must remain separated across runners_map and runners_map_v2." - } - - assert { - condition = toset(keys(output.runners_map["legacy"])) == toset( - [ - "launch_template_name", - "launch_template_id", - "launch_template_version", - "launch_template_ami_id", - "lambda_up", - "lambda_up_log_group", - "lambda_down", - "lambda_down_log_group", - "lambda_pool", - "lambda_pool_log_group", - "role_runner", - "role_scale_up", - "role_scale_down", - "role_pool", - "runners_log_groups", - "logfiles", - ] - ) - error_message = "A coexisting stable runner configuration must retain the legacy flat runners_map entry shape." + error_message = "Selecting v2 must leave the stable output empty and expose only runners_map_v2." } assert { condition = output.runners_map_v2["experimental"].provider.type == "ec2" && contains(keys(output.runners_map_v2["experimental"].provider.ec2), "launch_template") - error_message = "A coexisting v2 runner configuration must retain its nested EC2 provider output." + error_message = "The selected v2 configuration must retain its nested EC2 provider output." } assert { condition = ( - !contains(keys(output.runners_map["legacy"]), "provider") - && !contains(keys(output.runners_map["legacy"]), "runner") - && !contains(keys(output.runners_map_v2["experimental"]), "launch_template_name") + !contains(keys(output.runners_map_v2["experimental"]), "launch_template_name") && !contains(keys(output.runners_map_v2["experimental"]), "lambda_up") ) - error_message = "Coexisting outputs must not mix the stable flat schema with the experimental nested schema." + error_message = "The v2 output must not contain fields from the legacy flat schema." } } -run "duplicate_runner_configuration_keys_are_rejected" { +run "experimental_v2_replaces_same_key_stable_v1" { command = plan variables { @@ -621,7 +592,6 @@ run "duplicate_runner_configuration_keys_are_rejected" { maximum_count = 2 } compute_provider = { - type = "ec2" ec2 = { instance_types = ["m5.large"] binaries_syncer = { @@ -637,10 +607,28 @@ run "duplicate_runner_configuration_keys_are_rejected" { } } - expect_failures = [random_string.random] + assert { + condition = ( + length(local.runner_config_v1) == 0 + && keys(local.runner_config_v2) == ["duplicate"] + && length(module.runners) == 0 + && keys(module.runner_stacks) == ["duplicate"] + ) + error_message = "A same-key v2 configuration must replace v1 without creating legacy modules." + } + + assert { + condition = ( + length(local.runner_config_v2["duplicate"].matcherConfig.labelMatchers) == 1 + && toset(local.runner_config_v2["duplicate"].matcherConfig.labelMatchers[0]) == toset(["self-hosted", "linux", "x64", "experimental"]) + && length(output.runners_map) == 0 + && keys(output.runners_map_v2) == ["duplicate"] + ) + error_message = "Same-key selection must use the v2 matcher and expose only the v2 output." + } } -run "experimental_v2_rejects_future_providers" { +run "experimental_v2_rejects_empty_compute_provider" { command = plan variables { @@ -652,9 +640,7 @@ run "experimental_v2_rejects_future_providers" { architecture = "x64" maximum_count = 2 } - compute_provider = { - type = "microvm" - } + compute_provider = {} matcherConfig = { labelMatchers = [["self-hosted", "linux", "x64"]] } @@ -679,7 +665,6 @@ run "experimental_v2_rejects_profile_without_role" { maximum_count = 2 } compute_provider = { - type = "ec2" ec2 = { instance_types = ["m5.large"] instance_profile = { diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index 720102914a..6628c249c7 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -2,7 +2,7 @@ variable "experimental" { description = <<-EOT Opt-in experimental features. Omit this object to retain only the stable `multi_runner_config` behavior. Experimental schemas can change before they become stable. - - `multi_runner_config_v2`: Provider-oriented runner configurations keyed by configuration name. Each entry is deployed with `runner-stack`; stable `multi_runner_config` entries continue to use the unchanged `runners` module. + - `multi_runner_config_v2`: Provider-oriented runner configurations keyed by configuration name. A non-empty map selects v2 for the entire module and ignores `multi_runner_config`. When this map is empty, stable `multi_runner_config` entries continue to use the unchanged `runners` module. Each `multi_runner_config_v2` entry supports the following nested fields: @@ -67,8 +67,8 @@ variable "experimental" { - `ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`. - `ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values. - `observability.logs.tags`: Shared tags for CloudWatch log groups. Component tags override this map. - - `compute_provider.type`: Compute-provider discriminator. The only currently implemented value is `ec2`. - - `compute_provider.ec2`: EC2-specific configuration. This object is required when `type` is `ec2`. + - `compute_provider`: Typed compute-provider blocks. Exactly one block must be non-null, and the populated block selects the provider. Its presence must be known during planning; values inside it may remain unknown until apply. + - `compute_provider.ec2`: EC2-specific configuration. EC2 is the only provider currently implemented. - `compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter. - `compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI. - `compute_provider.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. The wrapper's presence selects external ownership at plan time. @@ -264,8 +264,6 @@ variable "experimental" { }), {}) compute_provider = object({ - type = string - ec2 = optional(object({ metadata_options = optional(object({ instance_metadata_tags = optional(string, "enabled") @@ -394,17 +392,12 @@ variable "experimental" { validation { condition = alltrue([ for _, runner_config in var.experimental.multi_runner_config_v2 : - lower(trimspace(runner_config.compute_provider.type)) == "ec2" - ]) - error_message = "compute_provider.type must be ec2. microvm and codebuild are reserved for future Terraform support." - } - - validation { - condition = alltrue([ - for _, runner_config in var.experimental.multi_runner_config_v2 : - runner_config.compute_provider.ec2 != null + length([ + for provider_type, provider_config in runner_config.compute_provider : provider_type + if provider_config != null + ]) == 1 ]) - error_message = "Each experimental runner configuration must set compute_provider.ec2." + error_message = "Each experimental runner configuration must set exactly one compute-provider block. Supported compute-provider blocks: ec2." } validation { diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index 7ce734e00c..9bdbd309b2 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -6,7 +6,7 @@ This internal module implements the experimental provider-neutral runner control The stack coordinates internal modules for [`scale-runners`](./scale-runners), [`pool`](./pool), [`job-retry`](./job-retry), and [`ssm-housekeeper`](./ssm-housekeeper). These modules own their provider-neutral Lambda, scheduler, logging, and IAM resources. The stack also creates or selects the runner IAM role, manages shared runner configuration in SSM, and dispatches the selected compute provider. -Provider-owned settings are typed and nested under the selected provider block; for example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.ec2`. [`../compute-providers/ec2/runner-role`](../compute-providers/ec2/runner-role) supplies the EC2 runner-role trust and permission documents without depending on the role. The common stack creates or selects the role and attaches those policies, then passes the role into [`../compute-providers/ec2`](../compute-providers/ec2), which owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and the EC2 environment and IAM fragments consumed by the internal control-plane modules. EC2 is the only active provider today; future providers can implement the same contracts without copying the control plane. +Provider-owned settings are typed and nested under the selected provider block; for example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.ec2`. Exactly one provider block must be populated, and its presence must be known during planning; there is no separate input discriminator. The common stack creates or selects the runner role, then passes it into [`../compute-providers/ec2`](../compute-providers/ec2), which owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and a nested contract of provider policies and Lambda environment variables. The common stack attaches each returned policy group to its runner, scale-up, scale-down, or pool role. EC2 is the only active provider today; future providers can implement the same contract without copying the control plane. The nested provider output may include a computed type derived from the populated block, but that value is output metadata rather than an input selector. ## Tagging @@ -78,7 +78,6 @@ yarn run dist | Name | Source | Version | | ---- | ------ | ------- | | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | -| [ec2\_runner\_role](#module\_ec2\_runner\_role) | ../compute-providers/ec2/runner-role | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | | [scale\_runners](#module\_scale\_runners) | ./scale-runners | n/a | @@ -96,6 +95,7 @@ yarn run dist | [aws_ssm_parameter.runner_agent_mode](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.token_path](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.runner_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs @@ -103,7 +103,7 @@ yarn run dist | ---- | ----------- | ---- | ------- | :------: | | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | -| [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block.

- `type`: Compute-provider discriminator. The currently supported value is `ec2`.
- `ec2`: EC2 compute-provider configuration. This object is required when `type` is `ec2`.
- `ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults.
- `ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter.
- `ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource.
- `ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator.
- `ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `ec2.vpc_id`: VPC in which runner networking resources are created.
- `ec2.subnet_ids`: Subnets from which scale-up may launch runner instances.
- `ec2.overrides`: Optional resource-name overrides.
- `ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name.
- `ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name.
- `ec2.instance_profile`: Optional externally managed instance profile used by the launch template.
- `ec2.instance_profile.name`: Name of the externally managed instance profile.
- `ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the stack prefix.
- `ec2.binaries_syncer`: Runner-distribution synchronization configuration.
- `ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3.
- `ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies.
- `ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI.
- `ec2.binaries_syncer.s3.key`: Object key of the runner distribution.
- `ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it.
- `ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `ec2.block_device_mappings[].volume_type`: EBS volume type.
- `ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity.
- `ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `ec2.user_data`: Runner bootstrap user-data configuration.
- `ec2.user_data.enabled`: Enables launch-template user data.
- `ec2.user_data.template`: Optional path to a custom user-data template.
- `ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template.
- `ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances.
- `ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`.
- `ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group.
- `ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults.
- `ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing.
- `ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `ec2.key_name`: Optional EC2 key-pair name added to the launch template.
- `ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group.
- `ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `ec2.egress_rules`: Egress rules created on the managed runner security group.
- `ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `ec2.egress_rules[].from_port`: First destination port in the permitted range.
- `ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `ec2.egress_rules[].security_groups`: Destination security-group IDs.
- `ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `ec2.egress_rules[].to_port`: Last destination port in the permitted range.
- `ec2.egress_rules[].description`: Optional rule description.
- `ec2.tags`: Additional tags for runner instances, EBS volumes, network interfaces, and eligible Spot instance requests created from the launch template. They override module-level tags and the generated runner `Name`; the provider-managed `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` bootstrap tags take final precedence. These tags do not apply to static provider resources such as the launch template, security group, IAM resources, SSM parameters, or log groups.
- `ec2.metadata_options`: Instance Metadata Service configuration in the launch template.
- `ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`.
- `ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`.
- `ec2.cpu_options`: CPU topology and processor-feature configuration.
- `ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `ec2.placement`: EC2 placement configuration for runner instances.
- `ec2.placement.affinity`: Host affinity setting.
- `ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `ec2.placement.group_id`: Placement-group ID.
- `ec2.placement.group_name`: Placement-group name.
- `ec2.placement.host_id`: Dedicated Host ID.
- `ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `ec2.placement.spread_domain`: Spread-domain placement value.
- `ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `ec2.placement.partition_number`: Placement-group partition number.
- `ec2.license_specifications`: License Manager configurations added to the launch template.
- `ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. |
object({
type = string

ec2 = optional(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)
enable_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)
}), null)
})
| n/a | yes | +| [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block.

Exactly one compute-provider block must be non-null. The populated block selects the provider, and its presence must be known during planning. Values inside the selected block may remain unknown until apply.

- `ec2`: EC2 compute-provider configuration. EC2 is the only provider currently implemented.
- `ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults.
- `ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter.
- `ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource.
- `ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator.
- `ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `ec2.vpc_id`: VPC in which runner networking resources are created.
- `ec2.subnet_ids`: Subnets from which scale-up may launch runner instances.
- `ec2.overrides`: Optional resource-name overrides.
- `ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name.
- `ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name.
- `ec2.instance_profile`: Optional externally managed instance profile used by the launch template.
- `ec2.instance_profile.name`: Name of the externally managed instance profile.
- `ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the stack prefix.
- `ec2.binaries_syncer`: Runner-distribution synchronization configuration.
- `ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3.
- `ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies.
- `ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI.
- `ec2.binaries_syncer.s3.key`: Object key of the runner distribution.
- `ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it.
- `ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `ec2.block_device_mappings[].volume_type`: EBS volume type.
- `ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity.
- `ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `ec2.user_data`: Runner bootstrap user-data configuration.
- `ec2.user_data.enabled`: Enables launch-template user data.
- `ec2.user_data.template`: Optional path to a custom user-data template.
- `ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template.
- `ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances.
- `ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`.
- `ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group.
- `ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults.
- `ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing.
- `ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `ec2.key_name`: Optional EC2 key-pair name added to the launch template.
- `ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group.
- `ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `ec2.egress_rules`: Egress rules created on the managed runner security group.
- `ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `ec2.egress_rules[].from_port`: First destination port in the permitted range.
- `ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `ec2.egress_rules[].security_groups`: Destination security-group IDs.
- `ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `ec2.egress_rules[].to_port`: Last destination port in the permitted range.
- `ec2.egress_rules[].description`: Optional rule description.
- `ec2.tags`: Additional tags for runner instances, EBS volumes, network interfaces, and eligible Spot instance requests created from the launch template. They override module-level tags and the generated runner `Name`; the provider-managed `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` bootstrap tags take final precedence. These tags do not apply to static provider resources such as the launch template, security group, IAM resources, SSM parameters, or log groups.
- `ec2.metadata_options`: Instance Metadata Service configuration in the launch template.
- `ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`.
- `ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`.
- `ec2.cpu_options`: CPU topology and processor-feature configuration.
- `ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `ec2.placement`: EC2 placement configuration for runner instances.
- `ec2.placement.affinity`: Host affinity setting.
- `ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `ec2.placement.group_id`: Placement-group ID.
- `ec2.placement.group_name`: Placement-group name.
- `ec2.placement.host_id`: Dedicated Host ID.
- `ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `ec2.placement.spread_domain`: Spread-domain placement value.
- `ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `ec2.placement.partition_number`: Placement-group partition number.
- `ec2.license_specifications`: License Manager configurations added to the launch template.
- `ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. |
object({
ec2 = optional(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)
enable_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)
}), null)
})
| n/a | yes | | [github](#input\_github) | GitHub API and runner-registration configuration.

- `app_parameters.key_base64`: Parameter Store reference for the GitHub App private key.
- `app_parameters.key_base64.name`: Name of the private-key parameter supplied to the control-plane functions.
- `app_parameters.key_base64.arn`: ARN of the private-key parameter used by IAM policies.
- `app_parameters.id`: Parameter Store reference for the GitHub App ID.
- `app_parameters.id.name`: Name of the App-ID parameter supplied to the control-plane functions.
- `app_parameters.id.arn`: ARN of the App-ID parameter used by IAM policies.
- `organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `user_agent`: Optional User-Agent value added to GitHub API requests. |
object({
app_parameters = object({
key_base64 = map(string)
id = map(string)
})
organization_runners = bool
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | | [job\_retry](#input\_job\_retry) | Job-retry queue and Lambda configuration.

- `enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `delay_in_seconds`: Initial delay before a queued-job retry check. AWS SQS limits this value to 900 seconds.
- `delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `tags`: Tags for job-retry resources, including the Lambda function, log group, IAM role, retry queue, and event-source mapping. These override module-level tags and the shared `lambda.tags`, `queue.tags`, and `observability.logs.tags` maps when keys conflict.
- `lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. |
object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
})
| `{}` | no | | [lambda](#input\_lambda) | Configuration shared by the control-plane Lambda functions.

- `zip`: Local control-plane archive. When null, the module's packaged runner archive is used.
- `s3.bucket`: Optional S3 bucket containing the Lambda archive. Setting this selects S3 instead of a local archive.
- `s3.key`: Object key of the Lambda archive in `s3.bucket`.
- `s3.object_version`: Optional version of the Lambda archive object.
- `runtime`: Runtime used by all control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by all control-plane Lambda functions. Supported values are `arm64` and `x86_64`.
- `subnet_ids`: Subnets used for Lambda VPC configuration.
- `security_group_ids`: Security groups used for Lambda VPC configuration.
- `tags`: Shared tags applied to Lambda function resources only. These override module-level `tags`; component `tags` override this map when keys conflict.
- `role.path`: IAM path for module-managed Lambda execution roles. Defaults to a path derived from `prefix`.
- `role.permissions_boundary`: Permissions-boundary ARN applied to module-managed Lambda execution roles. |
object({
zip = optional(string, null)
s3 = optional(object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| `{}` | no | diff --git a/modules/runner-stack/compute-provider.tf b/modules/runner-stack/compute-provider.tf new file mode 100644 index 0000000000..9b63c0b64a --- /dev/null +++ b/modules/runner-stack/compute-provider.tf @@ -0,0 +1,12 @@ +locals { + provider_type = one([ + for provider_type, provider_config in var.compute_provider : provider_type + if provider_config != null + ]) + + provider_modules = { + ec2 = one(module.ec2[*].provider) + } + + provider = local.provider_modules[local.provider_type] +} diff --git a/modules/runner-stack/ec2.tf b/modules/runner-stack/ec2.tf index ca7cc547c1..ba734bf3f8 100644 --- a/modules/runner-stack/ec2.tf +++ b/modules/runner-stack/ec2.tf @@ -1,18 +1,13 @@ -locals { - provider_type = lower(trimspace(var.compute_provider.type)) - ec2 = var.compute_provider.ec2 - provider = one(module.ec2[*].control_plane) -} - module "ec2" { count = local.provider_type == "ec2" ? 1 : 0 source = "../compute-providers/ec2" aws_partition = var.aws_partition + aws_region = var.aws_region prefix = var.prefix tags = var.tags - config = local.ec2 + config = var.compute_provider.ec2 runner = merge(var.runner, { iam = merge(var.runner.iam, { role = local.runner_role diff --git a/modules/runner-stack/job-retry.tf b/modules/runner-stack/job-retry.tf index b51c0da0fe..2631f1c279 100644 --- a/modules/runner-stack/job-retry.tf +++ b/modules/runner-stack/job-retry.tf @@ -1,48 +1,62 @@ locals { job_retry_enabled = var.job_retry.enabled - - job_retry = { - prefix = var.prefix - tags = local.job_retry_tags - aws_partition = var.aws_partition - architecture = var.lambda.architecture - runtime = var.lambda.runtime - security_group_ids = var.lambda.security_group_ids - subnet_ids = var.lambda.subnet_ids - kms_key = var.ssm.kms_key - lambda_tags = local.job_retry_lambda_tags - log_group_tags = local.job_retry_log_tags - queue_tags = local.job_retry_queue_tags - log_level = var.observability.logs.level - log_class = var.observability.logs.class - logging_kms_key_id = var.observability.logs.kms_key_id - logging_retention_in_days = var.observability.logs.retention_in_days - metrics = var.observability.metrics - role_path = var.lambda.role.path - role_permissions_boundary = var.lambda.role.permissions_boundary - s3_bucket = var.lambda.s3.bucket - s3_key = var.lambda.s3.key - s3_object_version = var.lambda.s3.object_version - zip = var.lambda.zip - tracing_config = var.observability.tracing - github_app_parameters = var.github.app_parameters - enable_organization_runners = var.github.organization_runners - runner_name_prefix = var.runner.name_prefix - sqs_build_queue = var.queue.build - ghes_url = var.github.enterprise_server.url - user_agent = var.github.user_agent - lambda_event_source_mapping_batch_size = var.queue.event_source_mapping.batch_size - lambda_event_source_mapping_maximum_batching_window_in_seconds = var.queue.event_source_mapping.maximum_batching_window_in_seconds - memory_size = var.job_retry.lambda.memory_size - reserved_concurrent_executions = var.job_retry.lambda.reserved_concurrent_executions - timeout = var.job_retry.lambda.timeout - } } module "job_retry" { source = "./job-retry" count = local.job_retry_enabled ? 1 : 0 - config = local.job_retry + config = { + prefix = var.prefix + aws_partition = var.aws_partition + lambda = { + artifact = { + zip = local.lambda_zip + s3 = var.lambda.s3 + } + runtime = var.lambda.runtime + architecture = var.lambda.architecture + memory_size = var.job_retry.lambda.memory_size + timeout = var.job_retry.lambda.timeout + reserved_concurrent_executions = var.job_retry.lambda.reserved_concurrent_executions + environment_variables = {} + vpc = { + subnet_ids = var.lambda.subnet_ids + security_group_ids = var.lambda.security_group_ids + } + role = { + path = local.lambda_role_path + permissions_boundary = var.lambda.role.permissions_boundary + principals = [] + } + } + runner = { + name_prefix = var.runner.name_prefix + } + github = var.github + queue = { + build = var.queue.build + event_source_mapping = { + batch_size = var.queue.event_source_mapping.batch_size + maximum_batching_window_in_seconds = var.queue.event_source_mapping.maximum_batching_window_in_seconds + } + encryption = { + sqs_managed_sse_enabled = true + kms_master_key_id = null + kms_data_key_reuse_period_seconds = null + } + } + ssm = { + kms_key = local.kms_key + } + observability = var.observability + tags = { + resources = local.job_retry_tags + lambda = local.job_retry_lambda_tags + log_group = local.job_retry_log_tags + queue = local.job_retry_queue_tags + event_source_mapping = local.job_retry_queue_tags + } + } } diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md index 478ba00f15..5fed3b5b17 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/runner-stack/job-retry/README.md @@ -50,7 +50,7 @@ No modules. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | -| [config](#input\_config) | Configuration for the job-retry Lambda and retry queue.

- `aws_partition`: Partition for the base ARN if not `aws`.
- `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
- `environment_variables`: Additional environment variables for the job-retry Lambda. Required job-retry variables override matching keys.
- `enable_organization_runners`: Enable organization runners.
- `ghes_url`: Optional GitHub Enterprise Server URL.
- `user_agent`: Optional User-Agent header for GitHub API requests.
- `github_app_parameters`: SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Metadata for the SSM parameter containing the base64-encoded GitHub App private key.
- `github_app_parameters.key_base64.name`: Name of the private-key parameter supplied to the job-retry Lambda.
- `github_app_parameters.key_base64.arn`: ARN of the private-key parameter used by the job-retry IAM policy.
- `github_app_parameters.id`: Metadata for the SSM parameter containing the GitHub App ID.
- `github_app_parameters.id.name`: Name of the App-ID parameter supplied to the job-retry Lambda.
- `github_app_parameters.id.arn`: ARN of the App-ID parameter used by the job-retry IAM policy.
- `kms_key`: Optional customer-managed KMS key used by the job-retry IAM policy. Object presence controls whether the KMS statement exists.
- `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply.
- `lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
- `lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
- `lambda_tags`: Tags added specifically to the job-retry Lambda function. These override `tags` values with the same key.
- `log_group_tags`: Tags added specifically to the job-retry Lambda log group. These override `tags` values with the same key.
- `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
- `log_class`: CloudWatch log-group class for the job-retry Lambda. Supported values are `STANDARD` and `INFREQUENT_ACCESS`.
- `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
- `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.
- `memory_size`: Memory size limit in MB of the lambda.
- `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation.
- `metrics`: Configuration to enable metrics creation by the lambda.
- `metrics.enable`: Whether the job-retry Lambda emits metrics.
- `metrics.namespace`: CloudWatch namespace for metrics emitted by the job-retry Lambda.
- `metrics.metric`: Controls individual metrics emitted by the job-retry Lambda.
- `metrics.metric.enable_github_app_rate_limit`: Whether to emit GitHub App rate-limit metrics.
- `metrics.metric.enable_job_retry`: Whether to emit job-retry metrics.
- `prefix`: The prefix used for naming resources.
- `principals`: Extra principals allowed to assume the job-retry Lambda execution role, for example during local testing.
- `principals[*].type`: IAM principal type, such as `Service` or `AWS`.
- `principals[*].identifiers`: IAM principal identifiers for the selected principal type.
- `queue_encryption`: Encryption configuration for the retry queue.
- `queue_encryption.kms_data_key_reuse_period_seconds`: Length of time, in seconds, for which SQS reuses a data key.
- `queue_encryption.kms_master_key_id`: KMS key ID used to encrypt the retry queue.
- `queue_encryption.sqs_managed_sse_enabled`: Whether SQS-managed server-side encryption is enabled.
- `role_path`: The path that will be added to the role, if not set the environment name will be used.
- `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
- `runner_name_prefix`: Prefix used to identify runners belonging to this runner configuration.
- `runtime`: AWS Lambda runtime.
- `s3_bucket`: S3 bucket containing the job-retry Lambda deployment package. This is an alternative to `zip`.
- `s3_key`: Object key of the job-retry Lambda deployment package. Required when `s3_bucket` is set.
- `s3_object_version`: Optional version of the job-retry Lambda deployment-package object.
- `security_group_ids`: List of security group IDs associated with the Lambda function.
- `sqs_build_queue`: SQS queue to which the job-retry Lambda republishes job requests.
- `sqs_build_queue.url`: URL of the build queue.
- `sqs_build_queue.arn`: ARN of the build queue.
- `queue_tags`: Map of tags that will be added to the retry queue and event-source mapping.
- `subnet_ids`: Subnets used for the job-retry Lambda VPC configuration.
- `tags`: Base component tags added to the Lambda function, log group, and execution role. Specialized Lambda and log-group tags override matching keys.
- `timeout`: Time out of the lambda in seconds.
- `tracing_config`: Configuration for lambda tracing.
- `tracing_config.mode`: AWS X-Ray tracing mode. A null value disables tracing.
- `tracing_config.capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests.
- `tracing_config.capture_error`: Whether Powertools tracing captures errors as tracing metadata.
- `zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key = optional(object({
arn = string
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_group_tags = optional(map(string), {})
log_level = optional(string, null)
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
reserved_concurrent_executions = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runner_name_prefix = optional(string, "")
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
queue_tags = optional(map(string), {})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | +| [config](#input\_config) | Provider-neutral job-retry configuration assembled by runner-stack.

- `prefix`: Prefix used to name job-retry resources.
- `aws_partition`: AWS partition used to construct the Lambda VPC managed-policy ARN.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by the job-retry Lambda.
- `lambda.architecture`: Instruction-set architecture used by the job-retry Lambda.
- `lambda.memory_size`: Memory allocated to the job-retry Lambda.
- `lambda.timeout`: Lambda timeout and retry-queue visibility timeout in seconds.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the Lambda. Use `-1` for unreserved concurrency.
- `lambda.environment_variables`: Additional Lambda environment variables. Required job-retry variables override matching keys.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the job-retry Lambda role.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the Lambda role.
- `lambda.role.principals`: Extra principals allowed to assume the Lambda role, for example during local testing.
- `runner.name_prefix`: Prefix used to identify runners belonging to this runner configuration.
- `github.organization_runners`: Enables organization runners.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Name and ARN of the GitHub App private-key parameter.
- `github.app_parameters.id`: Name and ARN of the GitHub App ID parameter.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per job-retry invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `queue.encryption`: Server-side encryption configuration for the retry queue.
- `ssm.kms_key`: Optional KMS key used by the job-retry IAM policy.
- `observability.logs`: Logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and job-retry metric configuration.
- `tags.resources`: Tags for the job-retry Lambda role and component resources.
- `tags.lambda`: Tags for the job-retry Lambda function.
- `tags.log_group`: Tags for the job-retry log group.
- `tags.queue`: Tags for the retry queue.
- `tags.event_source_mapping`: Tags for the retry-queue event-source mapping. |
object({
prefix = string
aws_partition = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
reserved_concurrent_executions = number
environment_variables = map(string)
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = list(object({
type = string
identifiers = list(string)
}))
})
})
runner = object({
name_prefix = string
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = object({
name = string
arn = string
})
id = object({
name = string
arn = string
})
})
})
queue = object({
build = object({
url = string
arn = string
})
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
encryption = object({
sqs_managed_sse_enabled = bool
kms_master_key_id = optional(string, null)
kms_data_key_reuse_period_seconds = optional(number, null)
})
})
ssm = object({
kms_key = optional(object({
arn = string
}), null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enable = bool
namespace = string
metric = object({
enable_github_app_rate_limit = bool
enable_job_retry = bool
})
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
queue = map(string)
event_source_mapping = map(string)
})
})
| n/a | yes | ## Outputs diff --git a/modules/runner-stack/job-retry/iam-policies.tf b/modules/runner-stack/job-retry/iam-policies.tf index d912fe339d..6f0a3b215e 100644 --- a/modules/runner-stack/job-retry/iam-policies.tf +++ b/modules/runner-stack/job-retry/iam-policies.tf @@ -9,7 +9,7 @@ data "aws_iam_policy_document" "lambda_assume_role" { } dynamic "principals" { - for_each = var.config.principals + for_each = var.config.lambda.role.principals content { type = principals.value.type @@ -33,7 +33,7 @@ data "aws_iam_policy_document" "job_retry_logging" { } data "aws_iam_policy_document" "lambda_xray" { - count = var.config.tracing_config.mode != null ? 1 : 0 + count = var.config.observability.tracing.mode != null ? 1 : 0 statement { sid = "AllowXRay" @@ -58,8 +58,8 @@ data "aws_iam_policy_document" "job_retry" { ] resources = [ - var.config.github_app_parameters.key_base64.arn, - var.config.github_app_parameters.id.arn, + var.config.github.app_parameters.key_base64.arn, + var.config.github.app_parameters.id.arn, ] } @@ -83,11 +83,11 @@ data "aws_iam_policy_document" "job_retry" { "sqs:GetQueueAttributes", ] - resources = [var.config.sqs_build_queue.arn] + resources = [var.config.queue.build.arn] } dynamic "statement" { - for_each = var.config.kms_key == null ? [] : [var.config.kms_key] + for_each = var.config.ssm.kms_key == null ? [] : [var.config.ssm.kms_key] content { effect = "Allow" diff --git a/modules/runner-stack/job-retry/job-retry.tf b/modules/runner-stack/job-retry/job-retry.tf index dbe09ffdd6..a8e873ed3c 100644 --- a/modules/runner-stack/job-retry/job-retry.tf +++ b/modules/runner-stack/job-retry/job-retry.tf @@ -1,44 +1,38 @@ # Provider-neutral job-retry queue and Lambda resources. locals { - name = "job-retry" - lambda_zip = var.config.zip == null ? "${path.module}/../../../lambdas/functions/control-plane/runners.zip" : var.config.zip - architecture = coalesce(var.config.architecture, "arm64") - aws_partition = coalesce(var.config.aws_partition, "aws") - log_level = coalesce(var.config.log_level, "info") - memory_size = coalesce(var.config.memory_size, 256) - metrics_namespace = coalesce(var.config.metrics.namespace, "GitHub Runners") - logging_retention_in_days = coalesce(var.config.logging_retention_in_days, 180) - runtime = coalesce(var.config.runtime, "nodejs24.x") - role_path = var.config.role_path == null ? "/${var.config.prefix}/" : var.config.role_path - vpc_enabled = length(var.config.subnet_ids) > 0 && length(var.config.security_group_ids) > 0 + name = "job-retry" + vpc_enabled = ( + length(var.config.lambda.vpc.subnet_ids) > 0 && + length(var.config.lambda.vpc.security_group_ids) > 0 + ) lambda_environment_variables = { ENVIRONMENT = var.config.prefix - LOG_LEVEL = local.log_level + LOG_LEVEL = var.config.observability.logs.level PREFIX = var.config.prefix - POWERTOOLS_LOGGER_LOG_EVENT = local.log_level == "debug" ? "true" : "false" + POWERTOOLS_LOGGER_LOG_EVENT = var.config.observability.logs.level == "debug" ? "true" : "false" POWERTOOLS_SERVICE_NAME = local.name - POWERTOOLS_TRACE_ENABLED = var.config.tracing_config.mode != null - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.tracing_config.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error - POWERTOOLS_METRICS_NAMESPACE = local.metrics_namespace + POWERTOOLS_TRACE_ENABLED = var.config.observability.tracing.mode != null + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.observability.tracing.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.observability.tracing.capture_error + POWERTOOLS_METRICS_NAMESPACE = var.config.observability.metrics.namespace } job_retry_environment_variables = { - ENABLE_ORGANIZATION_RUNNERS = var.config.enable_organization_runners - ENABLE_METRIC_JOB_RETRY = var.config.metrics.enable && var.config.metrics.metric.enable_job_retry - ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.metrics.enable && var.config.metrics.metric.enable_github_app_rate_limit - GHES_URL = var.config.ghes_url - USER_AGENT = var.config.user_agent - JOB_QUEUE_SCALE_UP_URL = var.config.sqs_build_queue.url - PARAMETER_GITHUB_APP_ID_NAME = var.config.github_app_parameters.id.name - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github_app_parameters.key_base64.name - RUNNER_NAME_PREFIX = var.config.runner_name_prefix + ENABLE_ORGANIZATION_RUNNERS = var.config.github.organization_runners + ENABLE_METRIC_JOB_RETRY = var.config.observability.metrics.enable && var.config.observability.metrics.metric.enable_job_retry + ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.observability.metrics.enable && var.config.observability.metrics.metric.enable_github_app_rate_limit + GHES_URL = var.config.github.enterprise_server.url + USER_AGENT = var.config.github.user_agent + JOB_QUEUE_SCALE_UP_URL = var.config.queue.build.url + PARAMETER_GITHUB_APP_ID_NAME = var.config.github.app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github.app_parameters.key_base64.name + RUNNER_NAME_PREFIX = var.config.runner.name_prefix } environment_variables = merge( local.lambda_environment_variables, - var.config.environment_variables, + var.config.lambda.environment_variables, local.job_retry_environment_variables, ) } @@ -50,29 +44,29 @@ resource "aws_sqs_queue_policy" "job_retry_check_queue_policy" { resource "aws_sqs_queue" "job_retry_check_queue" { name = "${var.config.prefix}-job-retry" - visibility_timeout_seconds = var.config.timeout + visibility_timeout_seconds = var.config.lambda.timeout - sqs_managed_sse_enabled = var.config.queue_encryption.sqs_managed_sse_enabled - kms_master_key_id = var.config.queue_encryption.kms_master_key_id - kms_data_key_reuse_period_seconds = var.config.queue_encryption.kms_data_key_reuse_period_seconds + sqs_managed_sse_enabled = var.config.queue.encryption.sqs_managed_sse_enabled + kms_master_key_id = var.config.queue.encryption.kms_master_key_id + kms_data_key_reuse_period_seconds = var.config.queue.encryption.kms_data_key_reuse_period_seconds - tags = var.config.queue_tags + tags = var.config.tags.queue } resource "aws_lambda_function" "job_retry" { - s3_bucket = var.config.s3_bucket - s3_key = var.config.s3_key - s3_object_version = var.config.s3_object_version - filename = var.config.s3_bucket == null ? local.lambda_zip : null - source_code_hash = var.config.s3_bucket == null ? filebase64sha256(local.lambda_zip) : null + s3_bucket = var.config.lambda.artifact.s3.bucket + s3_key = var.config.lambda.artifact.s3.key + s3_object_version = var.config.lambda.artifact.s3.object_version + filename = var.config.lambda.artifact.s3.bucket == null ? var.config.lambda.artifact.zip : null + source_code_hash = var.config.lambda.artifact.s3.bucket == null ? filebase64sha256(var.config.lambda.artifact.zip) : null function_name = "${var.config.prefix}-${local.name}" role = aws_iam_role.job_retry.arn handler = "index.jobRetryCheck" - runtime = local.runtime - timeout = var.config.timeout - memory_size = local.memory_size - reserved_concurrent_executions = var.config.reserved_concurrent_executions - architectures = [local.architecture] + runtime = var.config.lambda.runtime + timeout = var.config.lambda.timeout + memory_size = var.config.lambda.memory_size + reserved_concurrent_executions = var.config.lambda.reserved_concurrent_executions + architectures = [var.config.lambda.architecture] environment { variables = local.environment_variables @@ -82,36 +76,36 @@ resource "aws_lambda_function" "job_retry" { for_each = local.vpc_enabled ? [true] : [] content { - security_group_ids = var.config.security_group_ids - subnet_ids = var.config.subnet_ids + security_group_ids = var.config.lambda.vpc.security_group_ids + subnet_ids = var.config.lambda.vpc.subnet_ids } } dynamic "tracing_config" { - for_each = var.config.tracing_config.mode != null ? [true] : [] + for_each = var.config.observability.tracing.mode != null ? [true] : [] content { - mode = var.config.tracing_config.mode + mode = var.config.observability.tracing.mode } } - tags = merge(var.config.tags, var.config.lambda_tags) + tags = var.config.tags.lambda } resource "aws_cloudwatch_log_group" "job_retry" { name = "/aws/lambda/${aws_lambda_function.job_retry.function_name}" - retention_in_days = local.logging_retention_in_days - kms_key_id = var.config.logging_kms_key_id - log_group_class = var.config.log_class - tags = merge(var.config.tags, var.config.log_group_tags) + retention_in_days = var.config.observability.logs.retention_in_days + kms_key_id = var.config.observability.logs.kms_key_id + log_group_class = var.config.observability.logs.class + tags = var.config.tags.log_group } resource "aws_iam_role" "job_retry" { name = "${substr("${var.config.prefix}-${local.name}", 0, 54)}-${substr(md5("${var.config.prefix}-${local.name}"), 0, 8)}" assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json - path = local.role_path - permissions_boundary = var.config.role_permissions_boundary - tags = var.config.tags + path = var.config.lambda.role.path + permissions_boundary = var.config.lambda.role.permissions_boundary + tags = var.config.tags.resources } resource "aws_iam_role_policy" "job_retry_logging" { @@ -123,11 +117,11 @@ resource "aws_iam_role_policy" "job_retry_logging" { resource "aws_iam_role_policy_attachment" "job_retry_vpc_execution_role" { count = local.vpc_enabled ? 1 : 0 role = aws_iam_role.job_retry.name - policy_arn = "arn:${local.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" + policy_arn = "arn:${var.config.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" } resource "aws_iam_role_policy" "job_retry_xray" { - count = var.config.tracing_config.mode != null ? 1 : 0 + count = var.config.observability.tracing.mode != null ? 1 : 0 name = "xray-policy" policy = data.aws_iam_policy_document.lambda_xray[0].json role = aws_iam_role.job_retry.name @@ -136,9 +130,9 @@ resource "aws_iam_role_policy" "job_retry_xray" { resource "aws_lambda_event_source_mapping" "job_retry" { event_source_arn = aws_sqs_queue.job_retry_check_queue.arn function_name = aws_lambda_function.job_retry.arn - batch_size = var.config.lambda_event_source_mapping_batch_size - maximum_batching_window_in_seconds = var.config.lambda_event_source_mapping_maximum_batching_window_in_seconds - tags = var.config.queue_tags + batch_size = var.config.queue.event_source_mapping.batch_size + maximum_batching_window_in_seconds = var.config.queue.event_source_mapping.maximum_batching_window_in_seconds + tags = var.config.tags.event_source_mapping } resource "aws_lambda_permission" "job_retry" { diff --git a/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl b/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl index 0e8041a575..40cd279e30 100644 --- a/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl +++ b/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl @@ -14,45 +14,106 @@ mock_provider "aws" { variables { config = { - prefix = "job-retry-test" - architecture = "arm64" - runtime = "nodejs24.x" - log_level = "trace" - log_class = "INFREQUENT_ACCESS" - enable_organization_runners = false - ghes_url = "" - user_agent = "job-retry-test" - runner_name_prefix = "required-prefix-" - environment_variables = { - CUSTOM_ENV = "preserved" - RUNNER_NAME_PREFIX = "caller-prefix-" + prefix = "job-retry-test" + aws_partition = "aws" + lambda = { + artifact = { + zip = "unused.zip" + s3 = { + bucket = "lambda-artifacts" + key = "job-retry.zip" + } + } + architecture = "arm64" + runtime = "nodejs24.x" + memory_size = 256 + timeout = 30 + reserved_concurrent_executions = 1 + environment_variables = { + CUSTOM_ENV = "preserved" + RUNNER_NAME_PREFIX = "caller-prefix-" + } + vpc = { + security_group_ids = ["sg-12345678"] + subnet_ids = ["subnet-12345678"] + } + role = { + path = "/job-retry-test/" + principals = [{ + type = "AWS" + identifiers = ["arn:aws:iam::123456789012:root"] + }] + } } - github_app_parameters = { - key_base64 = { - name = "/github-runner/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + runner = { + name_prefix = "required-prefix-" + } + github = { + organization_runners = false + enterprise_server = { + url = "" } - id = { - name = "/github-runner/app-id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + user_agent = "job-retry-test" + app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } } } - kms_key = { - arn = "arn:aws:kms:eu-west-1:123456789012:key/job-retry-test" + queue = { + build = { + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + } + event_source_mapping = { + batch_size = 10 + maximum_batching_window_in_seconds = 0 + } + encryption = { + sqs_managed_sse_enabled = true + } } - metrics = { - namespace = "JobRetryTest" + ssm = { + kms_key = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/job-retry-test" + } } - s3_bucket = "lambda-artifacts" - s3_key = "job-retry.zip" - sqs_build_queue = { - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" - arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + observability = { + logs = { + level = "trace" + class = "INFREQUENT_ACCESS" + retention_in_days = 180 + } + tracing = { + mode = "Active" + capture_http_requests = false + capture_error = false + } + metrics = { + enable = false + namespace = "JobRetryTest" + metric = { + enable_github_app_rate_limit = true + enable_job_retry = true + } + } + } + tags = { + resources = { scope = "resources" } + lambda = { scope = "lambda" } + log_group = { scope = "log-group" } + queue = { scope = "queue" } + event_source_mapping = { scope = "event-source-mapping" } } } } -run "preserves_optional_lambda_configuration" { +run "preserves_nested_job_retry_configuration" { command = plan assert { @@ -70,64 +131,32 @@ run "preserves_optional_lambda_configuration" { toset(keys(output.lambda)) == toset(["function", "log_group", "role"]) && output.lambda.function.s3_bucket == "lambda-artifacts" && output.lambda.function.s3_key == "job-retry.zip" + && output.lambda.function.reserved_concurrent_executions == 1 ) - error_message = "The job-retry module must expose its direct Lambda resources and preserve the S3 artifact configuration." + error_message = "The nested Lambda configuration and direct resource output contract must be preserved." } assert { - condition = output.lambda.log_group.log_group_class == "INFREQUENT_ACCESS" - error_message = "The job-retry log-group class must be preserved through the typed child-module boundary." - } - - assert { - condition = length(data.aws_iam_policy_document.job_retry.statement) == 4 - error_message = "A present KMS key object must add the job-retry KMS policy statement." - } -} - -run "configures_role_tracing_and_complete_vpc" { - command = plan - - variables { - config = { - prefix = "job-retry-test" - enable_organization_runners = false - principals = [{ - type = "AWS" - identifiers = ["arn:aws:iam::123456789012:root"] - }] - security_group_ids = ["sg-12345678"] - subnet_ids = ["subnet-12345678"] - tracing_config = { - mode = "Active" - } - github_app_parameters = { - key_base64 = { - name = "/github-runner/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { - name = "/github-runner/app-id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } - } - s3_bucket = "lambda-artifacts" - s3_key = "job-retry.zip" - sqs_build_queue = { - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" - arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" - } - } + condition = ( + output.lambda.function.tags == tomap({ scope = "lambda" }) + && output.lambda.log_group.tags == tomap({ scope = "log-group" }) + && output.lambda.role.tags == tomap({ scope = "resources" }) + && output.job_retry_check_queue.tags == tomap({ scope = "queue" }) + && aws_lambda_event_source_mapping.job_retry.tags == tomap({ scope = "event-source-mapping" }) + ) + error_message = "Resolved nested tag maps must be applied to their owned resources." } assert { condition = ( - length(aws_lambda_function.job_retry.vpc_config) == 1 + output.lambda.log_group.log_group_class == "INFREQUENT_ACCESS" + && length(data.aws_iam_policy_document.job_retry.statement) == 4 + && length(aws_lambda_function.job_retry.vpc_config) == 1 && length(aws_iam_role_policy_attachment.job_retry_vpc_execution_role) == 1 && length(aws_iam_role_policy.job_retry_xray) == 1 && length(data.aws_iam_policy_document.lambda_assume_role.statement[0].principals) == 2 ) - error_message = "Complete VPC, tracing, and extra assume-role principal configuration must be applied to the direct Lambda resources." + error_message = "Logging, KMS, complete VPC, tracing, and extra role-principal configuration must be preserved." } } @@ -136,24 +165,87 @@ run "does_not_enable_partial_vpc_configuration" { variables { config = { - prefix = "job-retry-test" - enable_organization_runners = false - subnet_ids = ["subnet-12345678"] - github_app_parameters = { - key_base64 = { - name = "/github-runner/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + prefix = "job-retry-test" + aws_partition = "aws" + lambda = { + artifact = { + zip = "unused.zip" + s3 = { + bucket = "lambda-artifacts" + key = "job-retry.zip" + } } - id = { - name = "/github-runner/app-id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + architecture = "arm64" + runtime = "nodejs24.x" + memory_size = 256 + timeout = 30 + reserved_concurrent_executions = 1 + environment_variables = {} + vpc = { + security_group_ids = [] + subnet_ids = ["subnet-12345678"] + } + role = { + path = "/job-retry-test/" + principals = [] } } - s3_bucket = "lambda-artifacts" - s3_key = "job-retry.zip" - sqs_build_queue = { - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" - arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + runner = { + name_prefix = "" + } + github = { + organization_runners = false + enterprise_server = {} + app_parameters = { + key_base64 = { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + } + id = { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + } + } + } + queue = { + build = { + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + } + event_source_mapping = { + batch_size = 10 + maximum_batching_window_in_seconds = 0 + } + encryption = { + sqs_managed_sse_enabled = true + } + } + ssm = {} + observability = { + logs = { + level = "info" + class = "STANDARD" + retention_in_days = 180 + } + tracing = { + capture_http_requests = false + capture_error = false + } + metrics = { + enable = false + namespace = "GitHub Runners" + metric = { + enable_github_app_rate_limit = true + enable_job_retry = true + } + } + } + tags = { + resources = {} + lambda = {} + log_group = {} + queue = {} + event_source_mapping = {} } } } diff --git a/modules/runner-stack/job-retry/variables.tf b/modules/runner-stack/job-retry/variables.tf index df6f1bccb8..950bd6eb8b 100644 --- a/modules/runner-stack/job-retry/variables.tf +++ b/modules/runner-stack/job-retry/variables.tf @@ -1,136 +1,151 @@ variable "config" { - description = <<-EOF - Configuration for the job-retry Lambda and retry queue. + description = <<-EOT + Provider-neutral job-retry configuration assembled by runner-stack. + + - `prefix`: Prefix used to name job-retry resources. + - `aws_partition`: AWS partition used to construct the Lambda VPC managed-policy ARN. + - `lambda.artifact.zip`: Resolved local control-plane archive. + - `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive. + - `lambda.artifact.s3.key`: Object key of the Lambda archive. + - `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive. + - `lambda.runtime`: Runtime used by the job-retry Lambda. + - `lambda.architecture`: Instruction-set architecture used by the job-retry Lambda. + - `lambda.memory_size`: Memory allocated to the job-retry Lambda. + - `lambda.timeout`: Lambda timeout and retry-queue visibility timeout in seconds. + - `lambda.reserved_concurrent_executions`: Reserved concurrency for the Lambda. Use `-1` for unreserved concurrency. + - `lambda.environment_variables`: Additional Lambda environment variables. Required job-retry variables override matching keys. + - `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration. + - `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration. + - `lambda.role.path`: IAM path used for the job-retry Lambda role. + - `lambda.role.permissions_boundary`: Optional permissions boundary for the Lambda role. + - `lambda.role.principals`: Extra principals allowed to assume the Lambda role, for example during local testing. + - `runner.name_prefix`: Prefix used to identify runners belonging to this runner configuration. + - `github.organization_runners`: Enables organization runners. + - `github.enterprise_server.url`: Optional GitHub Enterprise Server URL. + - `github.user_agent`: Optional User-Agent sent to GitHub. + - `github.app_parameters.key_base64`: Name and ARN of the GitHub App private-key parameter. + - `github.app_parameters.id`: Name and ARN of the GitHub App ID parameter. + - `queue.build`: URL and ARN of the build queue to which retry messages are published. + - `queue.event_source_mapping.batch_size`: Maximum records delivered per job-retry invocation. + - `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window. + - `queue.encryption`: Server-side encryption configuration for the retry queue. + - `ssm.kms_key`: Optional KMS key used by the job-retry IAM policy. + - `observability.logs`: Logging level, retention, encryption, and log-class configuration. + - `observability.tracing`: Lambda X-Ray and tracing-helper configuration. + - `observability.metrics`: Metrics enablement, namespace, and job-retry metric configuration. + - `tags.resources`: Tags for the job-retry Lambda role and component resources. + - `tags.lambda`: Tags for the job-retry Lambda function. + - `tags.log_group`: Tags for the job-retry log group. + - `tags.queue`: Tags for the retry queue. + - `tags.event_source_mapping`: Tags for the retry-queue event-source mapping. + EOT - - `aws_partition`: Partition for the base ARN if not `aws`. - - `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86_64' functions. - - `environment_variables`: Additional environment variables for the job-retry Lambda. Required job-retry variables override matching keys. - - `enable_organization_runners`: Enable organization runners. - - `ghes_url`: Optional GitHub Enterprise Server URL. - - `user_agent`: Optional User-Agent header for GitHub API requests. - - `github_app_parameters`: SSM parameter metadata for GitHub App credentials. - - `github_app_parameters.key_base64`: Metadata for the SSM parameter containing the base64-encoded GitHub App private key. - - `github_app_parameters.key_base64.name`: Name of the private-key parameter supplied to the job-retry Lambda. - - `github_app_parameters.key_base64.arn`: ARN of the private-key parameter used by the job-retry IAM policy. - - `github_app_parameters.id`: Metadata for the SSM parameter containing the GitHub App ID. - - `github_app_parameters.id.name`: Name of the App-ID parameter supplied to the job-retry Lambda. - - `github_app_parameters.id.arn`: ARN of the App-ID parameter used by the job-retry IAM policy. - - `kms_key`: Optional customer-managed KMS key used by the job-retry IAM policy. Object presence controls whether the KMS statement exists. - - `kms_key.arn`: ARN of the customer-managed KMS key. The ARN may be unknown until apply. - - `lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used. - - `lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch_size is greater than 10. - - `lambda_tags`: Tags added specifically to the job-retry Lambda function. These override `tags` values with the same key. - - `log_group_tags`: Tags added specifically to the job-retry Lambda log group. These override `tags` values with the same key. - - `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. - - `log_class`: CloudWatch log-group class for the job-retry Lambda. Supported values are `STANDARD` and `INFREQUENT_ACCESS`. - - `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with - - `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. - - `memory_size`: Memory size limit in MB of the lambda. - - `reserved_concurrent_executions`: Reserved concurrency for the lambda. Use -1 for no reservation. - - `metrics`: Configuration to enable metrics creation by the lambda. - - `metrics.enable`: Whether the job-retry Lambda emits metrics. - - `metrics.namespace`: CloudWatch namespace for metrics emitted by the job-retry Lambda. - - `metrics.metric`: Controls individual metrics emitted by the job-retry Lambda. - - `metrics.metric.enable_github_app_rate_limit`: Whether to emit GitHub App rate-limit metrics. - - `metrics.metric.enable_job_retry`: Whether to emit job-retry metrics. - - `prefix`: The prefix used for naming resources. - - `principals`: Extra principals allowed to assume the job-retry Lambda execution role, for example during local testing. - - `principals[*].type`: IAM principal type, such as `Service` or `AWS`. - - `principals[*].identifiers`: IAM principal identifiers for the selected principal type. - - `queue_encryption`: Encryption configuration for the retry queue. - - `queue_encryption.kms_data_key_reuse_period_seconds`: Length of time, in seconds, for which SQS reuses a data key. - - `queue_encryption.kms_master_key_id`: KMS key ID used to encrypt the retry queue. - - `queue_encryption.sqs_managed_sse_enabled`: Whether SQS-managed server-side encryption is enabled. - - `role_path`: The path that will be added to the role, if not set the environment name will be used. - - `role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda. - - `runner_name_prefix`: Prefix used to identify runners belonging to this runner configuration. - - `runtime`: AWS Lambda runtime. - - `s3_bucket`: S3 bucket containing the job-retry Lambda deployment package. This is an alternative to `zip`. - - `s3_key`: Object key of the job-retry Lambda deployment package. Required when `s3_bucket` is set. - - `s3_object_version`: Optional version of the job-retry Lambda deployment-package object. - - `security_group_ids`: List of security group IDs associated with the Lambda function. - - `sqs_build_queue`: SQS queue to which the job-retry Lambda republishes job requests. - - `sqs_build_queue.url`: URL of the build queue. - - `sqs_build_queue.arn`: ARN of the build queue. - - `queue_tags`: Map of tags that will be added to the retry queue and event-source mapping. - - `subnet_ids`: Subnets used for the job-retry Lambda VPC configuration. - - `tags`: Base component tags added to the Lambda function, log group, and execution role. Specialized Lambda and log-group tags override matching keys. - - `timeout`: Time out of the lambda in seconds. - - `tracing_config`: Configuration for lambda tracing. - - `tracing_config.mode`: AWS X-Ray tracing mode. A null value disables tracing. - - `tracing_config.capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests. - - `tracing_config.capture_error`: Whether Powertools tracing captures errors as tracing metadata. - - `zip`: File location of the lambda zip file. - EOF type = object({ - aws_partition = optional(string, null) - architecture = optional(string, null) - enable_organization_runners = bool - environment_variables = optional(map(string), {}) - ghes_url = optional(string, null) - user_agent = optional(string, null) - github_app_parameters = object({ - key_base64 = map(string) - id = map(string) + prefix = string + aws_partition = string + lambda = object({ + artifact = object({ + zip = string + s3 = object({ + bucket = optional(string, null) + key = optional(string, null) + object_version = optional(string, null) + }) + }) + runtime = string + architecture = string + memory_size = number + timeout = number + reserved_concurrent_executions = number + environment_variables = map(string) + vpc = object({ + subnet_ids = list(string) + security_group_ids = list(string) + }) + role = object({ + path = string + permissions_boundary = optional(string, null) + principals = list(object({ + type = string + identifiers = list(string) + })) + }) + }) + runner = object({ + name_prefix = string + }) + github = object({ + organization_runners = bool + enterprise_server = object({ + url = optional(string, null) + }) + user_agent = optional(string, null) + app_parameters = object({ + key_base64 = object({ + name = string + arn = string + }) + id = object({ + name = string + arn = string + }) + }) + }) + queue = object({ + build = object({ + url = string + arn = string + }) + event_source_mapping = object({ + batch_size = number + maximum_batching_window_in_seconds = number + }) + encryption = object({ + sqs_managed_sse_enabled = bool + kms_master_key_id = optional(string, null) + kms_data_key_reuse_period_seconds = optional(number, null) + }) }) - kms_key = optional(object({ - arn = string - }), null) - lambda_event_source_mapping_batch_size = optional(number, 10) - lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0) - lambda_tags = optional(map(string), {}) - log_group_tags = optional(map(string), {}) - log_level = optional(string, null) - log_class = optional(string, "STANDARD") - logging_kms_key_id = optional(string, null) - logging_retention_in_days = optional(number, null) - memory_size = optional(number, null) - reserved_concurrent_executions = optional(number, null) - metrics = optional(object({ - enable = optional(bool, false) - namespace = optional(string, null) - metric = optional(object({ - enable_github_app_rate_limit = optional(bool, true) - enable_job_retry = optional(bool, true) - }), {}) - }), {}) - prefix = optional(string, null) - principals = optional(list(object({ - type = string - identifiers = list(string) - })), []) - queue_encryption = optional(object({ - kms_data_key_reuse_period_seconds = optional(number, null) - kms_master_key_id = optional(string, null) - sqs_managed_sse_enabled = optional(bool, true) - }), {}) - role_path = optional(string, null) - role_permissions_boundary = optional(string, null) - runner_name_prefix = optional(string, "") - runtime = optional(string, null) - security_group_ids = optional(list(string), []) - subnet_ids = optional(list(string), []) - s3_bucket = optional(string, null) - s3_key = optional(string, null) - s3_object_version = optional(string, null) - sqs_build_queue = object({ - url = string - arn = string + ssm = object({ + kms_key = optional(object({ + arn = string + }), null) + }) + observability = object({ + logs = object({ + level = string + retention_in_days = number + kms_key_id = optional(string, null) + class = string + }) + tracing = object({ + mode = optional(string, null) + capture_http_requests = bool + capture_error = bool + }) + metrics = object({ + enable = bool + namespace = string + metric = object({ + enable_github_app_rate_limit = bool + enable_job_retry = bool + }) + }) + }) + tags = object({ + resources = map(string) + lambda = map(string) + log_group = map(string) + queue = map(string) + event_source_mapping = map(string) }) - queue_tags = optional(map(string), {}) - tags = optional(map(string), {}) - timeout = optional(number, 30) - tracing_config = optional(object({ - mode = optional(string, null) - capture_http_requests = optional(bool, false) - capture_error = optional(bool, false) - }), {}) - zip = optional(string, null) }) + nullable = false + validation { - condition = contains(["arm64", "x86_64"], coalesce(var.config.architecture, "arm64")) - error_message = "config.architecture must be arm64 or x86_64." + condition = contains(["arm64", "x86_64"], var.config.lambda.architecture) + error_message = "config.lambda.architecture must be arm64 or x86_64." } validation { @@ -142,12 +157,12 @@ variable "config" { "warn", "error", "fatal", - ], coalesce(var.config.log_level, "info")) - error_message = "config.log_level must be one of silly, trace, debug, info, warn, error, or fatal." + ], var.config.observability.logs.level) + error_message = "config.observability.logs.level must be one of silly, trace, debug, info, warn, error, or fatal." } validation { - condition = var.config.prefix == null ? false : length(var.config.prefix) + length("job-retry") <= 63 - error_message = "config.prefix is required and its length plus job-retry must be less than or equal to 63." + condition = length(var.config.prefix) + length("job-retry") <= 63 + error_message = "The length of config.prefix plus job-retry must be less than or equal to 63." } } diff --git a/modules/runner-stack/outputs.tf b/modules/runner-stack/outputs.tf index 8f2fe032fb..f554ad19f6 100644 --- a/modules/runner-stack/outputs.tf +++ b/modules/runner-stack/outputs.tf @@ -24,6 +24,6 @@ output "provider" { description = "Selected compute provider type and its provider-specific resources." value = { type = local.provider.type - ec2 = one(module.ec2[*].resources) + ec2 = local.provider.resources } } diff --git a/modules/runner-stack/pool.tf b/modules/runner-stack/pool.tf index 1b1c730604..bc4a3ce25d 100644 --- a/modules/runner-stack/pool.tf +++ b/modules/runner-stack/pool.tf @@ -56,9 +56,9 @@ module "pool" { tracing_config = var.observability.tracing runner_provider = { type = local.provider.type - environment_variables = local.provider.pool.environment_variables - iam_policy_json = local.provider.pool.iam_policy_json - managed_policy_enabled = local.provider.pool.managed_policy_enabled - managed_policy_arn = local.provider.pool.managed_policy_arn + environment_variables = local.provider.environment_variables.pool + iam_policy_json = local.provider.policies.pool.iam_policy_json + managed_policy_enabled = local.provider.policies.pool.managed_policy_enabled + managed_policy_arn = local.provider.policies.pool.managed_policy_arn } } diff --git a/modules/runner-stack/runner-role.tf b/modules/runner-stack/runner-role.tf index 52eef677fe..588cdcfa76 100644 --- a/modules/runner-stack/runner-role.tf +++ b/modules/runner-stack/runner-role.tf @@ -1,18 +1,4 @@ -# The common stack owns the runner role. The selected compute provider supplies -# the trust policy and provider-specific permission documents attached to it. -module "ec2_runner_role" { - count = local.provider_type == "ec2" ? 1 : 0 - source = "../compute-providers/ec2/runner-role" - - aws_partition = var.aws_partition - aws_region = var.aws_region - config = local.ec2 - ssm = var.ssm -} - locals { - provider_runner_role = one(module.ec2_runner_role[*]) - # Role ownership belongs to the common stack. The selected compute provider # contributes its trust and permission documents, but does not decide whether # the role is created. @@ -23,6 +9,8 @@ locals { name = local.create_runner_role ? one(aws_iam_role.runner[*].name) : basename(var.runner.iam.role.arn) } + provider_runner_policies = local.provider.policies.runner + runner_managed_policy_arns = merge( { for policy_name, policy_arn in var.runner.iam.managed_policy_arns : @@ -32,30 +20,42 @@ locals { xray = "arn:${var.aws_partition}:iam::aws:policy/AWSXRayDaemonWriteAccess" } : {}, { - for policy_name, policy_arn in try(local.provider_runner_role.managed_policy_arns, {}) : + for policy_name, policy_arn in local.provider_runner_policies.managed_policy_arns : "provider-${policy_name}" => policy_arn }, ) } +data "aws_iam_policy_document" "runner_assume_role" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = local.provider_type == "ec2" ? ["ec2.amazonaws.com"] : [] + } + } +} + resource "aws_iam_role" "runner" { count = local.create_runner_role ? 1 : 0 name = "${substr("${var.prefix}-runner", 0, 54)}-${substr(md5("${var.prefix}-runner"), 0, 8)}" - assume_role_policy = local.provider_runner_role.assume_role_policy_json + assume_role_policy = data.aws_iam_policy_document.runner_assume_role.json path = local.runner_role_path permissions_boundary = var.runner.iam.permissions_boundary tags = local.runner_tags lifecycle { precondition { - condition = try(local.ec2.instance_profile, null) == null || var.runner.iam.role != null + condition = try(var.compute_provider.ec2.instance_profile, null) == null || var.runner.iam.role != null error_message = "runner.iam.role must be set when compute_provider.ec2.instance_profile selects an external instance profile." } } } resource "aws_iam_role_policy" "runner_provider" { - for_each = local.create_runner_role ? local.provider_runner_role.inline_policies : {} + for_each = local.create_runner_role ? local.provider_runner_policies.inline_policies : {} name = each.value.name role = aws_iam_role.runner[0].name diff --git a/modules/runner-stack/scale-runners.tf b/modules/runner-stack/scale-runners.tf index 5b358a58a7..bf28050faf 100644 --- a/modules/runner-stack/scale-runners.tf +++ b/modules/runner-stack/scale-runners.tf @@ -71,16 +71,16 @@ module "scale_runners" { runner_provider = { type = local.provider.type scale_up = { - environment_variables = local.provider.scale_up.environment_variables - iam_policy_json = local.provider.scale_up.iam_policy_json - additional_iam_policy_json = local.provider.scale_up.additional_iam_policy_json - managed_policy = local.provider.scale_up.managed_policy_enabled ? { - arn = local.provider.scale_up.managed_policy_arn + environment_variables = local.provider.environment_variables.scale_up + iam_policy_json = local.provider.policies.scale_up.iam_policy_json + additional_iam_policy_json = local.provider.policies.scale_up.additional_iam_policy_json + managed_policy = local.provider.policies.scale_up.managed_policy_enabled ? { + arn = local.provider.policies.scale_up.managed_policy_arn } : null } scale_down = { - environment_variables = local.provider.scale_down.environment_variables - iam_policy_json = local.provider.scale_down.iam_policy_json + environment_variables = local.provider.environment_variables.scale_down + iam_policy_json = local.provider.policies.scale_down.iam_policy_json } } } diff --git a/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf b/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf index bab40b38d1..20bcdbef52 100644 --- a/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf +++ b/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -17,7 +17,6 @@ module "external_iam" { prefix = "computed-external" compute_provider = { - type = "ec2" ec2 = { vpc_id = "vpc-12345678" subnet_ids = ["subnet-12345678"] @@ -110,7 +109,6 @@ module "generated_policy" { prefix = "computed-policy" compute_provider = { - type = "ec2" ec2 = { vpc_id = "vpc-12345678" subnet_ids = ["subnet-12345678"] diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl index 2fe31cb426..c9c775ea27 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -22,7 +22,6 @@ variables { aws_region = "eu-west-1" compute_provider = { - type = "ec2" ec2 = { vpc_id = "vpc-12345678" subnet_ids = ["subnet-12345678"] @@ -113,6 +112,14 @@ run "plan_with_pool_enabled" { error_message = "The common runner stack must create and expose the runner role." } + assert { + condition = anytrue([ + for principal in data.aws_iam_policy_document.runner_assume_role.statement[0].principals : + principal.type == "Service" && toset(principal.identifiers) == toset(["ec2.amazonaws.com"]) + ]) + error_message = "The common runner role must use the selected EC2 provider trust relationship before EC2 consumes it." + } + assert { condition = ( output.pool != null @@ -213,7 +220,6 @@ run "external_runner_role_and_profile_remain_external" { } } compute_provider = { - type = "ec2" ec2 = { vpc_id = "vpc-12345678" subnet_ids = ["subnet-12345678"] @@ -244,7 +250,6 @@ run "external_profile_requires_external_role" { variables { compute_provider = { - type = "ec2" ec2 = { vpc_id = "vpc-12345678" subnet_ids = ["subnet-12345678"] @@ -303,7 +308,6 @@ run "requires_distribution_object_when_sync_is_enabled" { variables { compute_provider = { - type = "ec2" ec2 = { vpc_id = "vpc-12345678" subnet_ids = ["subnet-12345678"] @@ -319,13 +323,11 @@ run "requires_distribution_object_when_sync_is_enabled" { expect_failures = [var.compute_provider] } -run "rejects_unimplemented_compute_provider" { +run "rejects_empty_compute_provider" { command = plan variables { - compute_provider = { - type = "microvm" - } + compute_provider = {} } expect_failures = [var.compute_provider] diff --git a/modules/runner-stack/tests/tags.tftest.hcl b/modules/runner-stack/tests/tags.tftest.hcl index 534cf1887d..006c57ea97 100644 --- a/modules/runner-stack/tests/tags.tftest.hcl +++ b/modules/runner-stack/tests/tags.tftest.hcl @@ -27,7 +27,6 @@ variables { } compute_provider = { - type = "ec2" ec2 = { vpc_id = "vpc-12345678" subnet_ids = ["subnet-12345678"] diff --git a/modules/runner-stack/compute-provider-variables.tf b/modules/runner-stack/variables.compute-provider.tf similarity index 96% rename from modules/runner-stack/compute-provider-variables.tf rename to modules/runner-stack/variables.compute-provider.tf index 6dfd81fdca..3d0c8da129 100644 --- a/modules/runner-stack/compute-provider-variables.tf +++ b/modules/runner-stack/variables.compute-provider.tf @@ -1,10 +1,11 @@ -# Typed input boundary between the common control plane and compute implementations. +# Typed compute-provider input boundary between the common control plane and compute implementations. variable "compute_provider" { description = <<-EOT Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block. - - `type`: Compute-provider discriminator. The currently supported value is `ec2`. - - `ec2`: EC2 compute-provider configuration. This object is required when `type` is `ec2`. + Exactly one compute-provider block must be non-null. The populated block selects the provider, and its presence must be known during planning. Values inside the selected block may remain unknown until apply. + + - `ec2`: EC2 compute-provider configuration. EC2 is the only provider currently implemented. - `ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults. - `ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter. - `ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI. @@ -106,8 +107,6 @@ variable "compute_provider" { EOT type = object({ - type = string - ec2 = optional(object({ ami = optional(object({ filter = optional(map(list(string)), { state = ["available"] }) @@ -247,13 +246,11 @@ variable "compute_provider" { }) validation { - condition = contains(["ec2"], lower(trimspace(var.compute_provider.type))) - error_message = "Supported compute providers: ec2." - } - - validation { - condition = lower(trimspace(var.compute_provider.type)) != "ec2" || var.compute_provider.ec2 != null - error_message = "compute_provider.ec2 must be set when compute_provider.type is ec2." + condition = length([ + for provider_type, provider_config in var.compute_provider : provider_type + if provider_config != null + ]) == 1 + error_message = "Exactly one compute-provider block must be set. Supported compute-provider blocks: ec2." } validation { From 07b94354dc9b768953644634cd55022cd6e41933 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:09:17 +0000 Subject: [PATCH 27/27] docs: auto update terraform docs --- modules/compute-providers/ec2/README.md | 10 +++++----- modules/multi-runner/README.md | 12 ++++++------ modules/runner-stack/README.md | 12 ++++++------ modules/runner-stack/job-retry/README.md | 10 +++++----- modules/runner-stack/scale-runners/README.md | 10 +++++----- modules/runner-stack/ssm-housekeeper/README.md | 10 +++++----- 6 files changed, 32 insertions(+), 32 deletions(-) diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index b734479b47..9cfe95aeb4 100644 --- a/modules/compute-providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -10,14 +10,14 @@ EC2 is the only active compute provider. The parent stack selects it when `ec2` ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -27,7 +27,7 @@ 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 | @@ -55,7 +55,7 @@ No modules. ## 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 to construct provider-owned runner policy ARNs. | `string` | n/a | yes | | [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner stack.

- `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 stack 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.
- `enable_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)
enable_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 | @@ -69,6 +69,6 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-stack. | diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 05d340cb9b..9323cd68ae 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -121,7 +121,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -129,14 +129,14 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | | [random](#provider\_random) | ~> 3.0 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -148,7 +148,7 @@ module "multi-runner" { ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -159,7 +159,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | | [ami\_housekeeper\_lambda\_s3\_key](#input\_ami\_housekeeper\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | @@ -243,7 +243,7 @@ module "multi-runner" { ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index 9bdbd309b2..66492cefce 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -63,20 +63,20 @@ yarn run dist ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | @@ -86,7 +86,7 @@ yarn run dist ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_iam_role.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.runner_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy_attachment.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | @@ -100,7 +100,7 @@ yarn run dist ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | | [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block.

Exactly one compute-provider block must be non-null. The populated block selects the provider, and its presence must be known during planning. Values inside the selected block may remain unknown until apply.

- `ec2`: EC2 compute-provider configuration. EC2 is the only provider currently implemented.
- `ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults.
- `ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter.
- `ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource.
- `ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator.
- `ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `ec2.vpc_id`: VPC in which runner networking resources are created.
- `ec2.subnet_ids`: Subnets from which scale-up may launch runner instances.
- `ec2.overrides`: Optional resource-name overrides.
- `ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name.
- `ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name.
- `ec2.instance_profile`: Optional externally managed instance profile used by the launch template.
- `ec2.instance_profile.name`: Name of the externally managed instance profile.
- `ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the stack prefix.
- `ec2.binaries_syncer`: Runner-distribution synchronization configuration.
- `ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3.
- `ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies.
- `ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI.
- `ec2.binaries_syncer.s3.key`: Object key of the runner distribution.
- `ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it.
- `ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `ec2.block_device_mappings[].volume_type`: EBS volume type.
- `ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity.
- `ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `ec2.user_data`: Runner bootstrap user-data configuration.
- `ec2.user_data.enabled`: Enables launch-template user data.
- `ec2.user_data.template`: Optional path to a custom user-data template.
- `ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template.
- `ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances.
- `ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`.
- `ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group.
- `ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults.
- `ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing.
- `ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `ec2.key_name`: Optional EC2 key-pair name added to the launch template.
- `ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group.
- `ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `ec2.egress_rules`: Egress rules created on the managed runner security group.
- `ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `ec2.egress_rules[].from_port`: First destination port in the permitted range.
- `ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `ec2.egress_rules[].security_groups`: Destination security-group IDs.
- `ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `ec2.egress_rules[].to_port`: Last destination port in the permitted range.
- `ec2.egress_rules[].description`: Optional rule description.
- `ec2.tags`: Additional tags for runner instances, EBS volumes, network interfaces, and eligible Spot instance requests created from the launch template. They override module-level tags and the generated runner `Name`; the provider-managed `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` bootstrap tags take final precedence. These tags do not apply to static provider resources such as the launch template, security group, IAM resources, SSM parameters, or log groups.
- `ec2.metadata_options`: Instance Metadata Service configuration in the launch template.
- `ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`.
- `ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`.
- `ec2.cpu_options`: CPU topology and processor-feature configuration.
- `ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `ec2.placement`: EC2 placement configuration for runner instances.
- `ec2.placement.affinity`: Host affinity setting.
- `ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `ec2.placement.group_id`: Placement-group ID.
- `ec2.placement.group_name`: Placement-group name.
- `ec2.placement.host_id`: Dedicated Host ID.
- `ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `ec2.placement.spread_domain`: Spread-domain placement value.
- `ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `ec2.placement.partition_number`: Placement-group partition number.
- `ec2.license_specifications`: License Manager configurations added to the launch template.
- `ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. |
object({
ec2 = optional(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)
enable_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)
}), null)
})
| n/a | yes | @@ -120,7 +120,7 @@ yarn run dist ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Selected compute provider type and its provider-specific resources. | | [runner](#output\_runner) | Common runner resources. The role is null when an external runner role is used. | diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md index 5fed3b5b17..ffba2f9636 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/runner-stack/job-retry/README.md @@ -11,14 +11,14 @@ The module is an inner module used by the runner stack when the opt-in feature f ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | ## Modules @@ -28,7 +28,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_log_group.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -49,13 +49,13 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [config](#input\_config) | Provider-neutral job-retry configuration assembled by runner-stack.

- `prefix`: Prefix used to name job-retry resources.
- `aws_partition`: AWS partition used to construct the Lambda VPC managed-policy ARN.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by the job-retry Lambda.
- `lambda.architecture`: Instruction-set architecture used by the job-retry Lambda.
- `lambda.memory_size`: Memory allocated to the job-retry Lambda.
- `lambda.timeout`: Lambda timeout and retry-queue visibility timeout in seconds.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the Lambda. Use `-1` for unreserved concurrency.
- `lambda.environment_variables`: Additional Lambda environment variables. Required job-retry variables override matching keys.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the job-retry Lambda role.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the Lambda role.
- `lambda.role.principals`: Extra principals allowed to assume the Lambda role, for example during local testing.
- `runner.name_prefix`: Prefix used to identify runners belonging to this runner configuration.
- `github.organization_runners`: Enables organization runners.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Name and ARN of the GitHub App private-key parameter.
- `github.app_parameters.id`: Name and ARN of the GitHub App ID parameter.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per job-retry invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `queue.encryption`: Server-side encryption configuration for the retry queue.
- `ssm.kms_key`: Optional KMS key used by the job-retry IAM policy.
- `observability.logs`: Logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and job-retry metric configuration.
- `tags.resources`: Tags for the job-retry Lambda role and component resources.
- `tags.lambda`: Tags for the job-retry Lambda function.
- `tags.log_group`: Tags for the job-retry log group.
- `tags.queue`: Tags for the retry queue.
- `tags.event_source_mapping`: Tags for the retry-queue event-source mapping. |
object({
prefix = string
aws_partition = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
reserved_concurrent_executions = number
environment_variables = map(string)
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = list(object({
type = string
identifiers = list(string)
}))
})
})
runner = object({
name_prefix = string
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = object({
name = string
arn = string
})
id = object({
name = string
arn = string
})
})
})
queue = object({
build = object({
url = string
arn = string
})
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
encryption = object({
sqs_managed_sse_enabled = bool
kms_master_key_id = optional(string, null)
kms_data_key_reuse_period_seconds = optional(number, null)
})
})
ssm = object({
kms_key = optional(object({
arn = string
}), null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enable = bool
namespace = string
metric = object({
enable_github_app_rate_limit = bool
enable_job_retry = bool
})
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
queue = map(string)
event_source_mapping = map(string)
})
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | Queue consumed by the job-retry Lambda. | | [lambda](#output\_lambda) | Job-retry Lambda resources. | diff --git a/modules/runner-stack/scale-runners/README.md b/modules/runner-stack/scale-runners/README.md index fafc500a82..861792c99a 100644 --- a/modules/runner-stack/scale-runners/README.md +++ b/modules/runner-stack/scale-runners/README.md @@ -10,14 +10,14 @@ The module is an implementation detail of the experimental runner stack. It is c ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -27,7 +27,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | | [aws_cloudwatch_log_group.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | @@ -63,7 +63,7 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM policy ARNs. | `string` | `"aws"` | no | | [config](#input\_config) | Provider-neutral scale-up and scale-down configuration assembled by runner-stack.

- `prefix`: Prefix used to name scaling resources.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by both scaling Lambdas.
- `lambda.architecture`: Instruction-set architecture used by both scaling Lambdas.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the scaling Lambda roles.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the scaling Lambda roles.
- `runner.os`: Runner operating system used for the minimum-runtime default.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Enables or disables just-in-time runner configuration.
- `runner.labels`: Labels supplied when a runner is registered.
- `runner.group_name`: GitHub runner group used during registration.
- `runner.name_prefix`: Prefix added to registered runner names.
- `runner.maximum_count`: Maximum number of runners for this stack.
- `github.organization_runners`: Registers organization runners when true.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS verification for GitHub Enterprise Server.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Name and ARN of the GitHub App private-key parameter.
- `github.app_parameters.id`: Name and ARN of the GitHub App ID parameter.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per scale-up invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `ssm.token_path`: Parameter Store path used for registration tokens.
- `ssm.config_path`: Parameter Store path used for persistent runner configuration.
- `ssm.config_path_arn`: ARN of the persistent runner configuration path.
- `ssm.kms_key`: Optional KMS key used to decrypt shared parameters.
- `ssm.parameter_store_tags`: JSON-encoded tags applied to parameters created at runtime.
- `observability.logs`: Shared logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and GitHub rate-limit metric configuration.
- `scale_up`: Scale-up Lambda sizing, concurrency, queued-job behavior, and resolved resource tag maps.
- `scale_up.tags.resources`: Tags for the scale-up IAM role and other component resources.
- `scale_up.tags.lambda`: Tags for the scale-up Lambda function.
- `scale_up.tags.log_group`: Tags for the scale-up log group.
- `scale_up.tags.event_source_mapping`: Tags for the build-queue event-source mapping.
- `scale_down`: Scale-down Lambda sizing, schedule, idle configuration, minimum runtime, and resolved resource tag maps.
- `scale_down.tags.resources`: Tags for the scale-down IAM role and EventBridge rule.
- `scale_down.tags.lambda`: Tags for the scale-down Lambda function.
- `scale_down.tags.log_group`: Tags for the scale-down log group.
- `job_retry.enabled`: Enables publishing retry checks from scale-up.
- `job_retry.queue`: Retry queue ARN and URL. Required when job retry is enabled.
- `job_retry.max_attempts`: Maximum queued-job retry attempts.
- `job_retry.delay_in_seconds`: Initial delay before checking the queued job.
- `job_retry.delay_backoff`: Multiplier applied to subsequent delays. |
object({
prefix = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
})
})
runner = object({
os = string
auto_update_disabled = bool
ephemeral = bool
jit_config_enabled = optional(bool, null)
labels = list(string)
group_name = string
name_prefix = string
maximum_count = number
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = object({
name = string
arn = string
})
id = object({
name = string
arn = string
})
})
})
queue = object({
build = object({
arn = string
})
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key = optional(object({
arn = string
}), null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enable = bool
namespace = string
metric = object({
enable_github_app_rate_limit = bool
})
})
})
scale_up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = bool
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
event_source_mapping = map(string)
})
})
scale_down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
})
job_retry = object({
enabled = bool
max_attempts = number
delay_in_seconds = number
delay_backoff = number
queue = optional(object({
arn = string
url = string
}), null)
})
})
| n/a | yes | | [runner\_provider](#input\_runner\_provider) | Selected compute-provider integration for the scaling control plane.

- `type`: Compute-provider discriminator supplied to both Lambdas.
- `scale_up.environment_variables`: Provider-specific scale-up environment variables.
- `scale_up.iam_policy_json`: Provider-specific IAM policy merged into the common scale-up policy.
- `scale_up.additional_iam_policy_json`: Optional additional provider policy attached separately to the scale-up role.
- `scale_up.managed_policy`: Optional provider-managed policy attachment. Object presence controls attachment creation.
- `scale_up.managed_policy.arn`: ARN of the provider-managed policy. The ARN may remain unknown until apply.
- `scale_down.environment_variables`: Provider-specific scale-down environment variables.
- `scale_down.iam_policy_json`: Provider-specific IAM policy merged into the common scale-down policy. |
object({
type = string
scale_up = object({
environment_variables = map(string)
iam_policy_json = string
additional_iam_policy_json = optional(string, null)
managed_policy = optional(object({
arn = string
}), null)
})
scale_down = object({
environment_variables = map(string)
iam_policy_json = string
})
})
| n/a | yes | @@ -71,7 +71,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [scale\_down](#output\_scale\_down) | Scale-down Lambda resources. | | [scale\_up](#output\_scale\_up) | Scale-up Lambda resources. | diff --git a/modules/runner-stack/ssm-housekeeper/README.md b/modules/runner-stack/ssm-housekeeper/README.md index 2a98b3d16d..4cc0af9d63 100644 --- a/modules/runner-stack/ssm-housekeeper/README.md +++ b/modules/runner-stack/ssm-housekeeper/README.md @@ -10,14 +10,14 @@ The module is an implementation detail of the experimental runner stack. It is c ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -27,7 +27,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | | [aws_cloudwatch_log_group.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | @@ -46,12 +46,12 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [config](#input\_config) | Provider-neutral SSM housekeeper configuration assembled by runner-stack.

- `prefix`: Prefix used to name the housekeeper resources.
- `aws_partition`: AWS partition used to construct IAM policy ARNs.
- `schedule.expression`: EventBridge schedule expression that invokes the housekeeper.
- `schedule.state`: State of the EventBridge rule.
- `cleanup.token_path`: Parameter Store token path supplied to the Lambda.
- `cleanup.parameter_path_arn`: IAM resource ARN matching `cleanup.token_path`.
- `cleanup.minimum_days_old`: Minimum parameter age before deletion.
- `cleanup.dry_run`: Reports eligible parameters without deleting them when true.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by the housekeeper Lambda.
- `lambda.architecture`: Instruction-set architecture used by the housekeeper Lambda.
- `lambda.memory_size`: Memory allocated to the housekeeper Lambda.
- `lambda.timeout`: Housekeeper Lambda timeout in seconds.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the housekeeper Lambda role.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the housekeeper role.
- `observability.logs`: Logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `tags.resources`: Tags for the housekeeper role and EventBridge rule.
- `tags.lambda`: Tags for the housekeeper Lambda function.
- `tags.log_group`: Tags for the housekeeper log group. |
object({
prefix = string
aws_partition = string
schedule = object({
expression = string
state = string
})
cleanup = object({
token_path = string
parameter_path_arn = string
minimum_days_old = number
dry_run = bool
})
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
})
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [housekeeper](#output\_housekeeper) | SSM housekeeper Lambda resources. |